From cc73b40c59b9724a0db18fbb9da86e85191e87e1 Mon Sep 17 00:00:00 2001 From: Jack Schultz Date: Wed, 10 Jun 2026 17:09:43 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20pgcrate=20skill=20=E2=80=94=20SKI?= =?UTF-8?q?LL.md=20+=20skill=20show/install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow-organized Claude Code skill (178 lines) embedded via include_str!, teaching agents when/how to use pgcrate: session-start orientation, guarded querying, migration loop, seeds, prod triage, safety conventions. Every documented command and flag verified against the built binary. New `pgcrate skill` subcommand: `show` prints to stdout; `install` writes to ~/.claude/skills/pgcrate/SKILL.md (idempotent, refuses to clobber local edits without --force, --path to override). Five unit tests. --- skill/SKILL.md | 178 ++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/skill.rs | 125 +++++++++++++++++++++++++++++ src/main.rs | 30 +++++++ 4 files changed, 334 insertions(+) create mode 100644 skill/SKILL.md create mode 100644 src/commands/skill.rs diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..f882bbb --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,178 @@ +--- +name: pgcrate +description: >- + Use when working with a PostgreSQL database: running queries, inspecting + schema, writing or applying migrations, loading seed/test data, or + diagnosing health and performance ("why is the DB slow", locks, bloat, + sequence exhaustion, slow queries). pgcrate is a single binary that wraps + psql-class work with read-only-by-default guardrails, timeouts, structured + JSON, and semantic exit codes so the work is safe to run unsupervised. +--- + +# pgcrate: the Postgres interface for agents + +`pgcrate` is a CLI you reach for instead of raw `psql` when touching Postgres. +You are the user; the human supervising you is the customer. pgcrate exists so +that work is trustworthy by default: reads can't accidentally write, queries +can't hang forever, and output is dense and machine-readable. + +## Why pgcrate over raw psql + +- **Read-only by default.** `sql` refuses writes unless you pass `--allow-write`; + diagnostics never write. You can't `DROP` something by accident. +- **Timeouts on everything.** Connect/statement/lock timeouts are enforced + (defaults 5s / 30s / 500ms; override with `--connect-timeout`, + `--statement-timeout`, `--lock-timeout`). A bad query fails fast instead of + pinning a backend. +- **Structured output.** Add `--json` to query/diagnostic commands when the + result feeds further work. Human tables otherwise. +- **Semantic exit codes** — branch on them, don't parse text: + - `0` healthy / success + - `1` warning (non-critical finding) + - `2` critical finding + - `10+` operational failure (`11` connection, `12` config, `13` permission, + `130` interrupted) — i.e. "couldn't check", not "found a problem". + +Connection comes from `-d `, `DATABASE_URL`, a named `-C ` from +`pgcrate.toml`, or `--env `. `--help-llm` on any command dumps the full +exhaustive reference; this skill is the *when/why*, that flag is the *what*. + +## Session start — orient before you act + +```bash +pgcrate context # connection, server version, extensions, privileges, read/write mode +pgcrate capabilities # what you're actually allowed to do here +pgcrate inspect table # columns, indexes, constraints, stats +pgcrate inspect table --dependents # what breaks if you change it +pgcrate inspect roles # roles/users; add --describe for one +pgcrate inspect extensions # installed extensions (--available for the rest) +``` + +Run `context` first on an unfamiliar database — it tells you the PG version, +whether you're on a replica, and whether you even have permission for the +diagnostics below. (A one-shot `brief` summary command is coming; not available yet.) + +## Querying + +```bash +pgcrate sql -c "SELECT ..." # read-only; blocks writes +echo "SELECT ..." | pgcrate sql # reads from stdin if no -c +pgcrate sql -c "SELECT ..." --json # structured rows for downstream use +pgcrate sql -c "UPDATE ..." --allow-write # opt in to writes, explicitly +``` + +Default is read-only: a write statement errors with a prompt to re-run with +`--allow-write`. Only escalate when the human asked for a write. `query` is an +alias for `sql`. + +## Migration loop + +Migrations are single `.sql` files with `-- up` / `-- down` sections. + +```bash +pgcrate migrate new add_users # scaffold timestamped file; then edit the SQL +pgcrate migrate up # apply pending migrations +pgcrate migrate status # what's applied vs pending (supports --json) +pgcrate migrate up --dry-run # preview without applying +``` + +Rolling back is **dev-only** and gated: + +```bash +pgcrate migrate down --steps 1 --yes # --steps is required; --yes confirms +``` + +Never run `migrate down` against production data. `pgcrate generate` (top-level) +can emit migration files from an existing schema (brownfield); `migrate baseline` +marks existing files as already-applied. + +## Test data (seeds) + +```bash +pgcrate seed list # available seed files +pgcrate seed validate # check files parse, without loading +pgcrate seed run # load all (or name specific: pgcrate seed run public.users) +pgcrate seed diff # compare seed files to current DB state +``` + +Use `seed diff` before `seed run` to see what would change. + +## Production triage loop + +Lead with triage, then drill into whatever it flags. All of these are read-only +and time-bounded, so they're safe to run against prod. + +```bash +pgcrate dba triage --include-fixes # one-shot health scan + suggested fixes +``` + +Then target the area triage (or the symptom) points at: + +```bash +pgcrate dba locks # blocking chains, long/idle-in-tx (--blocking, --long-tx N) +pgcrate dba explain "SELECT ..." # plan analysis (add --analyze to actually run it) +pgcrate dba queries # top queries from pg_stat_statements (--by mean|calls) +pgcrate dba storage # disk usage by table/index/TOAST +pgcrate dba indexes # missing / unused / duplicate indexes +pgcrate dba bloat # table & index bloat estimates +pgcrate dba vacuum # vacuum health and dead-tuple ratios +pgcrate dba sequences # sequence exhaustion risk +pgcrate dba xid # transaction-ID wraparound risk +pgcrate dba connections # connection usage vs max_connections +pgcrate dba cache # buffer cache hit ratios +pgcrate dba wal # WAL generation / archiving / disk +pgcrate dba replication # streaming replication lag +pgcrate dba checkpoints # checkpoint frequency/health +pgcrate dba config # notable PostgreSQL settings +``` + +"Why is the DB slow?" → `dba triage`, then `dba locks` (blocking?), +`dba queries` (a hot statement?), `dba explain` on the suspect query, +`dba cache`/`dba bloat`/`dba indexes` (storage/IO). + +Remediation is a separate, explicit, write step — each needs `--yes`: + +```bash +pgcrate dba fix sequence --upgrade-to bigint --yes +pgcrate dba fix index --drop --yes +pgcrate dba fix vacuum --yes +pgcrate dba fix bloat --yes # REINDEX CONCURRENTLY by default +``` + +Every `fix` supports `--dry-run` (preview) and `--verify` (re-check after). Run +`--dry-run` first and surface the plan to the human before using `--yes`. + +## Safety conventions + +- Destructive operations require `--yes` — never pass it speculatively; show the + plan/`--dry-run` first and let the human confirm intent. +- Never add `--read-write` (global) or `sql --allow-write` unless the task is + explicitly a write. Default read-only is the guardrail; keep it on. +- Prefer `--json` whenever output is consumed by you for a next step; use human + tables when reporting to the person. +- Treat exit `10+` as "I couldn't check" (fix connectivity/perms), distinct from + `1`/`2` "the DB has a finding". + +## Command reference + +| Goal | Command | +|------|---------| +| Connection + server + privileges | `pgcrate context` | +| What I'm allowed to do | `pgcrate capabilities` | +| Describe a table | `pgcrate inspect table ` | +| Table dependents/dependencies | `pgcrate inspect table --dependents` / `--dependencies` | +| Roles / grants / extensions | `pgcrate inspect roles` / `grants` / `extensions` | +| Compare two schemas | `pgcrate inspect diff --to ` | +| Run a read query | `pgcrate sql -c "SELECT ..."` (add `--json`) | +| Run a write | `pgcrate sql -c "..." --allow-write` | +| New migration | `pgcrate migrate new ` | +| Apply / status | `pgcrate migrate up` / `pgcrate migrate status` | +| Roll back (dev) | `pgcrate migrate down --steps N --yes` | +| Generate from DB / baseline | `pgcrate generate` / `pgcrate migrate baseline` | +| Load / diff seeds | `pgcrate seed run` / `pgcrate seed diff` | +| Health triage | `pgcrate dba triage --include-fixes` | +| Locks / slow queries / plans | `pgcrate dba locks` / `queries` / `explain "..."` | +| Storage / bloat / indexes / vacuum | `pgcrate dba storage` / `bloat` / `indexes` / `vacuum` | +| Sequences / XID / connections / WAL | `pgcrate dba sequences` / `xid` / `connections` / `wal` | +| Apply a fix | `pgcrate dba fix ... --yes` | +| Full exhaustive reference | `pgcrate --help-llm` | diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 21eaf83..34fd513 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -27,6 +27,7 @@ mod role; mod schema; mod seed; pub mod sequences; +pub mod skill; mod snapshot; mod sql_cmd; pub mod stats_age; diff --git a/src/commands/skill.rs b/src/commands/skill.rs new file mode 100644 index 0000000..5ac90d2 --- /dev/null +++ b/src/commands/skill.rs @@ -0,0 +1,125 @@ +//! Skill command: print or install the embedded Claude Code skill. +//! +//! The skill teaches an agent *when and how* to reach for pgcrate. It ships +//! compiled into the binary (`include_str!`) so `pgcrate skill install` always +//! writes the version that matches the running binary. + +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; + +/// The skill document, compiled into the binary. +pub const SKILL_MD: &str = include_str!("../../skill/SKILL.md"); + +/// Print the embedded SKILL.md to stdout. +pub fn show() { + print!("{SKILL_MD}"); +} + +/// Resolve the default install directory: `~/.claude/skills/pgcrate`. +fn default_install_dir() -> Result { + let home = std::env::var_os("HOME") + .filter(|h| !h.is_empty()) + .context("HOME is not set; pass --path to choose an install destination")?; + Ok(PathBuf::from(home) + .join(".claude") + .join("skills") + .join("pgcrate")) +} + +/// Install the embedded skill to a Claude Code skills directory. +/// +/// Writes `/SKILL.md`, creating parent directories as needed. If a file +/// already exists with different content, refuses unless `force` is set so a +/// human's local edits are never clobbered silently. +pub fn install(path: Option<&Path>, force: bool, quiet: bool) -> Result<()> { + let dir = match path { + Some(p) => p.to_path_buf(), + None => default_install_dir()?, + }; + let dest = dir.join("SKILL.md"); + + if dest.exists() && !force { + let existing = std::fs::read_to_string(&dest) + .with_context(|| format!("failed to read existing skill at {}", dest.display()))?; + if existing == SKILL_MD { + if !quiet { + println!("Already up to date: {}", dest.display()); + } + return Ok(()); + } + bail!( + "{} already exists and differs from the bundled skill. \ + Re-run with --force to overwrite, or use --path to choose another destination.", + dest.display() + ); + } + + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create skill directory {}", dir.display()))?; + std::fs::write(&dest, SKILL_MD) + .with_context(|| format!("failed to write skill to {}", dest.display()))?; + + if !quiet { + println!("Installed pgcrate skill: {}", dest.display()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skill_md_has_frontmatter() { + assert!(SKILL_MD.starts_with("---\n")); + assert!(SKILL_MD.contains("name: pgcrate")); + assert!(SKILL_MD.contains("description:")); + } + + #[test] + fn install_writes_skill_to_path() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("skills").join("pgcrate"); + install(Some(&dir), false, true).unwrap(); + let written = std::fs::read_to_string(dir.join("SKILL.md")).unwrap(); + assert_eq!(written, SKILL_MD); + } + + #[test] + fn install_is_idempotent_without_force() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("pgcrate"); + install(Some(&dir), false, true).unwrap(); + // Second install with identical content should succeed (no-op), not error. + install(Some(&dir), false, true).unwrap(); + } + + #[test] + fn install_refuses_to_overwrite_modified_file() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("pgcrate"); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("SKILL.md"); + std::fs::write(&dest, "locally edited skill").unwrap(); + + let err = install(Some(&dir), false, true).unwrap_err(); + assert!(err.to_string().contains("--force")); + // Original content is preserved. + assert_eq!( + std::fs::read_to_string(&dest).unwrap(), + "locally edited skill" + ); + } + + #[test] + fn install_force_overwrites_modified_file() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("pgcrate"); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("SKILL.md"); + std::fs::write(&dest, "locally edited skill").unwrap(); + + install(Some(&dir), true, true).unwrap(); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), SKILL_MD); + } +} diff --git a/src/main.rs b/src/main.rs index d195c6d..87a7a5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -306,6 +306,13 @@ enum Commands { #[arg(long)] full: bool, }, + + // ===== Agent Integration ===== + /// Show or install the pgcrate agent skill (Claude Code SKILL.md) + Skill { + #[command(subcommand)] + command: SkillCommands, + }, } #[derive(Subcommand)] @@ -521,6 +528,22 @@ enum DbCommands { }, } +/// Agent skill commands +#[derive(Subcommand)] +enum SkillCommands { + /// Print the embedded SKILL.md to stdout + Show, + /// Install the skill to a Claude Code skills directory + Install { + /// Destination directory (default: ~/.claude/skills/pgcrate) + #[arg(long, value_name = "DIR")] + path: Option, + /// Overwrite an existing SKILL.md even if it was modified locally + #[arg(long)] + force: bool, + }, +} + #[derive(Subcommand)] enum SnapshotCommands { /// Save current database state to a snapshot @@ -2308,6 +2331,12 @@ async fn run(cli: Cli, output: &Output) -> Result<()> { commands::reset(&database_url, &config, cli.quiet, cli.verbose, yes, full).await?; } + Commands::Skill { command } => match command { + SkillCommands::Show => commands::skill::show(), + SkillCommands::Install { path, force } => { + commands::skill::install(path.as_deref(), force, cli.quiet)?; + } + }, Commands::Anonymize { command } => { let config = Config::load(cli.config_path.as_deref()).context("Failed to load configuration")?; @@ -2456,6 +2485,7 @@ async fn run(cli: Cli, output: &Output) -> Result<()> { | Commands::Anonymize { .. } | Commands::Seed { .. } | Commands::Bootstrap { .. } + | Commands::Skill { .. } | Commands::Status => unreachable!(), } }