Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <url>`, `DATABASE_URL`, a named `-C <name>` from
`pgcrate.toml`, or `--env <VAR>`. `--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 <schema.name> # columns, indexes, constraints, stats
pgcrate inspect table <name> --dependents # what breaks if you change it
pgcrate inspect roles # roles/users; add --describe <name> 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 <schema.seq> --upgrade-to bigint --yes
pgcrate dba fix index --drop <schema.index> --yes
pgcrate dba fix vacuum <schema.table> --yes
pgcrate dba fix bloat <schema.index> --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 <name>` |
| Table dependents/dependencies | `pgcrate inspect table <name> --dependents` / `--dependencies` |
| Roles / grants / extensions | `pgcrate inspect roles` / `grants` / `extensions` |
| Compare two schemas | `pgcrate inspect diff --to <url>` |
| Run a read query | `pgcrate sql -c "SELECT ..."` (add `--json`) |
| Run a write | `pgcrate sql -c "..." --allow-write` |
| New migration | `pgcrate migrate new <name>` |
| 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 <sequence\|index\|vacuum\|bloat> ... --yes` |
| Full exhaustive reference | `pgcrate <cmd> --help-llm` |
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
125 changes: 125 additions & 0 deletions src/commands/skill.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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 `<dir>/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);
}
}
30 changes: 30 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<PathBuf>,
/// 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
Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -2456,6 +2485,7 @@ async fn run(cli: Cli, output: &Output) -> Result<()> {
| Commands::Anonymize { .. }
| Commands::Seed { .. }
| Commands::Bootstrap { .. }
| Commands::Skill { .. }
| Commands::Status => unreachable!(),
}
}
Expand Down
Loading