From 923ce43e0b9436eb59d9c3bc4f617259a181ca6d Mon Sep 17 00:00:00 2001 From: "a.khanteev" Date: Tue, 25 Aug 2026 00:13:16 +0400 Subject: [PATCH] feat(pgc): align CLI with shared cmd-ts pattern --- .changeset/improve-pgc-query-inputs.md | 5 + .changeset/migrate-pgc-to-cmd-ts.md | 5 + .../.openspec.yaml | 2 + .../design.md | 34 +++ .../proposal.md | 27 ++ .../specs/postgres/query-inputs/spec.md | 72 +++++ .../tasks.md | 19 ++ .../.openspec.yaml | 2 + .../design.md | 54 ++++ .../proposal.md | 28 ++ .../shared/cli-command-interface/spec.md | 52 ++++ .../2026-08-25-migrate-pgc-to-cmd-ts/tasks.md | 18 ++ openspec/specs/postgres/query-inputs/spec.md | 73 +++++ .../shared/cli-command-interface/spec.md | 54 ++++ packages/postgres-cli/README.md | 39 ++- packages/postgres-cli/package.json | 6 +- packages/postgres-cli/src/cli.js | 141 ++-------- packages/postgres-cli/src/lib/command-spec.js | 261 ++++++++++++++++++ packages/postgres-cli/src/lib/query-input.js | 34 +++ packages/postgres-cli/test/cli.test.js | 65 ++++- .../postgres-cli/test/command-spec.test.js | 64 +++++ pnpm-lock.yaml | 3 + skills/postgres-cli/SKILL.md | 42 ++- 23 files changed, 957 insertions(+), 143 deletions(-) create mode 100644 .changeset/improve-pgc-query-inputs.md create mode 100644 .changeset/migrate-pgc-to-cmd-ts.md create mode 100644 openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/design.md create mode 100644 openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/proposal.md create mode 100644 openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/specs/postgres/query-inputs/spec.md create mode 100644 openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/tasks.md create mode 100644 openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/design.md create mode 100644 openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/proposal.md create mode 100644 openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/specs/shared/cli-command-interface/spec.md create mode 100644 openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/tasks.md create mode 100644 openspec/specs/postgres/query-inputs/spec.md create mode 100644 openspec/specs/shared/cli-command-interface/spec.md create mode 100644 packages/postgres-cli/src/lib/command-spec.js create mode 100644 packages/postgres-cli/src/lib/query-input.js create mode 100644 packages/postgres-cli/test/command-spec.test.js diff --git a/.changeset/improve-pgc-query-inputs.md b/.changeset/improve-pgc-query-inputs.md new file mode 100644 index 0000000..57bf9c4 --- /dev/null +++ b/.changeset/improve-pgc-query-inputs.md @@ -0,0 +1,5 @@ +--- +"@khaale/postgres-cli": patch +--- + +Add per-query row-limit overrides and UTF-8 SQL-file input for `pgc`. diff --git a/.changeset/migrate-pgc-to-cmd-ts.md b/.changeset/migrate-pgc-to-cmd-ts.md new file mode 100644 index 0000000..7bc9567 --- /dev/null +++ b/.changeset/migrate-pgc-to-cmd-ts.md @@ -0,0 +1,5 @@ +--- +"@khaale/postgres-cli": patch +--- + +Align `pgc` command parsing and generated help with the `glc` and `ktc` CLI pattern. diff --git a/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/.openspec.yaml b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/.openspec.yaml new file mode 100644 index 0000000..4102db8 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-24 diff --git a/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/design.md b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/design.md new file mode 100644 index 0000000..a4e05c8 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/design.md @@ -0,0 +1,34 @@ +## Context + +`pgc` currently parses all command-line options as strings, passes inline `options.sql` directly to `executeReadQuery`, and derives the effective row limit from the named session. Query execution already applies the session byte limit and statement timeout independently of the row limit. See `proposal.md` and `specs/postgres/query-inputs/spec.md` for the requested behavior. + +## Goals / Non-Goals + +**Goals:** + +- Resolve one SQL source for `query` before opening a database connection. +- Validate a per-invocation positive row-limit override and pass it through the existing execution options. +- Read SQL files as UTF-8 in a platform-independent way, including files with a leading UTF-8 BOM. +- Keep byte and timeout limits sourced from the selected session and unchanged by the override. +- Make all input and file failures use the existing JSON error envelope and exit-code behavior. + +**Non-Goals:** + +- No unbounded or unlimited export mode. +- No row-limit override for schema listing `--limit` or the `compare` command in this change. +- No support for multiple statements; the existing read-only validator continues to reject them. +- No changes to PostgreSQL roles, connection settings, or output formats. + +## Decisions + +- Use the explicit flag name `--row-limit` rather than overloading `--limit`, because schema commands already use `--limit` for catalog pagination and the query option controls a safety bound. +- Resolve `--sql-file` in the CLI dispatch layer and pass the resulting SQL string through the existing `executeReadQuery` path. This keeps read-only validation, timeout setup, byte limiting, and truncation semantics in one execution path. +- Read files using the repository's injectable filesystem boundary where available, with UTF-8 decoding and a leading `\uFEFF` removed. This makes Windows PowerShell-generated files work without depending on shell quoting or platform-specific newline behavior. +- Validate `--row-limit` as a positive integer before `executeReadQuery` is called. The effective execution options should override only `rowLimit`; `statementTimeoutMs` and `byteLimit` continue to fall back to the session values. +- Treat `--sql` and `--sql-file` as mutually exclusive and require one source. File errors should be normalized at the CLI boundary so JSON and human-readable modes remain consistent with existing failures. + +## Risks / Trade-offs + +- [Risk] A larger row limit can increase memory and output size. → Keep the configured byte limit and statement timeout mandatory safety bounds, and expose truncation in the existing result shape. +- [Risk] A SQL file may contain a BOM or Windows line endings. → Decode as UTF-8 and strip only a leading BOM; leave SQL content otherwise unchanged for PostgreSQL validation. +- [Risk] File reads could become difficult to unit-test if they use global filesystem calls. → Inject the file-read capability through the existing runtime/dependency path and cover missing, UTF-8, and BOM cases with focused tests. diff --git a/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/proposal.md b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/proposal.md new file mode 100644 index 0000000..123b731 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/proposal.md @@ -0,0 +1,27 @@ +## Why + +`pgc query` currently applies the configured row limit, which makes the default 1,000-row cap inconvenient for intentional larger reads even when the byte and statement-timeout safeguards are sufficient. Passing a complex SQL script through PowerShell command-line quoting is also error-prone on Windows, so users need a file-based SQL input path. + +## What Changes + +- Add a `query` command-line row-limit override that takes precedence over the selected session's configured `rowLimit`. +- Keep the selected session's `byteLimit` and `statementTimeoutMs` active for every query, including queries using the override. +- Add `query --sql-file PATH` as an alternative to inline `--sql`; read the file as UTF-8 and tolerate a leading UTF-8 BOM. +- Require exactly one SQL source (`--sql` or `--sql-file`) and return the existing structured error shape for invalid combinations or unreadable files. +- Preserve the existing read-only validation, truncation reporting, and secret-redaction behavior. + +## Capabilities + +### New Capabilities + +- `postgres/query-inputs`: Define command-line row-limit overrides and UTF-8 SQL-file input for PostgreSQL queries. + +### Modified Capabilities + + + +## Impact + +- `packages/postgres-cli/src/cli.js` and query execution helpers for input resolution and limit precedence. +- PostgreSQL CLI README/help text and focused tests for CLI parsing, file encoding, and safety-limit preservation. +- No database schema, connection, or dependency changes. diff --git a/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/specs/postgres/query-inputs/spec.md b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/specs/postgres/query-inputs/spec.md new file mode 100644 index 0000000..14e681d --- /dev/null +++ b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/specs/postgres/query-inputs/spec.md @@ -0,0 +1,72 @@ +## Purpose + +Provide reliable, bounded ways to supply larger or complex read-only SQL queries to `pgc` across shells and operating systems. + +## ADDED Requirements + +### Requirement: Override the query row limit from the command line + +The `query` command SHALL accept a positive integer `--row-limit` option that overrides the selected session's configured `rowLimit` for that invocation only. When the option is absent, the configured session limit SHALL remain effective. + +#### Scenario: Command-line row limit overrides the session + +- **WHEN** the user runs `pgc query` with `--row-limit 5000` and the selected session has `rowLimit` set to 1000 +- **THEN** the query execution and result normalization use 5000 as the row limit for that invocation + +#### Scenario: Configured row limit remains the default + +- **WHEN** the user runs `pgc query` without `--row-limit` +- **THEN** the selected session's configured `rowLimit` is used + +#### Scenario: Invalid row-limit values fail before execution + +- **WHEN** `--row-limit` is missing a value or is not a positive integer +- **THEN** `pgc` returns a structured CLI error and does not connect to PostgreSQL + +### Requirement: Preserve independent query safety limits + +The `query` command SHALL continue applying the selected session's `byteLimit` and `statementTimeoutMs` regardless of whether `--row-limit` is supplied. A row-limit override SHALL NOT provide an option to disable or bypass either safety limit. + +#### Scenario: Large row override remains byte-bounded + +- **WHEN** a query is run with a row limit larger than the session's `byteLimit` can represent +- **THEN** the result is truncated at the byte limit and reports `truncated: true` + +#### Scenario: Large row override remains time-bounded + +- **WHEN** a query is run with any row-limit override +- **THEN** the session's configured statement timeout is still applied to the PostgreSQL transaction + +### Requirement: Read SQL from a UTF-8 file + +The `query` command SHALL accept `--sql-file PATH` as an alternative to `--sql`, read the referenced file as UTF-8, and execute the resulting SQL through the same read-only validation and safety limits as inline SQL. A leading UTF-8 BOM SHALL be ignored. + +#### Scenario: Execute SQL from a file + +- **WHEN** the user runs `pgc query --sql-file query.sql` +- **THEN** `pgc` reads the file as UTF-8 and executes its SQL using the selected session + +#### Scenario: Preserve non-ASCII SQL content + +- **WHEN** a UTF-8 SQL file contains non-ASCII identifiers, comments, or string literals +- **THEN** the SQL reaches validation and execution without shell-dependent re-encoding + +#### Scenario: Missing or unreadable SQL file + +- **WHEN** the path does not exist or cannot be read +- **THEN** `pgc` returns a structured CLI error identifying that the SQL file could not be read and does not execute a query + +### Requirement: Require one SQL input source + +The `query` command SHALL require exactly one of `--sql` and `--sql-file`. Supplying both or neither SHALL be rejected before PostgreSQL execution. + +#### Scenario: Inline and file SQL are both supplied + +- **WHEN** the user supplies both `--sql` and `--sql-file` +- **THEN** `pgc` returns a structured CLI error explaining that the SQL sources are mutually exclusive + +#### Scenario: Neither SQL source is supplied + +- **WHEN** the user runs `pgc query` without `--sql` or `--sql-file` +- **THEN** `pgc` returns a structured CLI error requiring one SQL source + diff --git a/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/tasks.md b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/tasks.md new file mode 100644 index 0000000..4007bcc --- /dev/null +++ b/openspec/changes/archive/2026-08-24-improve-pgc-query-inputs/tasks.md @@ -0,0 +1,19 @@ +## 1. Query input resolution + +- [x] 1.1 Add positive-integer validation for `query --row-limit`, preserve the session value when omitted, and verify invalid values fail before a database client is created +- [x] 1.2 Add mutually exclusive `--sql`/`--sql-file` resolution with required-source validation, UTF-8 file reading, leading-BOM handling, and focused tests for inline, UTF-8, missing, unreadable, both-sources, and no-source cases + +## 2. Bounded query execution + +- [x] 2.1 Pass the resolved SQL and effective row limit through the existing read-only execution path, and verify the command-line limit overrides only `rowLimit` +- [x] 2.2 Verify byte-limit truncation and statement-timeout configuration remain active with a larger command-line row limit, including the existing structured result/error behavior + +## 3. User experience and release metadata + +- [x] 3.1 Update `pgc --help` and the PostgreSQL CLI README with `--row-limit`, `--sql-file`, Windows/UTF-8 usage, and the safety-limit behavior +- [x] 3.2 Update `skills/postgres-cli/SKILL.md` with the new query flags, a file-based SQL example, and guidance on retained byte/timeout safeguards +- [x] 3.3 Add a changeset for the user-facing `@khaale/postgres-cli` query input improvements and verify the package metadata remains publishable + +## 4. Verification + +- [x] 4.1 Run the PostgreSQL CLI tests and full `pnpm check`, confirming lint, tests, package smoke checks, and dry-run packaging all pass diff --git a/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/.openspec.yaml b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/.openspec.yaml new file mode 100644 index 0000000..4102db8 --- /dev/null +++ b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-24 diff --git a/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/design.md b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/design.md new file mode 100644 index 0000000..5d3de80 --- /dev/null +++ b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/design.md @@ -0,0 +1,54 @@ +## Context + +`glc` and `ktc` already define their command trees with `cmd-ts`, use `runSafely` for parser failures, and adapt those failures to the repository's JSON/text error contract. `pgc` instead combines `createCliArgParser`, a static help string, and a hand-written dispatcher. The migration must preserve the behavior described in the new shared CLI command-interface specification and the existing PostgreSQL query-input specification. + +## Goals / Non-Goals + +**Goals:** + +- Make `pgc` follow the same command-spec and parser-error flow as `glc` and `ktc`. +- Keep command handlers focused on PostgreSQL behavior rather than token parsing. +- Generate help from the command definitions and test parser behavior independently from database execution. +- Preserve JSON-by-default output, `--md`/`--csv`, named sessions, query safety limits, and all existing command paths. + +**Non-Goals:** + +- Do not redesign PostgreSQL operations, configuration, output rendering, or query safety. +- Do not change `cli-core` into a generic command-definition wrapper in this change. +- Do not migrate `glc` or `ktc`; they are the reference implementations for the pattern. + +## Decisions + +### Use the established direct `cmd-ts` pattern + +Add `cmd-ts` as a runtime dependency of `@khaale/postgres-cli` and create a `pgc` command-spec module modeled on the existing `glc` and `ktc` modules. This keeps the command tree explicit and makes the implementation consistent with working repository examples. + +An internal wrapper in `cli-core` is not introduced: the current tools do not share such a wrapper, and adding one would expand this refactor without solving a concrete `pgc` requirement. Shared config, errors, and output helpers remain in `cli-core` where they already belong. + +### Keep handlers and normalize at the CLI boundary + +Existing domain functions remain responsible for config resolution, PostgreSQL access, schema operations, comparisons, and output data. The command-spec handlers translate parsed arguments into the same option objects those functions already receive. The CLI entry point unwraps command results, handles `runSafely` parser failures, and preserves the existing structured JSON error envelope and text-stream routing. + +### Represent option types in the command specification + +Options such as positive row limits, numeric safety settings, CSV lists, and JSON query parameters will be parsed or validated at the command boundary where practical. Domain-level validation remains for rules that depend on the command semantics, such as exactly one SQL source. This separates generic argument errors from query-specific errors without changing their observable safety behavior. + +### Treat generated help as the source of truth + +The static help block will be removed after the command tree covers every currently supported resource, verb, alias, and option. Tests will assert key help content and that help does not initialize configuration or database access; README and skill examples will be updated only where generated usage differs materially. + +## Risks / Trade-offs + +- [Risk] `cmd-ts` help or parser wording differs from the current static text. → Mitigate by testing stable command/option presence and preserving exit codes and structured error fields rather than asserting incidental prose. +- [Risk] A command or flag is omitted during the hand-written command-tree migration. → Mitigate with a command inventory from the current dispatcher, focused parse tests for every resource, and the full monorepo check. +- [Risk] The runtime bundle resolves the new dependency differently from the workspace tests. → Mitigate by running the existing self-contained bundle smoke test for `pgc` and the repository-wide packaging checks. +- [Risk] Parser-level validation changes when configuration or database work starts. → Mitigate by testing invalid invocations with injected dependencies and asserting those dependencies are not called. + +## Migration Plan + +1. Add the command specification and map all current `pgc` commands and options to it. +2. Replace the custom parser/dispatch entry path while retaining the existing domain handlers. +3. Add parser, help, compatibility, and bundle smoke tests; update user-facing documentation and the package changeset. +4. Run strict OpenSpec validation and `pnpm check`. + +Rollback is a source revert of the migration commit; no persisted configuration or database schema changes are involved. diff --git a/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/proposal.md b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/proposal.md new file mode 100644 index 0000000..00914d0 --- /dev/null +++ b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/proposal.md @@ -0,0 +1,28 @@ +## Why + +`pgc` currently parses arguments with the low-level shared tokenizer and keeps its help text and command validation in a hand-written dispatcher. This differs from the `cmd-ts` command-spec pattern already used by `glc` and `ktc`, making help, option validation, and future command additions harder to keep consistent across the tools. + +## What Changes + +- Migrate `pgc` command parsing and dispatch to the repository's established `cmd-ts` command-spec pattern. +- Provide generated top-level and command-level help, including usage and option descriptions. +- Validate command arguments and options through the command specification, with the existing structured CLI error/output contract preserved. +- Preserve the current `pgc` commands, aliases, output formats, named-session behavior, and query options, including `--row-limit` and `--sql-file`. +- Add regression coverage for help, valid command invocations, invalid arguments, and the self-contained executable bundle. + +## Capabilities + +### New Capabilities + +- `shared/cli-command-interface`: Standard command parsing, generated help, and structured validation behavior shared by the repository's CLI tools. + +### Modified Capabilities + +None. + +## Impact + +- `packages/postgres-cli/src/cli.js` and a new or updated command-spec module will own the `pgc` command tree and handlers. +- `@khaale/postgres-cli` will add or align its `cmd-ts` dependency and may have small help/error text changes as a result of generated output. +- Tests, README examples, and `skills/postgres-cli/SKILL.md` will be updated where the generated help or invocation contract is user-visible. +- The existing self-contained bundling path must continue to produce runnable `pgc`, `glc`, and `ktc` executables. diff --git a/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/specs/shared/cli-command-interface/spec.md b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/specs/shared/cli-command-interface/spec.md new file mode 100644 index 0000000..fabf5c5 --- /dev/null +++ b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/specs/shared/cli-command-interface/spec.md @@ -0,0 +1,52 @@ +## Purpose + +This capability gives the repository's CLI tools a predictable command interface with discoverable help, early argument validation, and errors suitable for both shell users and agents. + +## ADDED Requirements + +### Requirement: CLI tools SHALL expose discoverable command help + +An adopting CLI tool SHALL expose top-level help through `--help` and through an invocation without a command. Nested resources and commands SHALL expose their own usage and option descriptions through the same help mechanism. Help requests MUST complete without loading a session or connecting to an external service. + +#### Scenario: pgc top-level help + +- **WHEN** the user runs `pgc --help` or `pgc` without a command +- **THEN** `pgc` prints its command overview and exits successfully without reading database credentials or opening a database connection + +#### Scenario: pgc command help + +- **WHEN** the user runs `pgc query --help` or `pgc schema search --help` +- **THEN** `pgc` prints usage and the options supported by that command and exits successfully without executing a query + +### Requirement: CLI tools SHALL validate command arguments before execution + +An adopting CLI tool SHALL reject unknown commands, unknown options, missing required options, and values that do not match the option type before invoking the command handler. Validation failures SHALL use exit code `2`; when JSON output is requested, they SHALL be represented by the standard structured error envelope. + +#### Scenario: missing required option + +- **WHEN** the user runs a command that requires a session without providing its session option +- **THEN** the CLI returns a structured validation error with exit code `2` and does not load a session or connect to PostgreSQL + +#### Scenario: unknown option + +- **WHEN** the user supplies an option that is not supported by the selected command +- **THEN** the CLI returns a structured validation error with exit code `2` before the command handler runs + +#### Scenario: invalid typed value + +- **WHEN** the user supplies a value that cannot be parsed as the selected option's type +- **THEN** the CLI returns a structured validation error with exit code `2` before the command handler runs + +### Requirement: Migrated command interfaces SHALL preserve supported pgc invocations + +The `pgc` command interface SHALL continue to support its existing resources and verbs, output mode flags, named-session options, query input options, schema exploration options, relationship options, and comparison options. In particular, query execution SHALL continue to accept exactly one of `--sql` and `--sql-file`, and SHALL retain the per-query `--row-limit` override. + +#### Scenario: query input options remain available + +- **WHEN** the user runs a valid `pgc query` command with either `--sql` or `--sql-file` and an optional `--row-limit` +- **THEN** `pgc` executes the same query-input behavior and applies the same safety limits as before the command-interface migration + +#### Scenario: schema and comparison commands remain available + +- **WHEN** the user runs a valid schema exploration, relationship, or two-session comparison command +- **THEN** `pgc` dispatches it to the corresponding existing functionality with the requested output format diff --git a/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/tasks.md b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/tasks.md new file mode 100644 index 0000000..b7bea92 --- /dev/null +++ b/openspec/changes/archive/2026-08-25-migrate-pgc-to-cmd-ts/tasks.md @@ -0,0 +1,18 @@ +## 1. Command specification + +- [x] 1.1 Inventory the current `pgc` resources, verbs, aliases, and options and encode the complete command tree in a `cmd-ts` command-spec module; verify every documented invocation has a corresponding parser definition +- [x] 1.2 Add `cmd-ts` as a runtime dependency of `@khaale/postgres-cli` and verify the workspace lockfile resolves the same version family used by `glc` and `ktc` +- [x] 1.3 Connect parsed command arguments to the existing `pgc` domain handlers without changing session resolution, query safety options, schema exploration, comparison, or output data; verify existing PostgreSQL CLI tests remain green + +## 2. CLI boundary and help + +- [x] 2.1 Replace the custom argument-parser/static-help entry path with safe command-spec execution and preserve JSON/text error routing, exit codes, and output-format selection; verify `pgc --help` and `pgc` do not invoke configuration or database dependencies +- [x] 2.2 Add generated top-level and nested help coverage for `doctor`, `sessions`, `schema`, `query`, and `compare`; verify help includes required options such as `--sql-file` and `--row-limit` +- [x] 2.3 Add parser validation coverage for unknown commands/options, missing required options, and invalid typed values; verify failures use exit code `2`, structured JSON when requested, and do not execute handlers + +## 3. Compatibility and packaging + +- [x] 3.1 Add command-spec compatibility tests for all existing `pgc` command families and both SQL input forms, including query row-limit overrides; verify the tests preserve the archived query-input requirements +- [x] 3.2 Update `packages/postgres-cli/README.md` and `skills/postgres-cli/SKILL.md` only where generated help or invocation examples change; verify examples match `pgc --help` +- [x] 3.3 Add a patch changeset for the user-visible CLI interface migration and verify it names `@khaale/postgres-cli` +- [x] 3.4 Run the self-contained `pgc` bundle smoke test and the full repository check; verify the package build remains runnable and `pnpm check` passes diff --git a/openspec/specs/postgres/query-inputs/spec.md b/openspec/specs/postgres/query-inputs/spec.md new file mode 100644 index 0000000..40a971c --- /dev/null +++ b/openspec/specs/postgres/query-inputs/spec.md @@ -0,0 +1,73 @@ +# PostgreSQL Query Inputs Specification + +## Purpose + +Provide reliable, bounded ways to supply larger or complex read-only SQL queries to `pgc` across shells and operating systems. + +## Requirements + +### Requirement: Override the query row limit from the command line + +The `query` command SHALL accept a positive integer `--row-limit` option that overrides the selected session's configured `rowLimit` for that invocation only. When the option is absent, the configured session limit SHALL remain effective. + +#### Scenario: Command-line row limit overrides the session + +- **WHEN** the user runs `pgc query` with `--row-limit 5000` and the selected session has `rowLimit` set to 1000 +- **THEN** the query execution and result normalization use 5000 as the row limit for that invocation + +#### Scenario: Configured row limit remains the default + +- **WHEN** the user runs `pgc query` without `--row-limit` +- **THEN** the selected session's configured `rowLimit` is used + +#### Scenario: Invalid row-limit values fail before execution + +- **WHEN** `--row-limit` is missing a value or is not a positive integer +- **THEN** `pgc` returns a structured CLI error and does not connect to PostgreSQL + +### Requirement: Preserve independent query safety limits + +The `query` command SHALL continue applying the selected session's `byteLimit` and `statementTimeoutMs` regardless of whether `--row-limit` is supplied. A row-limit override SHALL NOT provide an option to disable or bypass either safety limit. + +#### Scenario: Large row override remains byte-bounded + +- **WHEN** a query is run with a row limit larger than the session's `byteLimit` can represent +- **THEN** the result is truncated at the byte limit and reports `truncated: true` + +#### Scenario: Large row override remains time-bounded + +- **WHEN** a query is run with any row-limit override +- **THEN** the session's configured statement timeout is still applied to the PostgreSQL transaction + +### Requirement: Read SQL from a UTF-8 file + +The `query` command SHALL accept `--sql-file PATH` as an alternative to `--sql`, read the referenced file as UTF-8, and execute the resulting SQL through the same read-only validation and safety limits as inline SQL. A leading UTF-8 BOM SHALL be ignored. + +#### Scenario: Execute SQL from a file + +- **WHEN** the user runs `pgc query --sql-file query.sql` +- **THEN** `pgc` reads the file as UTF-8 and executes its SQL using the selected session + +#### Scenario: Preserve non-ASCII SQL content + +- **WHEN** a UTF-8 SQL file contains non-ASCII identifiers, comments, or string literals +- **THEN** the SQL reaches validation and execution without shell-dependent re-encoding + +#### Scenario: Missing or unreadable SQL file + +- **WHEN** the path does not exist or cannot be read +- **THEN** `pgc` returns a structured CLI error identifying that the SQL file could not be read and does not execute a query + +### Requirement: Require one SQL input source + +The `query` command SHALL require exactly one of `--sql` and `--sql-file`. Supplying both or neither SHALL be rejected before PostgreSQL execution. + +#### Scenario: Inline and file SQL are both supplied + +- **WHEN** the user supplies both `--sql` and `--sql-file` +- **THEN** `pgc` returns a structured CLI error explaining that the SQL sources are mutually exclusive + +#### Scenario: Neither SQL source is supplied + +- **WHEN** the user runs `pgc query` without `--sql` or `--sql-file` +- **THEN** `pgc` returns a structured CLI error requiring one SQL source diff --git a/openspec/specs/shared/cli-command-interface/spec.md b/openspec/specs/shared/cli-command-interface/spec.md new file mode 100644 index 0000000..c5ca880 --- /dev/null +++ b/openspec/specs/shared/cli-command-interface/spec.md @@ -0,0 +1,54 @@ +# CLI Command Interface Specification + +## Purpose + +This capability gives the repository's CLI tools a predictable command interface with discoverable help, early argument validation, and errors suitable for both shell users and agents. + +## Requirements + +### Requirement: CLI tools SHALL expose discoverable command help + +An adopting CLI tool SHALL expose top-level help through `--help` and through an invocation without a command. Nested resources and commands SHALL expose their own usage and option descriptions through the same help mechanism. Help requests MUST complete without loading a session or connecting to an external service. + +#### Scenario: pgc top-level help + +- **WHEN** the user runs `pgc --help` or `pgc` without a command +- **THEN** `pgc` prints its command overview and exits successfully without reading database credentials or opening a database connection + +#### Scenario: pgc command help + +- **WHEN** the user runs `pgc query --help` or `pgc schema search --help` +- **THEN** `pgc` prints usage and the options supported by that command and exits successfully without executing a query + +### Requirement: CLI tools SHALL validate command arguments before execution + +An adopting CLI tool SHALL reject unknown commands, unknown options, missing required options, and values that do not match the option type before invoking the command handler. Validation failures SHALL use exit code `2`; when JSON output is requested, they SHALL be represented by the standard structured error envelope. + +#### Scenario: missing required option + +- **WHEN** the user runs a command that requires a session without providing its session option +- **THEN** the CLI returns a structured validation error with exit code `2` and does not load a session or connect to PostgreSQL + +#### Scenario: unknown option + +- **WHEN** the user supplies an option that is not supported by the selected command +- **THEN** the CLI returns a structured validation error with exit code `2` before the command handler runs + +#### Scenario: invalid typed value + +- **WHEN** the user supplies a value that cannot be parsed as the selected option's type +- **THEN** the CLI returns a structured validation error with exit code `2` before the command handler runs + +### Requirement: Migrated command interfaces SHALL preserve supported pgc invocations + +The `pgc` command interface SHALL continue to support its existing resources and verbs, output mode flags, named-session options, query input options, schema exploration options, relationship options, and comparison options. In particular, query execution SHALL continue to accept exactly one of `--sql` and `--sql-file`, and SHALL retain the per-query `--row-limit` override. + +#### Scenario: query input options remain available + +- **WHEN** the user runs a valid `pgc query` command with either `--sql` or `--sql-file` and an optional `--row-limit` +- **THEN** `pgc` executes the same query-input behavior and applies the same safety limits as before the command-interface migration + +#### Scenario: schema and comparison commands remain available + +- **WHEN** the user runs a valid schema exploration, relationship, or two-session comparison command +- **THEN** `pgc` dispatches it to the corresponding existing functionality with the requested output format diff --git a/packages/postgres-cli/README.md b/packages/postgres-cli/README.md index 5458677..0d41ae0 100644 --- a/packages/postgres-cli/README.md +++ b/packages/postgres-cli/README.md @@ -9,13 +9,13 @@ Read-only PostgreSQL explorer for agents. The platform-specific config path is shown by: ```bash -pgc --json config path +pgc config path --json ``` Initialize an empty config with: ```bash -pgc --json config init +pgc config init --json ``` A config contains named sessions and bounded read defaults: @@ -53,16 +53,16 @@ Use `password` instead of `passwordEnv` only when the local config policy permit Start with the preflight check: ```bash -pgc --json doctor +pgc doctor --json ``` Explore a large schema progressively: ```bash -pgc --json schema overview --session qa -pgc --json schema search --session qa --query user --type table -pgc --json schema table --session qa --schema public --table users -pgc --json schema relations --session qa --schema public --table users --direction both +pgc schema overview --session qa --json +pgc schema search --session qa --query user --type table --json +pgc schema table --session qa --schema public --table users --json +pgc schema relations --session qa --schema public --table users --direction both --json ``` `schema table` includes PostgreSQL comments for the table and its columns when they are defined. `schema search` matches both object names and comments, which makes documented business terms useful for finding tables and columns. @@ -72,24 +72,41 @@ Schema list responses include `continuation` when the requested limit is reached Run a bounded read-only query: ```bash -pgc --json query --session qa --sql 'SELECT id, email FROM public.users WHERE id = $1' --params '[42]' +pgc query --session qa --sql 'SELECT id, email FROM public.users WHERE id = $1' --params '[42]' --json ``` +Override the configured row limit for one query while keeping the session's byte and statement-timeout limits: + +```bash +pgc query --session qa --row-limit 5000 --sql 'SELECT id, email FROM public.users' --json +``` + +Read complex SQL from a UTF-8 file. This is also convenient on Windows when PowerShell quoting would be cumbersome: + +```powershell +pgc query --session qa --sql-file .\queries\users.sql --row-limit 5000 --json +``` + +`--sql` and `--sql-file` are mutually exclusive. The SQL file may contain a leading UTF-8 BOM; it is ignored. The row-limit override does not disable the configured byte limit or statement timeout. + Compare two independently supplied queries by same-named key columns. Use SQL aliases when the source column names differ: ```bash -pgc --json compare \ +pgc compare \ --left-session qa \ --right-session uat \ --left-query 'SELECT id, status FROM public.users' \ --right-query 'SELECT user_id AS id, status FROM public.accounts' \ - --key id + --key id \ + --json ``` +Run `pgc --help` or `pgc --help` for generated command and option help. Output flags are conventionally placed after the command; the legacy form with `--json` before the command remains accepted. + ## Safety and output - Every query runs in a PostgreSQL read-only transaction. - Mutating, session-control, transaction-control, and multi-statement SQL is rejected before execution. -- Queries are bounded by statement timeout, row limit, and result byte limit. +- Queries are bounded by statement timeout, row limit, and result byte limit. `--row-limit` can override the configured row limit for one query, but byte and timeout limits always remain active. - JSON is the canonical agent format. `--md` renders a human-readable view; `--csv` is for tabular query results only. - Truncated, timed-out, unavailable, or incompatible comparison inputs are marked incomplete and are never reported as complete equality. diff --git a/packages/postgres-cli/package.json b/packages/postgres-cli/package.json index 353a199..1d11cbf 100644 --- a/packages/postgres-cli/package.json +++ b/packages/postgres-cli/package.json @@ -16,7 +16,7 @@ "scripts": { "start": "node ./bin/pgc.js", "build:pack": "node ../../scripts/build-self-contained-cli.mjs packages/postgres-cli pgc", - "lint": "node --check ./bin/pgc.js && node --check ./src/cli.js", + "lint": "node --check ./bin/pgc.js && node --check ./src/cli.js && node --check ./src/lib/command-spec.js", "test": "node --test test/*.test.js", "pack:check": "pnpm build:pack && node ../../scripts/test-self-contained-cli.mjs packages/postgres-cli pgc && npm pack --dry-run --cache ./.npm-cache", "prepublishOnly": "pnpm lint && pnpm test && pnpm pack:check" @@ -34,8 +34,7 @@ "license": "MIT", "publishConfig": { "access": "public", - "registry": "https://registry.npmjs.org/", - "provenance": true + "registry": "https://registry.npmjs.org/" }, "repository": { "type": "git", @@ -43,6 +42,7 @@ "directory": "packages/postgres-cli" }, "dependencies": { + "cmd-ts": "^0.15.0", "pg": "^8.16.3" }, "devDependencies": { diff --git a/packages/postgres-cli/src/cli.js b/packages/postgres-cli/src/cli.js index c46fe64..311aaf6 100644 --- a/packages/postgres-cli/src/cli.js +++ b/packages/postgres-cli/src/cli.js @@ -1,14 +1,7 @@ -import { createCliArgParser } from "@khaale/cli-core"; +import { runSafely } from "cmd-ts"; import { CliError } from "./lib/errors.js"; -import { loadConfig } from "./lib/config.js"; +import { pgcCli, normalizePgcArgv, unwrapCommandResult, createPgcCli } from "./lib/command-spec.js"; import { resolveFormat, writeOutput } from "./lib/output.js"; -import { executeReadQuery, diagnoseSession } from "./lib/postgres.js"; -import { compareQueries } from "./lib/compare.js"; -import { relationships, schemaOverview, schemaSearch, tableDetail } from "./lib/schema.js"; - -const parseArgs = createCliArgParser({ - booleanFlags: ["json", "md", "csv", "compact", "force", "help"] -}); export async function main(argv, dependencies = {}) { const result = await run(argv, dependencies); @@ -23,18 +16,18 @@ export async function run(argv, dependencies = {}) { const wantsJson = !argv.includes("--md") && !argv.includes("--csv"); try { - const parsed = parseArgs(argv); - if (parsed.options.help || argv.length === 0) { - stdout.write(`${HELP_TEXT}\n`); - return { exitCode: 0 }; + const outcome = await runSafely(createPgcCli(dependencies), normalizePgcArgv(argv)); + if (outcome._tag === "error") { + return writeCommandError(outcome.error.config, { wantsJson, stdout, stderr }); } - const data = await dispatch(parsed, dependencies); - writeOutput(data, resolveFormat(parsed.options), { - compact: parsed.options.compact, - fields: parseCsv(parsed.options.fields), + + const { result, outputOptions } = unwrapCommandResult(outcome.value); + writeOutput(result, resolveFormat(outputOptions), { + compact: outputOptions.compact, + fields: outputOptions.fields, stdout }); - return { exitCode: 0, data }; + return { exitCode: 0, data: result }; } catch (error) { const normalized = normalizeError(error); if (wantsJson) { @@ -47,79 +40,21 @@ export async function run(argv, dependencies = {}) { } } -async function dispatch(parsed, dependencies) { - const { resource, verb, options } = parsed; - const config = await loadConfig(dependencies); - - if (resource === "doctor") { - const base = { ok: true, tool: "pgc", config: config.safeView() }; - if (!options.session) { - return base; - } - - const session = config.getSession(options.session); - return { ...base, session: session.name, diagnosis: await diagnoseSession(session, dependencies) }; +function writeCommandError(error, { wantsJson, stdout, stderr }) { + const exitCode = error.exitCode === 0 ? 0 : 2; + if (exitCode === 0) { + (error.into === "stderr" ? stderr : stdout).write(error.message.endsWith("\n") ? error.message : `${error.message}\n`); + return { exitCode: 0 }; } - if (resource === "sessions" && verb === "list") { - return { ok: true, sessions: config.listSessions() }; + const normalized = { message: error.message, exitCode, code: "cli_error" }; + if (wantsJson) { + writeError(stdout, normalized); + } else { + stderr.write(`${normalized.message}\n`); } - if (resource === "config" && verb === "path") { - return { ok: true, path: config.path }; - } - - if (resource === "config" && verb === "get") { - return { ok: true, ...config.safeView() }; - } - - if (resource === "config" && verb === "init") { - return config.init({ force: options.force }); - } - - if (resource === "query") { - const session = config.getSession(options.session); - const result = await executeReadQuery( - session, - options.sql, - parseJsonArray(options.params, "query parameters"), - { ...dependencies, ...options } - ); - return { ok: true, kind: "query", session: session.name, ...result }; - } - - if (resource === "schema") { - if (verb === "overview") { - return schemaOverview(config, { ...options, ...dependencies }); - } - - if (verb === "search") { - return schemaSearch(config, { ...options, ...dependencies }); - } - - if (verb === "table") { - return tableDetail(config, { ...options, ...dependencies }); - } - - if (verb === "relations") { - const result = await relationships(config, { ...options, ...dependencies }); - return { ok: true, kind: "schema-relations", session: options.session, table: { schema: options.schema, name: options.table }, ...result }; - } - } - - if (resource === "compare") { - return compareQueries(config, { ...options, ...dependencies }); - } - - throw new CliError(`unsupported command: ${[resource, verb].filter(Boolean).join(" ") || "(empty)"}`, 2); -} - -function parseCsv(value) { - if (!value) { - return null; - } - - return value.split(",").map((item) => item.trim()).filter(Boolean); + return { exitCode, error: normalized }; } function normalizeError(error) { @@ -127,23 +62,7 @@ function normalizeError(error) { return { message: error.message, exitCode: error.exitCode, code: error.code || "cli_error" }; } - return { message: error?.message || String(error), exitCode: 1, code: "internal_error" }; -} - -function parseJsonArray(value, label) { - if (value === undefined || value === null || value === "") { - return []; - } - - try { - const parsed = JSON.parse(value); - if (!Array.isArray(parsed)) { - throw new Error("expected an array"); - } - return parsed; - } catch { - throw new CliError(`${label} must be a JSON array`, 2); - } + return { message: error?.message || String(error), exitCode: error?.exitCode || 1, code: error?.code || "internal_error" }; } function writeError(stdout, error) { @@ -156,16 +75,4 @@ function writeError(stdout, error) { }, null, 2)}\n`); } -const HELP_TEXT = `pgc - read-only PostgreSQL explorer for agents - -Commands: - pgc --json doctor [--session NAME] - pgc --json sessions list - pgc --json schema overview --session NAME - pgc --json schema search --session NAME --query TEXT [--type TYPE] [--schema NAME] - pgc --json schema table --session NAME --schema NAME --table NAME - pgc --json schema relations --session NAME --schema NAME --table NAME [--direction incoming|outgoing|both] - pgc --json query --session NAME --sql SQL [--params JSON_ARRAY] - pgc --json compare --left-session NAME --right-session NAME --left-query SQL --right-query SQL --key COLUMN[,COLUMN] - -Output is JSON by default. Use --md for human-readable output or --csv for tabular query results.`; +export { pgcCli }; diff --git a/packages/postgres-cli/src/lib/command-spec.js b/packages/postgres-cli/src/lib/command-spec.js new file mode 100644 index 0000000..c3b9e03 --- /dev/null +++ b/packages/postgres-cli/src/lib/command-spec.js @@ -0,0 +1,261 @@ +import { + command, + extendType, + flag, + oneOf, + option, + optional, + string, + subcommands +} from "cmd-ts"; +import { compareQueries } from "./compare.js"; +import { loadConfig } from "./config.js"; +import { CliError } from "./errors.js"; +import { diagnoseSession, executeReadQuery } from "./postgres.js"; +import { parseRowLimit, resolveQueryInput } from "./query-input.js"; +import { relationships, schemaOverview, schemaSearch, tableDetail } from "./schema.js"; + +const CONFIG_VERBS = new Set(["init", "get", "path"]); +const csvType = extendType(string, (value) => + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +); +const positiveIntegerType = extendType(string, (value) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("must be a positive integer"); + } + return parsed; +}); + +const directionType = oneOf(["incoming", "outgoing", "both"]); + +export function createPgcCli(dependencies = {}) { + return subcommands({ + name: "pgc", + description: "Read-only PostgreSQL explorer for agents.", + cmds: { + doctor: commandLeaf("doctor", "Verify configuration and optionally database reachability.", { + ...outputArgs(), + session: textOption("session", "Named PostgreSQL session to diagnose.") + }, async (args) => { + const config = await loadConfig(dependencies); + const base = { ok: true, tool: "pgc", config: config.safeView() }; + if (!args.session) { + return base; + } + + const session = config.getSession(args.session); + return { ...base, session: session.name, diagnosis: await diagnoseSession(session, dependencies) }; + }), + sessions: subcommands({ + name: "sessions", + description: "Inspect configured PostgreSQL sessions.", + cmds: { + list: commandLeaf("list", "List safe metadata for named sessions.", outputArgs(), async () => { + const config = await loadConfig(dependencies); + return { ok: true, sessions: config.listSessions() }; + }) + } + }), + config: subcommands({ + name: "config", + description: "Manage persisted PostgreSQL CLI configuration.", + cmds: { + init: commandLeaf("init", "Create or update the global config file.", { + ...outputArgs(), + force: flag({ long: "force", description: "Overwrite an existing config file." }) + }, async (args) => { + const config = await loadConfig(dependencies); + return config.init({ force: args.force }); + }), + get: commandLeaf("get", "Show resolved config metadata.", outputArgs(), async () => { + const config = await loadConfig(dependencies); + return { ok: true, ...config.safeView() }; + }), + path: commandLeaf("path", "Print the absolute config path.", outputArgs(), async () => { + const config = await loadConfig(dependencies); + return { ok: true, path: config.path }; + }) + } + }), + schema: subcommands({ + name: "schema", + description: "Explore PostgreSQL schema metadata progressively.", + cmds: { + overview: commandLeaf("overview", "List schemas and bounded object counts.", { + ...outputArgs(), + session: requiredTextOption("session", "Named PostgreSQL session."), + limit: numberOption("limit", "Maximum number of schemas to return.") + }, async (args) => schemaOverview(await loadConfig(dependencies), { ...args, ...dependencies })), + search: commandLeaf("search", "Find tables, views, routines, and columns by name or comment.", { + ...outputArgs(), + session: requiredTextOption("session", "Named PostgreSQL session."), + query: requiredTextOption("query", "Text to search in object names and comments."), + type: textOption("type", "Restrict results to table, view, routine, or column."), + schema: textOption("schema", "Restrict results to one schema."), + limit: numberOption("limit", "Maximum number of objects to return.") + }, async (args) => schemaSearch(await loadConfig(dependencies), { ...args, ...dependencies })), + table: commandLeaf("table", "Inspect one table, its columns, constraints, and relationships.", { + ...outputArgs(), + session: requiredTextOption("session", "Named PostgreSQL session."), + schema: requiredTextOption("schema", "PostgreSQL schema name."), + table: requiredTextOption("table", "PostgreSQL table name.") + }, async (args) => tableDetail(await loadConfig(dependencies), { ...args, ...dependencies })), + relations: commandLeaf("relations", "Show incoming and outgoing foreign-key relationships.", { + ...outputArgs(), + session: requiredTextOption("session", "Named PostgreSQL session."), + schema: requiredTextOption("schema", "PostgreSQL schema name."), + table: requiredTextOption("table", "PostgreSQL table name."), + direction: option({ + long: "direction", + type: optional(directionType), + description: "Relationship direction: incoming, outgoing, or both." + }) + }, async (args) => { + const config = await loadConfig(dependencies); + const result = await relationships(config, { ...args, ...dependencies }); + return { + ok: true, + kind: "schema-relations", + session: args.session, + table: { schema: args.schema, name: args.table }, + ...result + }; + }) + } + }), + query: commandLeaf("query", "Execute one bounded, read-only PostgreSQL query.", { + ...outputArgs(), + session: requiredTextOption("session", "Named PostgreSQL session."), + sql: textOption("sql", "SQL query text."), + sqlFile: textOption("sql-file", "UTF-8 file containing the SQL query."), + params: textOption("params", "Query parameters as a JSON array."), + rowLimit: numberOption("row-limit", "Positive per-query row-limit override.") + }, async (args) => { + const config = await loadConfig(dependencies); + const session = config.getSession(args.session); + const sql = await resolveQueryInput(args, config.fsImpl); + const rowLimit = parseRowLimit(args.rowLimit); + const result = await executeReadQuery( + session, + sql, + parseJsonArray(args.params, "query parameters"), + { ...dependencies, ...args, rowLimit } + ); + return { ok: true, kind: "query", session: session.name, ...result }; + }), + compare: commandLeaf("compare", "Compare the results of two queries by same-named key columns.", { + ...outputArgs(), + leftSession: requiredTextOption("left-session", "Named session for the left query."), + rightSession: requiredTextOption("right-session", "Named session for the right query."), + leftQuery: requiredTextOption("left-query", "SQL query for the left session."), + rightQuery: requiredTextOption("right-query", "SQL query for the right session."), + leftParams: textOption("left-params", "Left query parameters as a JSON array."), + rightParams: textOption("right-params", "Right query parameters as a JSON array."), + key: option({ + long: "key", + type: csvType, + description: "Comma-separated same-named key columns." + }) + }, async (args) => compareQueries(await loadConfig(dependencies), { ...args, ...dependencies })) + } + }); +} + +export const pgcCli = createPgcCli(); + +export function normalizePgcArgv(argv) { + if (argv.length === 0) { + return ["--help"]; + } + + const leadingFlags = []; + let index = 0; + while (index < argv.length && ["--json", "--md", "--csv", "--compact", "--force"].includes(argv[index])) { + leadingFlags.push(argv[index]); + index += 1; + } + + const normalized = index === 0 ? argv : [...argv.slice(index), ...leadingFlags]; + const [first, second] = normalized; + if (CONFIG_VERBS.has(first) && (!second || second.startsWith("-"))) { + return ["config", ...normalized]; + } + + return normalized; +} + +export function unwrapCommandResult(value) { + if (value && typeof value === "object" && "command" in value && "value" in value) { + return unwrapCommandResult(value.value); + } + + return value; +} + +function commandLeaf(name, description, args, execute) { + return command({ + name, + description, + args, + handler: async (parsedArgs) => ({ + result: await execute(parsedArgs), + outputOptions: pickOutputOptions(parsedArgs) + }) + }); +} + +function outputArgs() { + return { + fields: csvOption("fields", "Comma-separated fields to project from the output."), + json: flag({ long: "json", description: "Force JSON output." }), + md: flag({ long: "md", description: "Force Markdown output." }), + csv: flag({ long: "csv", description: "Force CSV output for tabular query results." }), + compact: flag({ long: "compact", description: "Use compact JSON output." }) + }; +} + +function requiredTextOption(long, description) { + return option({ long, type: string, description }); +} + +function textOption(long, description) { + return option({ long, type: optional(string), description }); +} + +function csvOption(long, description) { + return option({ long, type: optional(csvType), description }); +} + +function numberOption(long, description) { + return option({ long, type: optional(positiveIntegerType), description }); +} + +function pickOutputOptions(args) { + return { + fields: args.fields, + json: args.json, + md: args.md, + csv: args.csv, + compact: args.compact + }; +} + +function parseJsonArray(value, label) { + if (value === undefined || value === null || value === "") { + return []; + } + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + throw new Error("expected an array"); + } + return parsed; + } catch { + throw new CliError(`${label} must be a JSON array`, 2); + } +} diff --git a/packages/postgres-cli/src/lib/query-input.js b/packages/postgres-cli/src/lib/query-input.js new file mode 100644 index 0000000..52691fb --- /dev/null +++ b/packages/postgres-cli/src/lib/query-input.js @@ -0,0 +1,34 @@ +import { fail } from "./errors.js"; + +export async function resolveQueryInput(options, fsImpl) { + const hasInlineSql = options.sql !== undefined; + const hasSqlFile = options.sqlFile !== undefined; + + if (hasInlineSql === hasSqlFile) { + fail("query requires exactly one of --sql or --sql-file", 2); + } + + if (hasInlineSql) { + return options.sql; + } + + try { + const sql = await fsImpl.readFile(options.sqlFile, "utf8"); + return sql.charCodeAt(0) === 0xfeff ? sql.slice(1) : sql; + } catch (error) { + fail(`unable to read SQL file: ${options.sqlFile}`, 2); + } +} + +export function parseRowLimit(value) { + if (value === undefined) { + return undefined; + } + + const rowLimit = Number(value); + if (!Number.isInteger(rowLimit) || rowLimit <= 0) { + fail("--row-limit must be a positive integer", 2); + } + + return rowLimit; +} diff --git a/packages/postgres-cli/test/cli.test.js b/packages/postgres-cli/test/cli.test.js index c9c2e72..b051666 100644 --- a/packages/postgres-cli/test/cli.test.js +++ b/packages/postgres-cli/test/cli.test.js @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; import test from "node:test"; import { run } from "../src/cli.js"; -import { captureStream, createConfigFile } from "./support.js"; +import { captureStream, createConfigFile, fakeClient } from "./support.js"; test("CLI lists named sessions in a stable JSON envelope", async () => { const { configPath } = await createConfigFile({ @@ -27,3 +29,64 @@ test("CLI returns a JSON error for unsupported commands", async () => { assert.equal(payload.ok, false); assert.equal(payload.error.code, "cli_error"); }); + +test("query reads UTF-8 SQL files and applies a command-line row limit", async () => { + const { directory, configPath } = await createConfigFile({ + sessions: { + qa: { host: "127.0.0.1", database: "app", user: "agent", password: "secret" } + }, + defaults: { statementTimeoutMs: 1234, rowLimit: 1000, byteLimit: 5 } + }); + const sqlPath = path.join(directory, "query.sql"); + await fs.writeFile(sqlPath, "\ufeffSELECT привет FROM public.orders", "utf8"); + const stdout = captureStream(); + const client = fakeClient({ rows: [{ id: 1 }, { id: 2 }] }); + + const result = await run( + ["--json", "query", "--session", "qa", "--sql-file", sqlPath, "--row-limit", "5000"], + { configPath, stdout, clientFactory: client.factory } + ); + + const payload = JSON.parse(stdout.value); + const queryCall = client.calls.find(([, query]) => typeof query === "object" && query?.text?.includes("pgc_result")); + const timeoutCall = client.calls.find(([, query]) => typeof query === "string" && query.startsWith("SET LOCAL statement_timeout")); + + assert.equal(result.exitCode, 0); + assert.equal(payload.ok, true); + assert.match(queryCall[1].text, /SELECT привет FROM public\.orders/); + assert.match(queryCall[1].text, /LIMIT 5001/); + assert.equal(timeoutCall[1], "SET LOCAL statement_timeout TO 1234"); + assert.equal(payload.truncated, true); +}); + +test("query rejects invalid SQL sources and row limits before connecting", async () => { + const { configPath } = await createConfigFile({ + sessions: { + qa: { host: "127.0.0.1", database: "app", user: "agent", password: "secret" } + } + }); + + for (const args of [ + ["--json", "query", "--session", "qa"], + ["--json", "query", "--session", "qa", "--sql", "SELECT 1", "--sql-file", "missing.sql"], + ["--json", "query", "--session", "qa", "--sql-file", "missing.sql"], + ["--json", "query", "--session", "qa", "--sql", "SELECT 1", "--row-limit", "0"] + ]) { + const stdout = captureStream(); + let connected = false; + const result = await run(args, { + configPath, + stdout, + clientFactory: () => { + connected = true; + throw new Error("should not connect"); + } + }); + const payload = JSON.parse(stdout.value); + + assert.equal(result.exitCode, 2); + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "cli_error"); + assert.equal(connected, false); + } +}); diff --git a/packages/postgres-cli/test/command-spec.test.js b/packages/postgres-cli/test/command-spec.test.js new file mode 100644 index 0000000..a5f8885 --- /dev/null +++ b/packages/postgres-cli/test/command-spec.test.js @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parse, runSafely } from "cmd-ts"; +import { pgcCli, normalizePgcArgv } from "../src/lib/command-spec.js"; + +test("normalizePgcArgv follows shared config shorthand and preserves prefix output flags", () => { + assert.deepEqual(normalizePgcArgv(["init", "--force"]), ["config", "init", "--force"]); + assert.deepEqual(normalizePgcArgv(["--json", "path"]), ["config", "path", "--json"]); + assert.deepEqual(normalizePgcArgv([]), ["--help"]); +}); + +test("cmd-ts parses pgc query options with generated output flags", async () => { + const outcome = await parse(pgcCli, [ + "query", + "--session", + "qa", + "--sql-file", + "queries/orders.sql", + "--row-limit", + "5000", + "--json" + ]); + + assert.equal(outcome._tag, "ok"); + assert.equal(outcome.value.args.sqlFile, "queries/orders.sql"); + assert.equal(outcome.value.args.rowLimit, 5000); + assert.equal(outcome.value.args.json, true); +}); + +test("cmd-ts validates typed and required pgc options before execution", async () => { + for (const args of [ + ["query", "--sql", "SELECT 1"], + ["query", "--session", "qa", "--sql", "SELECT 1", "--row-limit", "0"], + ["schema", "relations", "--session", "qa", "--schema", "public", "--table", "orders", "--direction", "sideways"], + ["query", "--session", "qa", "--sql", "SELECT 1", "--unknown"] + ]) { + const outcome = await runSafely(pgcCli, args); + assert.equal(outcome._tag, "error"); + assert.match(outcome.error.config.message, /error|must be|unknown|not a valid|missing/i); + } +}); + +test("cmd-ts exposes help for top-level and nested pgc commands", async () => { + const topLevel = await runSafely(pgcCli, ["--help"]); + const query = await runSafely(pgcCli, ["query", "--help"]); + const schema = await runSafely(pgcCli, ["schema", "search", "--help"]); + + assert.equal(topLevel._tag, "error"); + assert.equal(topLevel.error.config.exitCode, 0); + assert.match(topLevel.error.config.message, /schema|compare/); + assert.equal(query.error.config.exitCode, 0); + assert.match(query.error.config.message, /--sql-file|--row-limit/); + assert.equal(schema.error.config.exitCode, 0); + assert.match(schema.error.config.message, /--query|--type|--schema/); +}); + +test("nested parse results retain the command result envelope shape", async () => { + const outcome = await parse(pgcCli, ["schema", "relations", "--session", "qa", "--schema", "public", "--table", "orders"]); + const parsed = outcome.value.args; + + assert.equal(outcome._tag, "ok"); + assert.equal(parsed.args.direction, undefined); + assert.equal(parsed.args.session, "qa"); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74a1a01..55cdaf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: packages/postgres-cli: dependencies: + cmd-ts: + specifier: ^0.15.0 + version: 0.15.0 pg: specifier: ^8.16.3 version: 8.23.0 diff --git a/skills/postgres-cli/SKILL.md b/skills/postgres-cli/SKILL.md index 41ddb59..fa9557d 100644 --- a/skills/postgres-cli/SKILL.md +++ b/skills/postgres-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: pgc -description: Companion skill for the `pgc` PostgreSQL CLI. Run `pgc --json doctor` first, use named sessions without handling credentials, explore large schemas progressively, execute bounded read-only queries, and compare two query results by key columns. +description: Companion skill for the `pgc` PostgreSQL CLI. Run `pgc doctor --json` first, use named sessions without handling credentials, explore large schemas progressively, execute bounded read-only queries, and compare two query results by key columns. --- # `pgc` Companion Skill @@ -18,14 +18,14 @@ command -v pgc 2. Run the preflight check before real reads: ```bash -pgc --json doctor +pgc doctor --json ``` 3. Select a configured session by name, such as `qa` or `uat`. Never pass a password or credential-bearing connection string as an argument. ## Session and output rules -- Use `pgc --json sessions list` or `pgc --json config get` to inspect safe session metadata. +- Use `pgc sessions list --json` or `pgc config get --json` to inspect safe session metadata. - JSON is the canonical format for agent processing. - Use `--md` only when a human-readable summary is needed. - Use `--csv` only for flat tabular query results; use JSON for schema and comparison results. @@ -37,10 +37,10 @@ pgc --json doctor Start narrow and expand only the needed object: ```bash -pgc --json schema overview --session qa -pgc --json schema search --session qa --query order --type table -pgc --json schema table --session qa --schema public --table orders -pgc --json schema relations --session qa --schema public --table orders --direction both +pgc schema overview --session qa --json +pgc schema search --session qa --query order --type table --json +pgc schema table --session qa --schema public --table orders --json +pgc schema relations --session qa --schema public --table orders --direction both --json ``` Search supports table, view, routine, and column names and PostgreSQL comments with optional `--schema` and `--type` filters. Table detail returns table and column comments when available. Relationship results distinguish `incoming` and `outgoing` foreign keys and preserve composite-key column order. @@ -52,12 +52,31 @@ Treat a non-null `continuation` in schema overview/search as an incomplete list Use parameterized, bounded read queries: ```bash -pgc --json query \ +pgc query \ --session qa \ --sql 'SELECT id, status FROM public.orders WHERE id = $1' \ - --params '[42]' + --params '[42]' \ + --json ``` +For a larger intentional read, override only the row limit for this invocation: + +```bash +pgc query \ + --session qa \ + --row-limit 5000 \ + --sql 'SELECT id, status FROM public.orders' \ + --json +``` + +Keep the byte and statement-timeout limits as safety bounds; `--row-limit` does not disable them. For complex SQL, especially on Windows/PowerShell, read a UTF-8 file instead of passing the script through shell quoting: + +```powershell +pgc query --session qa --sql-file .\queries\orders.sql --row-limit 5000 --json +``` + +Use exactly one of `--sql` and `--sql-file`. Files with a leading UTF-8 BOM are supported. + The CLI rejects writes, DDL, session/transaction control, and multi-statement SQL. Every query runs in a read-only transaction with timeout, row, and byte limits. If more detail is needed, narrow the query explicitly rather than trying to bypass the limits. ## Compare two query results @@ -65,12 +84,13 @@ The CLI rejects writes, DDL, session/transaction control, and multi-statement SQ Provide distinct sessions, independent read-only queries, and same-named key columns: ```bash -pgc --json compare \ +pgc compare \ --left-session qa \ --right-session uat \ --left-query 'SELECT id, status FROM public.orders' \ --right-query 'SELECT order_id AS id, status FROM public.orders' \ - --key id + --key id \ + --json ``` The comparison reports equal, changed, left-only, and right-only rows. If either query is truncated, times out, fails, or has incompatible columns, the result is incomplete and must not be described as equality.