Skip to main content

agent_first_psql/
cli.rs

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    /// Manage both Codex and Claude Code personal skills.
85    All,
86    /// Manage the Codex local skill under $CODEX_HOME/skills.
87    Codex,
88    /// Manage the Claude Code skill under ~/.claude/skills or .claude/skills.
89    #[value(name = "claude-code", alias = "claude")]
90    ClaudeCode,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
94pub enum SkillScope {
95    /// Install under the user-level skills directory.
96    Personal,
97    /// Install under the current project's skills directory.
98    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    /// Manage the local psql wrapper for afpsql --mode psql.
131    Psql(PsqlCommand),
132    /// Manage Agent-First PSQL skills for Codex and Claude Code.
133    Skill(SkillCommand),
134}
135
136#[derive(Args)]
137struct PsqlCommand {
138    #[command(subcommand)]
139    action: PsqlCliAction,
140}
141
142#[derive(Subcommand)]
143enum PsqlCliAction {
144    /// Show whether the afpsql-managed psql wrapper is installed and active.
145    Status(PsqlPathArgs),
146    /// Install an afpsql-managed psql wrapper.
147    Install(PsqlPathArgs),
148    /// Remove an afpsql-managed psql wrapper.
149    Uninstall(PsqlPathArgs),
150}
151
152#[derive(Args)]
153struct PsqlPathArgs {
154    /// Directory that contains the psql wrapper. Defaults to the afpsql executable directory.
155    #[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    /// Show whether the Agent-First PSQL skill is installed and valid.
168    Status(SkillTargetArgs),
169    /// Install the Agent-First PSQL skill.
170    Install(SkillWriteArgs),
171    /// Remove an afpsql-managed Agent-First PSQL skill.
172    Uninstall(SkillWriteArgs),
173}
174
175#[derive(Args)]
176struct SkillTargetArgs {
177    /// Agent to manage. Defaults to all personal skill targets.
178    #[arg(long = "agent", value_enum, default_value_t = SkillAgentSelection::All)]
179    agent: SkillAgentSelection,
180    /// Skill scope. Project scope is supported for Claude Code only.
181    #[arg(long = "scope", value_enum, default_value_t = SkillScope::Personal)]
182    scope: SkillScope,
183    /// Directory that contains skill folders. Requires an explicit single --agent.
184    #[arg(long = "skills-dir")]
185    skills_dir: Option<String>,
186}
187
188#[derive(Args)]
189struct SkillWriteArgs {
190    #[command(flatten)]
191    target: SkillTargetArgs,
192    /// Overwrite or remove an unmanaged Agent-First PSQL skill at the target path.
193    #[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    /// Inline SQL string to execute.
258    #[arg(long, allow_hyphen_values = true, help_heading = "Query")]
259    sql: Option<String>,
260    /// Read SQL from a file.
261    #[arg(long = "sql-file", allow_hyphen_values = true, help_heading = "Query")]
262    sql_file: Option<String>,
263    /// Positional bind parameter in `N=value` form. Repeat for additional parameters.
264    #[arg(long = "param", help_heading = "Query")]
265    param: Vec<String>,
266    /// Stream large result sets as `result_rows` batches instead of a single inline result.
267    #[arg(long = "stream-rows", help_heading = "Query")]
268    stream_rows: bool,
269    /// Maximum rows per streamed batch.
270    #[arg(long = "batch-rows", help_heading = "Query")]
271    batch_rows: Option<usize>,
272    /// Soft byte target per streamed batch.
273    #[arg(long = "batch-bytes", help_heading = "Query")]
274    batch_bytes: Option<usize>,
275    /// Per-query statement timeout in milliseconds.
276    #[arg(long = "statement-timeout-ms", help_heading = "Query")]
277    statement_timeout_ms: Option<u64>,
278    /// Per-query lock timeout in milliseconds.
279    #[arg(long = "lock-timeout-ms", help_heading = "Query")]
280    lock_timeout_ms: Option<u64>,
281    /// Maximum inline rows before returning `result_too_large`.
282    #[arg(long = "inline-max-rows", help_heading = "Query")]
283    inline_max_rows: Option<usize>,
284    /// Maximum inline payload bytes before returning `result_too_large`.
285    #[arg(long = "inline-max-bytes", help_heading = "Query")]
286    inline_max_bytes: Option<usize>,
287    /// Query permission: read, write, ssh-read, or ssh-write. Defaults to read, or ssh-read with --ssh.
288    #[arg(long = "permission", value_parser = parse_permission_arg, help_heading = "Query")]
289    permission: Option<Permission>,
290    /// Preview the query without executing it
291    #[arg(long, help_heading = "Query")]
292    dry_run: bool,
293
294    /// PostgreSQL DSN URI. Redacted in structured output.
295    #[arg(long = "dsn-secret", help_heading = "Connection")]
296    dsn_secret: Option<String>,
297    /// Read PostgreSQL DSN URI from an environment variable.
298    #[arg(long = "dsn-secret-env", help_heading = "Connection")]
299    dsn_secret_env: Option<String>,
300    /// libpq-style conninfo string. Redacted in structured output.
301    #[arg(long = "conninfo-secret", help_heading = "Connection")]
302    conninfo_secret: Option<String>,
303    /// PostgreSQL host.
304    #[arg(long, help_heading = "Connection")]
305    host: Option<String>,
306    /// PostgreSQL port.
307    #[arg(long, help_heading = "Connection")]
308    port: Option<u16>,
309    /// PostgreSQL user name.
310    #[arg(long, help_heading = "Connection")]
311    user: Option<String>,
312    /// PostgreSQL database name.
313    #[arg(long, help_heading = "Connection")]
314    dbname: Option<String>,
315    /// PostgreSQL password. Redacted in structured output.
316    #[arg(long = "password-secret", help_heading = "Connection")]
317    password_secret: Option<String>,
318    /// Read PostgreSQL password from an environment variable.
319    #[arg(long = "password-secret-env", help_heading = "Connection")]
320    password_secret_env: Option<String>,
321    /// Open an SSH transport to USER@HOST before connecting to PostgreSQL.
322    #[arg(long = "ssh", help_heading = "SSH Transport")]
323    ssh: Option<String>,
324    /// Additional OpenSSH -o option. Repeat for multiple options.
325    #[arg(long = "ssh-option", help_heading = "SSH Transport")]
326    ssh_options: Vec<String>,
327    /// Local bind host for the SSH tunnel.
328    #[arg(long = "ssh-local-host", help_heading = "SSH Transport")]
329    ssh_local_host: Option<String>,
330    /// Local bind port for the SSH tunnel. Defaults to an ephemeral port.
331    #[arg(long = "ssh-local-port", help_heading = "SSH Transport")]
332    ssh_local_port: Option<u16>,
333    /// Explicit remote PostgreSQL Unix socket path for SSH forwarding.
334    #[arg(long = "ssh-remote-socket", help_heading = "SSH Transport")]
335    ssh_remote_socket: Option<String>,
336    /// Remote OS user for sudo -n Unix-socket bridge mode; requires an explicit socket.
337    #[arg(long = "ssh-sudo-user", help_heading = "SSH Transport")]
338    ssh_sudo_user: Option<String>,
339
340    /// Output format: json (default), yaml, or plain.
341    #[arg(long, default_value = "json", global = true, help_heading = "Runtime")]
342    output: String,
343    /// Diagnostic log categories.
344    #[arg(long = "log", value_delimiter = ',', help_heading = "Runtime")]
345    log: Vec<String>,
346    /// Runtime mode: canonical cli, pipe, or `psql` translation mode.
347    #[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    // --help: recursive plain-text help (all subcommands expanded)
362    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    // --help-markdown: Markdown for doc generation
371    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;