1use std::io::{Read, Write};
2
3use crate::limits::{MAX_PARAMS, MAX_SQL_BYTES};
4use crate::types::{Permission, QueryOptions, SessionConfig};
5use agent_first_data::{cli_parse_log_filters, cli_parse_output, OutputFormat};
6use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
7use serde_json::{json, Value};
8use std::collections::{btree_map::Entry, BTreeMap};
9
10const STARTUP_ENV_KEYS: &[&str] = &[
11 "AFPSQL_DSN_SECRET",
12 "AFPSQL_CONNINFO_SECRET",
13 "AFPSQL_HOST",
14 "AFPSQL_PORT",
15 "AFPSQL_USER",
16 "AFPSQL_DBNAME",
17 "AFPSQL_PASSWORD_SECRET",
18 "AFPSQL_SSH",
19 "AFPSQL_SSH_LOCAL_HOST",
20 "AFPSQL_SSH_LOCAL_PORT",
21 "AFPSQL_SSH_REMOTE_SOCKET",
22 "AFPSQL_SSH_SUDO_USER",
23 "PGHOST",
24 "PGPORT",
25 "PGUSER",
26 "PGDATABASE",
27 "PGPASSWORD",
28 "PGSSLMODE",
29];
30
31pub enum Mode {
32 Cli(CliRequest),
33 Pipe(PipeInit),
34 PsqlAdmin(PsqlAdminRequest),
35 SkillAdmin(SkillAdminRequest),
36 PsqlUnsupported(PsqlUnsupportedRequest),
37}
38
39pub struct PipeInit {
40 pub output: OutputFormat,
41 pub session: SessionConfig,
42 pub log: Vec<String>,
43 pub startup_args: Value,
44 pub startup_env: Value,
45 pub startup_requested: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct PsqlAdminRequest {
50 pub action: PsqlAdminAction,
51 pub output: OutputFormat,
52}
53
54#[derive(Debug, Clone)]
55pub enum PsqlAdminAction {
56 Status { bin_dir: Option<String> },
57 Install { bin_dir: Option<String> },
58 Uninstall { bin_dir: Option<String> },
59}
60
61#[derive(Debug, Clone)]
62pub struct SkillAdminRequest {
63 pub action: SkillAdminAction,
64 pub output: OutputFormat,
65}
66
67#[derive(Debug, Clone)]
68pub enum SkillAdminAction {
69 Status(SkillAdminOptions),
70 Install(SkillAdminOptions),
71 Uninstall(SkillAdminOptions),
72}
73
74#[derive(Debug, Clone)]
75pub struct SkillAdminOptions {
76 pub agent: SkillAgentSelection,
77 pub scope: SkillScope,
78 pub skills_dir: Option<String>,
79 pub force: bool,
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
83pub enum SkillAgentSelection {
84 All,
86 Codex,
88 #[value(name = "claude-code", alias = "claude")]
90 ClaudeCode,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
94pub enum SkillScope {
95 Personal,
97 Project,
99}
100
101pub struct CliRequest {
102 pub sql: String,
103 pub params: Vec<Value>,
104 pub options: QueryOptions,
105 pub session: SessionConfig,
106 pub output: OutputFormat,
107 pub output_file: Option<String>,
108 pub log_file: Option<String>,
109 pub log: Vec<String>,
110 pub startup_args: Value,
111 pub startup_env: Value,
112 pub startup_requested: bool,
113 pub dry_run: bool,
114}
115
116pub struct PsqlUnsupportedRequest {
117 pub reason: String,
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
121enum RuntimeMode {
122 Cli,
123 Pipe,
124 #[value(name = "psql")]
125 Psql,
126}
127
128#[derive(Subcommand)]
129enum AfdCommand {
130 Psql(PsqlCommand),
132 Skill(SkillCommand),
134}
135
136#[derive(Args)]
137struct PsqlCommand {
138 #[command(subcommand)]
139 action: PsqlCliAction,
140}
141
142#[derive(Subcommand)]
143enum PsqlCliAction {
144 Status(PsqlPathArgs),
146 Install(PsqlPathArgs),
148 Uninstall(PsqlPathArgs),
150}
151
152#[derive(Args)]
153struct PsqlPathArgs {
154 #[arg(long = "bin-dir")]
156 bin_dir: Option<String>,
157}
158
159#[derive(Args)]
160struct SkillCommand {
161 #[command(subcommand)]
162 action: SkillCliAction,
163}
164
165#[derive(Subcommand)]
166enum SkillCliAction {
167 Status(SkillTargetArgs),
169 Install(SkillWriteArgs),
171 Uninstall(SkillWriteArgs),
173}
174
175#[derive(Args)]
176struct SkillTargetArgs {
177 #[arg(long = "agent", value_enum, default_value_t = SkillAgentSelection::All)]
179 agent: SkillAgentSelection,
180 #[arg(long = "scope", value_enum, default_value_t = SkillScope::Personal)]
182 scope: SkillScope,
183 #[arg(long = "skills-dir")]
185 skills_dir: Option<String>,
186}
187
188#[derive(Args)]
189struct SkillWriteArgs {
190 #[command(flatten)]
191 target: SkillTargetArgs,
192 #[arg(long)]
194 force: bool,
195}
196
197#[doc = r#"Agent-First PostgreSQL client.
198
199`afpsql` gives agents a reliable PostgreSQL contract: structured stdout
200events, explicit write permissions, stable pipe sessions, and machine-readable
201failures.
202
203### Interface Policy
204
205- default mode is canonical agent-first CLI
206- `--mode psql` is argument translation only; runtime output stays JSONL
207- stdout carries protocol events; stderr is not a protocol channel
208- native CLI and pipe mode default to read-only transactions; writes require permission
209
210### Query Sources and Parameters
211
212- use `--sql` for inline SQL or `--sql-file` for a file
213- use repeatable `--param N=value` for positional binds
214- placeholder count is validated from prepared-statement metadata, not by SQL text scanning
215
216### Connection Sources
217
218- `--dsn-secret` for a PostgreSQL URI
219- `--conninfo-secret` for libpq-style conninfo
220- or discrete `--host`, `--port`, `--user`, `--dbname`, `--password-secret`
221- add `--ssh user@server` when PostgreSQL is reachable only from the server
222- agent-first environment fallbacks: `AFPSQL_*`
223- PostgreSQL environment fallbacks: `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`, `PGPASSWORD`, `PGSSLMODE`
224
225### Result Shaping
226
227- default mode buffers a bounded inline result
228- use `--stream-rows` for large result sets, with `--batch-rows` and `--batch-bytes` to tune chunk size
229- `--output json|yaml|plain` changes rendering only, not the runtime schema
230
231### Examples
232
233```text
234afpsql --sql "select now() as now_rfc3339"
235afpsql --sql-file ./query.sql
236afpsql --sql "select * from users where id = $1" --param 1=123
237afpsql --dsn-secret-env DATABASE_URL --sql "select 1"
238afpsql --ssh user@server --host 127.0.0.1 --port 5432 --user app --dbname appdb --sql "select 1"
239afpsql --mode psql -h 127.0.0.1 -p 5432 -U app -d appdb -c "select 1"
240afpsql --sql "select * from big_table" --stream-rows --batch-rows 1000
241afpsql --mode pipe
242afpsql psql status
243afpsql psql install
244afpsql skill status
245afpsql skill install
246```
247
248### Exit Codes
249
250- `0`: query completed successfully
251- `1`: SQL error or runtime error
252- `2`: invalid CLI arguments
253"#]
254#[derive(Parser)]
255#[command(name = "afpsql", version, verbatim_doc_comment)]
256pub struct AfdCli {
257 #[arg(long, allow_hyphen_values = true, help_heading = "Query")]
259 sql: Option<String>,
260 #[arg(long = "sql-file", allow_hyphen_values = true, help_heading = "Query")]
262 sql_file: Option<String>,
263 #[arg(long = "param", help_heading = "Query")]
265 param: Vec<String>,
266 #[arg(long = "stream-rows", help_heading = "Query")]
268 stream_rows: bool,
269 #[arg(long = "batch-rows", help_heading = "Query")]
271 batch_rows: Option<usize>,
272 #[arg(long = "batch-bytes", help_heading = "Query")]
274 batch_bytes: Option<usize>,
275 #[arg(long = "statement-timeout-ms", help_heading = "Query")]
277 statement_timeout_ms: Option<u64>,
278 #[arg(long = "lock-timeout-ms", help_heading = "Query")]
280 lock_timeout_ms: Option<u64>,
281 #[arg(long = "inline-max-rows", help_heading = "Query")]
283 inline_max_rows: Option<usize>,
284 #[arg(long = "inline-max-bytes", help_heading = "Query")]
286 inline_max_bytes: Option<usize>,
287 #[arg(long = "permission", value_parser = parse_permission_arg, help_heading = "Query")]
289 permission: Option<Permission>,
290 #[arg(long, help_heading = "Query")]
292 dry_run: bool,
293
294 #[arg(long = "dsn-secret", help_heading = "Connection")]
296 dsn_secret: Option<String>,
297 #[arg(long = "dsn-secret-env", help_heading = "Connection")]
299 dsn_secret_env: Option<String>,
300 #[arg(long = "conninfo-secret", help_heading = "Connection")]
302 conninfo_secret: Option<String>,
303 #[arg(long, help_heading = "Connection")]
305 host: Option<String>,
306 #[arg(long, help_heading = "Connection")]
308 port: Option<u16>,
309 #[arg(long, help_heading = "Connection")]
311 user: Option<String>,
312 #[arg(long, help_heading = "Connection")]
314 dbname: Option<String>,
315 #[arg(long = "password-secret", help_heading = "Connection")]
317 password_secret: Option<String>,
318 #[arg(long = "password-secret-env", help_heading = "Connection")]
320 password_secret_env: Option<String>,
321 #[arg(long = "ssh", help_heading = "SSH Transport")]
323 ssh: Option<String>,
324 #[arg(long = "ssh-option", help_heading = "SSH Transport")]
326 ssh_options: Vec<String>,
327 #[arg(long = "ssh-local-host", help_heading = "SSH Transport")]
329 ssh_local_host: Option<String>,
330 #[arg(long = "ssh-local-port", help_heading = "SSH Transport")]
332 ssh_local_port: Option<u16>,
333 #[arg(long = "ssh-remote-socket", help_heading = "SSH Transport")]
335 ssh_remote_socket: Option<String>,
336 #[arg(long = "ssh-sudo-user", help_heading = "SSH Transport")]
338 ssh_sudo_user: Option<String>,
339
340 #[arg(long, default_value = "json", global = true, help_heading = "Runtime")]
342 output: String,
343 #[arg(long = "log", value_delimiter = ',', help_heading = "Runtime")]
345 log: Vec<String>,
346 #[arg(long, value_enum, default_value_t = RuntimeMode::Cli, help_heading = "Runtime")]
348 mode: RuntimeMode,
349
350 #[command(subcommand)]
351 command: Option<AfdCommand>,
352}
353
354pub fn parse_args() -> Result<Mode, String> {
355 let raw: Vec<String> = std::env::args().collect();
356 if is_psql_mode_requested(&raw) {
357 return parse_psql_mode(&raw);
358 }
359 let startup_requested = startup_requested_from_raw(&raw);
360
361 if top_level_help_requested(&raw) {
363 let _ = writeln!(
364 std::io::stdout(),
365 "{}",
366 agent_first_data::cli_render_help(&AfdCli::command(), &[])
367 );
368 std::process::exit(0);
369 }
370 if top_level_help_markdown_requested(&raw) {
372 let _ = writeln!(
373 std::io::stdout(),
374 "{}",
375 agent_first_data::cli_render_help_markdown(&AfdCli::command(), &[])
376 );
377 std::process::exit(0);
378 }
379
380 let cli = match AfdCli::try_parse_from(&raw) {
381 Ok(c) => c,
382 Err(e) => {
383 use clap::error::ErrorKind;
384 if matches!(e.kind(), ErrorKind::DisplayVersion | ErrorKind::DisplayHelp) {
385 let _ = writeln!(std::io::stdout(), "{e}");
386 std::process::exit(0);
387 }
388 return Err(e.to_string());
389 }
390 };
391 let output = parse_output(&cli.output)?;
392 let log = parse_log_categories(&cli.log);
393 let dsn_secret = resolve_secret_value(
394 "--dsn-secret",
395 cli.dsn_secret,
396 cli.dsn_secret_env.as_deref(),
397 )?;
398 let password_secret = resolve_secret_value(
399 "--password-secret",
400 cli.password_secret,
401 cli.password_secret_env.as_deref(),
402 )?;
403 let session = SessionConfig {
404 dsn_secret,
405 conninfo_secret: cli.conninfo_secret,
406 host: cli.host,
407 port: cli.port,
408 user: cli.user,
409 dbname: cli.dbname,
410 password_secret,
411 ssh: cli.ssh.or_else(|| std::env::var("AFPSQL_SSH").ok()),
412 ssh_options: cli.ssh_options,
413 ssh_local_host: cli
414 .ssh_local_host
415 .or_else(|| std::env::var("AFPSQL_SSH_LOCAL_HOST").ok()),
416 ssh_local_port: cli.ssh_local_port.or_else(|| {
417 std::env::var("AFPSQL_SSH_LOCAL_PORT")
418 .ok()
419 .and_then(|v| v.parse().ok())
420 }),
421 ssh_remote_socket: cli
422 .ssh_remote_socket
423 .or_else(|| std::env::var("AFPSQL_SSH_REMOTE_SOCKET").ok()),
424 ssh_sudo_user: cli
425 .ssh_sudo_user
426 .or_else(|| std::env::var("AFPSQL_SSH_SUDO_USER").ok()),
427 };
428 let mode_name = match cli.mode {
429 RuntimeMode::Cli => "cli",
430 RuntimeMode::Pipe => "pipe",
431 RuntimeMode::Psql => "psql",
432 };
433 let startup_env = startup_env_snapshot();
434
435 if let Some(command) = cli.command {
436 return Ok(match command {
437 AfdCommand::Psql(psql) => Mode::PsqlAdmin(PsqlAdminRequest {
438 action: psql_admin_action(psql.action),
439 output,
440 }),
441 AfdCommand::Skill(skill) => Mode::SkillAdmin(SkillAdminRequest {
442 action: skill_admin_action(skill.action),
443 output,
444 }),
445 });
446 }
447
448 match cli.mode {
449 RuntimeMode::Pipe => {
450 return Ok(Mode::Pipe(PipeInit {
451 output,
452 session,
453 log: log.clone(),
454 startup_args: startup_args(mode_name, None, None, 0),
455 startup_env,
456 startup_requested,
457 }));
458 }
459 RuntimeMode::Cli | RuntimeMode::Psql => {}
460 }
461
462 let startup_sql_file = cli.sql_file.clone();
463 let sql = load_sql(cli.sql, cli.sql_file)?;
464 let params = parse_params(&cli.param)?;
465 let startup_args = startup_args(
466 mode_name,
467 Some(&sql),
468 startup_sql_file.as_deref(),
469 params.len(),
470 );
471
472 let options = QueryOptions {
473 stream_rows: cli.stream_rows,
474 batch_rows: cli.batch_rows,
475 batch_bytes: cli.batch_bytes,
476 statement_timeout_ms: cli.statement_timeout_ms,
477 lock_timeout_ms: cli.lock_timeout_ms,
478 permission: cli.permission,
479 inline_max_rows: cli.inline_max_rows,
480 inline_max_bytes: cli.inline_max_bytes,
481 };
482
483 Ok(Mode::Cli(CliRequest {
484 sql,
485 params,
486 options,
487 session,
488 output,
489 output_file: None,
490 log_file: None,
491 log,
492 startup_args,
493 startup_env,
494 startup_requested,
495 dry_run: cli.dry_run,
496 }))
497}
498
499fn parse_psql_mode(raw: &[String]) -> Result<Mode, String> {
500 let startup_requested = startup_requested_from_raw(raw);
501 let mut state = PsqlModeState::default();
502
503 let mut i = 1usize;
504 while i < raw.len() {
505 let arg = raw[i].as_str();
506 if arg == "--" {
507 i += 1;
508 while i < raw.len() {
509 state.positionals.push(raw[i].clone());
510 i += 1;
511 }
512 break;
513 }
514 if arg.starts_with("--") {
515 parse_psql_long_arg(raw, &mut i, &mut state)?;
516 continue;
517 }
518 if arg.starts_with('-') && arg.len() > 1 {
519 parse_psql_short_arg(raw, &mut i, &mut state)?;
520 continue;
521 }
522 state.positionals.push(raw[i].clone());
523 i += 1;
524 }
525
526 if let Some(reason) = state.interactive_reason {
527 return Ok(Mode::PsqlUnsupported(PsqlUnsupportedRequest { reason }));
528 }
529
530 apply_psql_positionals(&mut state)?;
531 if state.list_databases {
532 state.sql = Some(psql_list_databases_sql());
533 state.sql_file = None;
534 }
535 if state.sql.is_none() && state.sql_file.is_none() {
536 return Ok(Mode::PsqlUnsupported(PsqlUnsupportedRequest {
537 reason: "no -c/--command, -f/--file, or -l/--list was provided".to_string(),
538 }));
539 }
540
541 let dsn_secret = resolve_secret_value(
542 "--dsn-secret",
543 state.dsn_secret,
544 state.dsn_secret_env.as_deref(),
545 )?;
546 let password_secret = resolve_secret_value(
547 "--password-secret",
548 state.password_secret,
549 state.password_secret_env.as_deref(),
550 )?;
551 let session = SessionConfig {
552 dsn_secret,
553 conninfo_secret: state.conninfo_secret,
554 host: state.host,
555 port: state.port,
556 user: state.user,
557 dbname: state.dbname,
558 password_secret,
559 ssh: None,
560 ssh_options: vec![],
561 ssh_local_host: None,
562 ssh_local_port: None,
563 ssh_remote_socket: None,
564 ssh_sudo_user: None,
565 };
566
567 let startup_sql_file = state.sql_file.clone();
568 let sql = load_sql(state.sql, state.sql_file)?;
569 let params = parse_params(&state.params_kv)?;
570 let startup_args = psql_startup_args(PsqlStartupArgs {
571 mode: "psql",
572 sql: Some(&sql),
573 sql_file: startup_sql_file,
574 param_count: params.len(),
575 });
576 Ok(Mode::Cli(CliRequest {
577 sql,
578 params,
579 options: QueryOptions {
580 permission: Some(Permission::Write),
581 ..Default::default()
582 },
583 session,
584 output: state.output,
585 output_file: state.output_file,
586 log_file: state.log_file,
587 log: parse_log_categories(&state.log_entries),
588 startup_args,
589 startup_env: startup_env_snapshot(),
590 startup_requested,
591 dry_run: false,
592 }))
593}
594
595struct PsqlModeState {
596 sql: Option<String>,
597 sql_file: Option<String>,
598 host: Option<String>,
599 port: Option<u16>,
600 user: Option<String>,
601 dbname: Option<String>,
602 dsn_secret: Option<String>,
603 dsn_secret_env: Option<String>,
604 conninfo_secret: Option<String>,
605 password_secret: Option<String>,
606 password_secret_env: Option<String>,
607 params_kv: Vec<String>,
608 output: OutputFormat,
609 log_entries: Vec<String>,
610 output_file: Option<String>,
611 log_file: Option<String>,
612 list_databases: bool,
613 positionals: Vec<String>,
614 interactive_reason: Option<String>,
615}
616
617impl Default for PsqlModeState {
618 fn default() -> Self {
619 Self {
620 sql: None,
621 sql_file: None,
622 host: None,
623 port: None,
624 user: None,
625 dbname: None,
626 dsn_secret: None,
627 dsn_secret_env: None,
628 conninfo_secret: None,
629 password_secret: None,
630 password_secret_env: None,
631 params_kv: vec![],
632 output: OutputFormat::Json,
633 log_entries: vec![],
634 output_file: None,
635 log_file: None,
636 list_databases: false,
637 positionals: vec![],
638 interactive_reason: None,
639 }
640 }
641}
642
643impl PsqlModeState {
644 fn set_sql(&mut self, sql: String, flag: &str) -> Result<(), String> {
645 if self.sql.is_some() || self.sql_file.is_some() {
646 return Err(format!(
647 "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
648 ));
649 }
650 self.sql = Some(sql);
651 Ok(())
652 }
653
654 fn set_sql_file(&mut self, path: String, flag: &str) -> Result<(), String> {
655 if self.sql.is_some() || self.sql_file.is_some() {
656 return Err(format!(
657 "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
658 ));
659 }
660 self.sql_file = Some(path);
661 Ok(())
662 }
663}
664
665fn parse_psql_long_arg(
666 raw: &[String],
667 i: &mut usize,
668 state: &mut PsqlModeState,
669) -> Result<(), String> {
670 let arg = raw[*i].as_str();
671 if arg == "--mode" {
672 let value = take_arg_value(raw, i, "--mode")?;
673 if value != "psql" {
674 return Err(format!(
675 "unsupported psql-mode argument: --mode {value}; only --mode psql is allowed with psql translation"
676 ));
677 }
678 return Ok(());
679 }
680 if let Some(value) = arg.strip_prefix("--mode=") {
681 if value != "psql" {
682 return Err(format!(
683 "unsupported psql-mode argument: {arg}; only --mode=psql is allowed with psql translation"
684 ));
685 }
686 *i += 1;
687 return Ok(());
688 }
689
690 if arg == "--help" || arg.starts_with("--help=") {
691 emit_psql_mode_help();
692 std::process::exit(0);
693 }
694 if arg == "--version" {
695 emit_psql_mode_version();
696 std::process::exit(0);
697 }
698
699 match long_name(arg) {
700 "--command" => {
701 let value = take_long_arg_value(raw, i, "--command")?;
702 state.set_sql(value, "--command")
703 }
704 "--file" => {
705 let value = take_long_arg_value(raw, i, "--file")?;
706 state.set_sql_file(value, "--file")
707 }
708 "--host" => {
709 state.host = Some(take_long_arg_value(raw, i, "--host")?);
710 Ok(())
711 }
712 "--port" => {
713 state.port = Some(parse_port(
714 &take_long_arg_value(raw, i, "--port")?,
715 "--port",
716 )?);
717 Ok(())
718 }
719 "--username" | "--user" => {
720 state.user = Some(take_long_arg_value(raw, i, long_name(arg))?);
721 Ok(())
722 }
723 "--dbname" => {
724 apply_dbname_value(state, take_long_arg_value(raw, i, "--dbname")?);
725 Ok(())
726 }
727 "--set" | "--variable" => {
728 let value = take_long_arg_value(raw, i, long_name(arg))?;
729 add_psql_variable(state, value)
730 }
731 "--list" => {
732 state.list_databases = true;
733 *i += 1;
734 Ok(())
735 }
736 "--no-password"
737 | "--no-psqlrc"
738 | "--no-readline"
739 | "--quiet"
740 | "--echo-all"
741 | "--echo-errors"
742 | "--echo-queries"
743 | "--echo-hidden"
744 | "--no-align"
745 | "--csv"
746 | "--html"
747 | "--tuples-only"
748 | "--expanded"
749 | "--field-separator-zero"
750 | "--record-separator-zero"
751 | "--single-transaction" => {
752 *i += 1;
753 Ok(())
754 }
755 "--field-separator" | "--record-separator" | "--pset" | "--table-attr" => {
756 let _ = take_long_arg_value(raw, i, long_name(arg))?;
757 Ok(())
758 }
759 "--password" => {
760 state.interactive_reason =
761 Some("--password/-W requests an interactive password prompt".to_string());
762 *i += 1;
763 Ok(())
764 }
765 "--single-step" => {
766 state.interactive_reason =
767 Some("--single-step/-s requires interactive command confirmation".to_string());
768 *i += 1;
769 Ok(())
770 }
771 "--single-line" => {
772 state.interactive_reason =
773 Some("--single-line/-S is a human-interactive input mode".to_string());
774 *i += 1;
775 Ok(())
776 }
777 "--dsn-secret" => {
778 state.dsn_secret = Some(take_long_arg_value(raw, i, "--dsn-secret")?);
779 Ok(())
780 }
781 "--dsn-secret-env" => {
782 state.dsn_secret_env = Some(take_long_arg_value(raw, i, "--dsn-secret-env")?);
783 Ok(())
784 }
785 "--conninfo-secret" => {
786 state.conninfo_secret = Some(take_long_arg_value(raw, i, "--conninfo-secret")?);
787 Ok(())
788 }
789 "--password-secret" => {
790 state.password_secret = Some(take_long_arg_value(raw, i, "--password-secret")?);
791 Ok(())
792 }
793 "--password-secret-env" => {
794 state.password_secret_env = Some(take_long_arg_value(raw, i, "--password-secret-env")?);
795 Ok(())
796 }
797 "--output" => {
798 let value = take_long_arg_value(raw, i, "--output")?;
799 if let Ok(format) = parse_output(&value) {
800 state.output = format;
801 } else {
802 state.output_file = Some(value);
803 }
804 Ok(())
805 }
806 "--output-format" => {
807 let value = take_long_arg_value(raw, i, long_name(arg))?;
808 state.output = parse_output(&value)?;
809 Ok(())
810 }
811 "--log" => {
812 let values = take_long_arg_value(raw, i, "--log")?;
813 add_log_entries(state, &values);
814 Ok(())
815 }
816 "--log-file" => {
817 state.log_file = Some(take_long_arg_value(raw, i, "--log-file")?);
818 Ok(())
819 }
820 _ => Err(format!("unsupported psql-mode argument: {arg}")),
821 }
822}
823
824fn parse_psql_short_arg(
825 raw: &[String],
826 i: &mut usize,
827 state: &mut PsqlModeState,
828) -> Result<(), String> {
829 let arg = raw[*i].as_str();
830 let mut offset = 1usize;
831 while offset < arg.len() {
832 let flag = arg.as_bytes()[offset] as char;
833 offset += 1;
834 match flag {
835 '?' => {
836 emit_psql_mode_help();
837 std::process::exit(0);
838 }
839 'V' => {
840 emit_psql_mode_version();
841 std::process::exit(0);
842 }
843 'c' => {
844 let value = take_short_arg_value(raw, i, arg, offset, "-c")?;
845 return state.set_sql(value, "-c");
846 }
847 'f' => {
848 let value = take_short_arg_value(raw, i, arg, offset, "-f")?;
849 return state.set_sql_file(value, "-f");
850 }
851 'h' => {
852 state.host = Some(take_short_arg_value(raw, i, arg, offset, "-h")?);
853 return Ok(());
854 }
855 'p' => {
856 let value = take_short_arg_value(raw, i, arg, offset, "-p")?;
857 state.port = Some(parse_port(&value, "-p")?);
858 return Ok(());
859 }
860 'U' => {
861 state.user = Some(take_short_arg_value(raw, i, arg, offset, "-U")?);
862 return Ok(());
863 }
864 'd' => {
865 apply_dbname_value(state, take_short_arg_value(raw, i, arg, offset, "-d")?);
866 return Ok(());
867 }
868 'v' => {
869 let value = take_short_arg_value(raw, i, arg, offset, "-v")?;
870 return add_psql_variable(state, value);
871 }
872 'F' | 'P' | 'R' | 'T' => {
873 let _ = take_short_arg_value(raw, i, arg, offset, &format!("-{flag}"))?;
874 return Ok(());
875 }
876 'L' => {
877 state.log_file = Some(take_short_arg_value(raw, i, arg, offset, "-L")?);
878 return Ok(());
879 }
880 'o' => {
881 state.output_file = Some(take_short_arg_value(raw, i, arg, offset, "-o")?);
882 return Ok(());
883 }
884 'l' => state.list_databases = true,
885 'W' => {
886 state.interactive_reason =
887 Some("--password/-W requests an interactive password prompt".to_string());
888 }
889 's' => {
890 state.interactive_reason =
891 Some("--single-step/-s requires interactive command confirmation".to_string());
892 }
893 'S' => {
894 state.interactive_reason =
895 Some("--single-line/-S is a human-interactive input mode".to_string());
896 }
897 'a' | 'A' | 'b' | 'e' | 'E' | 'H' | 'n' | 'q' | 't' | 'w' | 'x' | 'X' | 'z' | '0'
898 | '1' => {}
899 _ => return Err(format!("unsupported psql-mode argument: -{flag}")),
900 }
901 }
902 *i += 1;
903 Ok(())
904}
905
906fn long_name(arg: &str) -> &str {
907 arg.split_once('=').map(|(name, _)| name).unwrap_or(arg)
908}
909
910fn take_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
911 *i += 1;
912 let value = raw
913 .get(*i)
914 .ok_or_else(|| format!("{flag} requires value"))?
915 .clone();
916 *i += 1;
917 Ok(value)
918}
919
920fn take_long_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
921 let arg = raw[*i].as_str();
922 if let Some((_, value)) = arg.split_once('=') {
923 *i += 1;
924 return Ok(value.to_string());
925 }
926 take_arg_value(raw, i, flag)
927}
928
929fn take_short_arg_value(
930 raw: &[String],
931 i: &mut usize,
932 arg: &str,
933 offset: usize,
934 flag: &str,
935) -> Result<String, String> {
936 if offset < arg.len() {
937 let value = arg[offset..].to_string();
938 *i += 1;
939 return Ok(value);
940 }
941 take_arg_value(raw, i, flag)
942}
943
944fn parse_port(value: &str, flag: &str) -> Result<u16, String> {
945 value.parse().map_err(|_| format!("invalid {flag} port"))
946}
947
948fn add_log_entries(state: &mut PsqlModeState, values: &str) {
949 for part in values.split(',') {
950 let trimmed = part.trim();
951 if !trimmed.is_empty() {
952 state.log_entries.push(trimmed.to_string());
953 }
954 }
955}
956
957fn add_psql_variable(state: &mut PsqlModeState, value: String) -> Result<(), String> {
958 let name = value
959 .split_once('=')
960 .map(|(name, _)| name)
961 .unwrap_or(value.as_str());
962 if name.parse::<usize>().is_ok() {
963 if value.contains('=') {
964 state.params_kv.push(value);
965 return Ok(());
966 }
967 return Err(format!("invalid param '{value}', expected N=value"));
968 }
969 if is_psql_behavior_variable(name) {
970 return Ok(());
971 }
972 Err(format!(
973 "invalid or unsupported psql variable '{name}'; afpsql supports numeric -v N=value bind parameters, not client-side :name interpolation"
974 ))
975}
976
977fn is_psql_behavior_variable(name: &str) -> bool {
978 matches!(
979 name.to_ascii_uppercase().as_str(),
980 "ON_ERROR_STOP"
981 | "ON_ERROR_ROLLBACK"
982 | "QUIET"
983 | "ECHO"
984 | "ECHO_HIDDEN"
985 | "FETCH_COUNT"
986 | "VERBOSITY"
987 | "SHOW_CONTEXT"
988 | "HISTCONTROL"
989 | "HISTFILE"
990 | "HISTSIZE"
991 | "IGNOREEOF"
992 | "PAGER"
993 | "COLUMNS"
994 )
995}
996
997fn apply_psql_positionals(state: &mut PsqlModeState) -> Result<(), String> {
998 let positionals = std::mem::take(&mut state.positionals);
999 for value in positionals {
1000 if is_postgres_uri(&value) {
1001 state.dsn_secret = Some(value);
1002 continue;
1003 }
1004 if looks_like_conninfo(&value) {
1005 state.conninfo_secret = Some(value);
1006 continue;
1007 }
1008 if state.dbname.is_none() {
1009 state.dbname = Some(value);
1010 continue;
1011 }
1012 if state.user.is_none() {
1013 state.user = Some(value);
1014 continue;
1015 }
1016 return Err(format!("too many positional psql arguments: {value}"));
1017 }
1018 Ok(())
1019}
1020
1021fn apply_dbname_value(state: &mut PsqlModeState, value: String) {
1022 if is_postgres_uri(&value) {
1023 state.dsn_secret = Some(value);
1024 } else if looks_like_conninfo(&value) {
1025 state.conninfo_secret = Some(value);
1026 } else {
1027 state.dbname = Some(value);
1028 }
1029}
1030
1031fn is_postgres_uri(value: &str) -> bool {
1032 value.starts_with("postgresql://") || value.starts_with("postgres://")
1033}
1034
1035fn looks_like_conninfo(value: &str) -> bool {
1036 value.contains('=')
1037}
1038
1039fn psql_list_databases_sql() -> String {
1040 "select datname as name from pg_catalog.pg_database where datallowconn order by datname"
1041 .to_string()
1042}
1043
1044fn emit_psql_mode_version() {
1045 let _ = writeln!(
1046 std::io::stdout(),
1047 "psql (afpsql wrapper) {}",
1048 env!("CARGO_PKG_VERSION")
1049 );
1050}
1051
1052fn emit_psql_mode_help() {
1053 let _ = writeln!(
1054 std::io::stdout(),
1055 "psql (afpsql wrapper) {}\n\
1056Usage:\n psql [OPTION]... [DBNAME [USERNAME]]\n\n\
1057Supported non-interactive forms:\n -c, --command=SQL\n -f, --file=FILE\n -l, --list\n -h/-p/-U/-d and --host/--port/--username/--dbname\n -v N=value, --set N=value for positional bind parameters\n\n\
1058Output routing:\n -o, --output=FILE writes structured output to FILE\n -L, --log-file=FILE tees structured output to FILE\n --output-format=json|yaml|plain changes afpsql rendering\n\n\
1059Human-interactive psql modes and psql meta-commands are not supported by this wrapper.",
1060 env!("CARGO_PKG_VERSION")
1061 );
1062}
1063
1064fn top_level_help_requested(raw: &[String]) -> bool {
1065 raw.len() == 2 && matches!(raw.get(1).map(String::as_str), Some("--help" | "-h"))
1066}
1067
1068fn top_level_help_markdown_requested(raw: &[String]) -> bool {
1069 let mut i = 1usize;
1070 while i < raw.len() {
1071 let arg = raw[i].as_str();
1072 if arg == "--" {
1073 break;
1074 }
1075 if arg == "--help-markdown" {
1076 return true;
1077 }
1078 if arg == "--mode" {
1079 i += 2;
1080 continue;
1081 }
1082 if top_level_arg_consumes_value(arg) {
1083 i += if arg.contains('=') { 1 } else { 2 };
1084 continue;
1085 }
1086 if arg.starts_with('-') {
1087 i += 1;
1088 continue;
1089 }
1090 break;
1091 }
1092 false
1093}
1094
1095fn psql_admin_action(action: PsqlCliAction) -> PsqlAdminAction {
1096 match action {
1097 PsqlCliAction::Status(args) => PsqlAdminAction::Status {
1098 bin_dir: args.bin_dir,
1099 },
1100 PsqlCliAction::Install(args) => PsqlAdminAction::Install {
1101 bin_dir: args.bin_dir,
1102 },
1103 PsqlCliAction::Uninstall(args) => PsqlAdminAction::Uninstall {
1104 bin_dir: args.bin_dir,
1105 },
1106 }
1107}
1108
1109fn skill_admin_action(action: SkillCliAction) -> SkillAdminAction {
1110 match action {
1111 SkillCliAction::Status(args) => SkillAdminAction::Status(skill_options(args, false)),
1112 SkillCliAction::Install(args) => {
1113 SkillAdminAction::Install(skill_options(args.target, args.force))
1114 }
1115 SkillCliAction::Uninstall(args) => {
1116 SkillAdminAction::Uninstall(skill_options(args.target, args.force))
1117 }
1118 }
1119}
1120
1121fn skill_options(args: SkillTargetArgs, force: bool) -> SkillAdminOptions {
1122 SkillAdminOptions {
1123 agent: args.agent,
1124 scope: args.scope,
1125 skills_dir: args.skills_dir,
1126 force,
1127 }
1128}
1129
1130fn is_psql_mode_requested(raw: &[String]) -> bool {
1131 let mut i = 1usize;
1132 while i < raw.len() {
1133 let arg = raw[i].as_str();
1134 if arg == "--" {
1135 break;
1136 }
1137 if arg == "--mode" {
1138 if let Some(v) = raw.get(i + 1) {
1139 return v == "psql";
1140 }
1141 return false;
1142 }
1143 if arg == "--mode=psql" {
1144 return true;
1145 }
1146 if top_level_arg_consumes_value(arg) {
1147 i += if arg.contains('=') { 1 } else { 2 };
1148 continue;
1149 }
1150 if arg.starts_with('-') {
1151 i += 1;
1152 continue;
1153 }
1154 break;
1155 }
1156 false
1157}
1158
1159fn top_level_arg_consumes_value(arg: &str) -> bool {
1160 let name = arg.split_once('=').map(|(name, _)| name).unwrap_or(arg);
1161 matches!(
1162 name,
1163 "--sql"
1164 | "--sql-file"
1165 | "--param"
1166 | "--batch-rows"
1167 | "--batch-bytes"
1168 | "--statement-timeout-ms"
1169 | "--lock-timeout-ms"
1170 | "--inline-max-rows"
1171 | "--inline-max-bytes"
1172 | "--permission"
1173 | "--dsn-secret"
1174 | "--dsn-secret-env"
1175 | "--conninfo-secret"
1176 | "--host"
1177 | "--port"
1178 | "--user"
1179 | "--dbname"
1180 | "--password-secret"
1181 | "--password-secret-env"
1182 | "--ssh"
1183 | "--ssh-option"
1184 | "--ssh-local-host"
1185 | "--ssh-local-port"
1186 | "--ssh-remote-socket"
1187 | "--ssh-sudo-user"
1188 | "--output"
1189 | "--log"
1190 )
1191}
1192
1193fn load_sql(sql: Option<String>, sql_file: Option<String>) -> Result<String, String> {
1194 match (sql, sql_file) {
1195 (Some(s), None) => validate_sql_size(s),
1196 (None, Some(path)) if path == "-" => {
1197 let stdin = std::io::stdin();
1198 read_limited_sql(stdin.lock(), "read --sql-file -")
1199 }
1200 (None, Some(path)) => {
1201 let metadata =
1202 std::fs::metadata(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
1203 if metadata.is_file() && metadata.len() > MAX_SQL_BYTES as u64 {
1204 return Err(sql_size_error());
1205 }
1206 let file =
1207 std::fs::File::open(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
1208 read_limited_sql(file, "read --sql-file")
1209 }
1210 (Some(_), Some(_)) => Err("--sql and --sql-file are mutually exclusive".to_string()),
1211 (None, None) => Err("one of --sql or --sql-file is required".to_string()),
1212 }
1213}
1214
1215fn read_limited_sql<R: Read>(reader: R, context: &str) -> Result<String, String> {
1216 let mut buf = Vec::new();
1217 let mut limited = reader.take(MAX_SQL_BYTES as u64 + 1);
1218 limited
1219 .read_to_end(&mut buf)
1220 .map_err(|e| format!("{context} failed: {e}"))?;
1221 if buf.len() > MAX_SQL_BYTES {
1222 return Err(sql_size_error());
1223 }
1224 String::from_utf8(buf).map_err(|e| format!("{context} failed: {e}"))
1225}
1226
1227fn validate_sql_size(sql: String) -> Result<String, String> {
1228 if sql.len() > MAX_SQL_BYTES {
1229 return Err(sql_size_error());
1230 }
1231 Ok(sql)
1232}
1233
1234fn sql_size_error() -> String {
1235 format!("sql exceeds maximum size; maximum SQL size is {MAX_SQL_BYTES} bytes")
1236}
1237
1238fn parse_output(v: &str) -> Result<OutputFormat, String> {
1239 cli_parse_output(v)
1240}
1241
1242fn parse_permission_arg(v: &str) -> Result<Permission, String> {
1243 v.parse()
1244}
1245
1246fn parse_log_categories(entries: &[String]) -> Vec<String> {
1247 cli_parse_log_filters(entries)
1248}
1249
1250fn startup_requested_from_raw(raw: &[String]) -> bool {
1251 let mut i = 1usize;
1252 while i < raw.len() {
1253 if raw[i] == "--log" {
1254 if let Some(values) = raw.get(i + 1) {
1255 for part in values.split(',') {
1256 let v = part.trim().to_ascii_lowercase();
1257 if matches!(v.as_str(), "startup" | "all" | "*") {
1258 return true;
1259 }
1260 }
1261 }
1262 i += 2;
1263 continue;
1264 }
1265 if let Some(values) = raw[i].strip_prefix("--log=") {
1266 for part in values.split(',') {
1267 let v = part.trim().to_ascii_lowercase();
1268 if matches!(v.as_str(), "startup" | "all" | "*") {
1269 return true;
1270 }
1271 }
1272 }
1273 i += 1;
1274 }
1275 false
1276}
1277
1278fn startup_env_snapshot() -> Value {
1279 Value::Array(
1280 STARTUP_ENV_KEYS
1281 .iter()
1282 .map(|key| {
1283 json!({
1284 "key": key,
1285 "present": std::env::var_os(key).is_some(),
1286 })
1287 })
1288 .collect(),
1289 )
1290}
1291
1292fn startup_args(
1293 mode: &str,
1294 sql: Option<&str>,
1295 sql_file: Option<&str>,
1296 param_count: usize,
1297) -> Value {
1298 json!({
1299 "mode": mode,
1300 "sql": startup_sql_summary(sql, sql_file),
1301 "param_count": param_count,
1302 })
1303}
1304
1305fn startup_sql_summary(sql: Option<&str>, sql_file: Option<&str>) -> Value {
1306 let Some(sql) = sql else {
1307 return json!({
1308 "present": false,
1309 "source": "none",
1310 "bytes": 0,
1311 "chars": 0,
1312 "operation": null,
1313 });
1314 };
1315 json!({
1316 "present": true,
1317 "source": if sql_file.is_some() { "file" } else { "inline" },
1318 "bytes": sql.len(),
1319 "chars": sql.chars().count(),
1320 "operation": sql_operation(sql),
1321 })
1322}
1323
1324fn sql_operation(sql: &str) -> Option<String> {
1325 let sql = trim_leading_sql_comments(sql);
1326 let token: String = sql
1327 .chars()
1328 .skip_while(|c| c.is_whitespace())
1329 .take_while(|c| c.is_ascii_alphabetic() || *c == '_')
1330 .collect();
1331 if token.is_empty() {
1332 None
1333 } else {
1334 Some(token.to_ascii_lowercase())
1335 }
1336}
1337
1338fn trim_leading_sql_comments(mut sql: &str) -> &str {
1339 loop {
1340 sql = sql.trim_start();
1341 if let Some(rest) = sql.strip_prefix("--") {
1342 sql = rest.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
1343 continue;
1344 }
1345 if let Some(rest) = sql.strip_prefix("/*") {
1346 let Some((_, after)) = rest.split_once("*/") else {
1347 return "";
1348 };
1349 sql = after;
1350 continue;
1351 }
1352 return sql;
1353 }
1354}
1355
1356struct PsqlStartupArgs<'a> {
1357 mode: &'a str,
1358 sql: Option<&'a str>,
1359 sql_file: Option<String>,
1360 param_count: usize,
1361}
1362
1363fn psql_startup_args(args: PsqlStartupArgs<'_>) -> Value {
1364 startup_args(
1365 args.mode,
1366 args.sql,
1367 args.sql_file.as_deref(),
1368 args.param_count,
1369 )
1370}
1371
1372fn resolve_secret_value(
1373 flag_name: &str,
1374 direct: Option<String>,
1375 env_name: Option<&str>,
1376) -> Result<Option<String>, String> {
1377 match (direct, env_name) {
1378 (Some(_), Some(_)) => Err(format!(
1379 "{flag_name} and {flag_name}-env are mutually exclusive"
1380 )),
1381 (Some(value), None) => Ok(Some(value)),
1382 (None, Some(name)) => {
1383 if name.is_empty() {
1384 return Err(format!(
1385 "{flag_name}-env requires a non-empty variable name"
1386 ));
1387 }
1388 std::env::var(name).map(Some).map_err(|_| {
1389 format!("{flag_name}-env references unset environment variable: {name}")
1390 })
1391 }
1392 (None, None) => Ok(None),
1393 }
1394}
1395
1396pub fn parse_params(entries: &[String]) -> Result<Vec<Value>, String> {
1397 if entries.len() > MAX_PARAMS {
1398 return Err(format!("too many params; maximum params is {MAX_PARAMS}"));
1399 }
1400
1401 let mut by_index: BTreeMap<usize, Value> = BTreeMap::new();
1402 for entry in entries {
1403 let (idx, raw) = split_index_value(entry)?;
1404 if idx == 0 {
1405 return Err("param index must start at 1".to_string());
1406 }
1407 if idx > MAX_PARAMS {
1408 return Err(format!(
1409 "parameter index {idx} exceeds maximum params {MAX_PARAMS}"
1410 ));
1411 }
1412 match by_index.entry(idx) {
1413 Entry::Vacant(slot) => {
1414 slot.insert(parse_param_value(raw));
1415 }
1416 Entry::Occupied(_) => return Err(format!("duplicate parameter index {idx}")),
1417 }
1418 }
1419 if by_index.is_empty() {
1420 return Ok(vec![]);
1421 }
1422 let max = by_index.keys().max().copied().unwrap_or(0);
1423 for i in 1..=max {
1424 if !by_index.contains_key(&i) {
1425 return Err(format!("missing parameter index {i}"));
1426 }
1427 }
1428 Ok(by_index.into_values().collect())
1429}
1430
1431fn split_index_value(entry: &str) -> Result<(usize, &str), String> {
1432 let mut parts = entry.splitn(2, '=');
1433 let left = parts.next().unwrap_or_default();
1434 let right = parts
1435 .next()
1436 .ok_or_else(|| format!("invalid param '{entry}', expected N=value"))?;
1437 let idx = left
1438 .parse::<usize>()
1439 .map_err(|_| format!("invalid param index in '{entry}'"))?;
1440 Ok((idx, right))
1441}
1442
1443fn parse_param_value(v: &str) -> Value {
1444 if v == "null" {
1445 return Value::Null;
1446 }
1447 if v == "true" {
1448 return Value::Bool(true);
1449 }
1450 if v == "false" {
1451 return Value::Bool(false);
1452 }
1453 if let Ok(i) = v.parse::<i64>() {
1454 return Value::Number(i.into());
1455 }
1456 if let Ok(f) = v.parse::<f64>() {
1457 if let Some(n) = serde_json::Number::from_f64(f) {
1458 return Value::Number(n);
1459 }
1460 }
1461 Value::String(v.to_string())
1462}
1463
1464#[cfg(test)]
1465#[path = "../tests/support/unit_cli.rs"]
1466mod tests;