diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1ed3d6bbc..9d05f3c66 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -5,6 +5,9 @@ export default defineConfig({ description: 'Ephemeral SQL index over a local directory. Watches a filesystem, ingests structured files into an ephemeral SQLite database, and exposes a SQL query interface.', base: '/', + // The guide `dirsql context` prints, compiled into the binary; not site pages. + srcExclude: ['context/**'], + // Vite blocks dev-server requests whose Host header isn't allowlisted, so // `pnpm dev --host` reached by a network hostname (e.g. a Tailscale // `*.ts.net` name) 400s. Allow extra hosts from the environment so a diff --git a/docs/context/.gitattributes b/docs/context/.gitattributes new file mode 100644 index 000000000..616e6f5b0 --- /dev/null +++ b/docs/context/.gitattributes @@ -0,0 +1,2 @@ +# include_str! embeds these verbatim; a CRLF checkout would print a mixed-ending guide. +*.md text eol=lf diff --git a/docs/context/recipes.md b/docs/context/recipes.md new file mode 100644 index 000000000..b0acd1eb9 --- /dev/null +++ b/docs/context/recipes.md @@ -0,0 +1,103 @@ +## Recipes + +Each query runs as written from the directory in question and prints a JSON array of row objects. + +### Largest files + +```sh +uvx dirsql "SELECT path, size FROM './**' ORDER BY size DESC LIMIT 10" --format json +``` + +### Count and total size by extension + +```sh +uvx dirsql "SELECT ext, COUNT(*) AS files, SUM(size) AS bytes FROM './**' GROUP BY ext ORDER BY files DESC" --format json +``` + +`ext` is `NULL` for files with no extension and keeps its case; group by `LOWER(ext)` to fold `JPG` into `jpg`. + +### Files per directory + +```sh +uvx dirsql "SELECT dir, COUNT(*) AS files FROM './**' GROUP BY dir ORDER BY files DESC" --format json +``` + +`dir` is the empty string for files at the top level. + +### Stale files + +Not modified in the last 90 days: + +```sh +uvx dirsql "SELECT path, datetime(mtime, 'unixepoch') AS modified FROM './**' WHERE mtime < strftime('%s', 'now') - 90 * 86400 ORDER BY mtime" --format json +``` + +Most recently modified markdown: + +```sh +uvx dirsql "SELECT path, datetime(mtime, 'unixepoch') AS modified FROM './**/*.md' ORDER BY mtime DESC LIMIT 10" --format json +``` + +### The same JSON field from many files + +```sh +uvx dirsql "SELECT path, content ->> 'name' AS name, content ->> 'version' AS version FROM './**/metadata.json' WHERE json_valid(content) ORDER BY name" --format json +``` + +`json_valid(content)` keeps one empty or malformed file from failing the whole query. Nested keys are JSON paths, still quoted: `content ->> '$.runtime.engine'`. Booleans arrive as `1` and `0`: + +```sh +uvx dirsql "SELECT path FROM './**/metadata.json' WHERE json_valid(content) AND content ->> 'disabled' = 0" --format json +``` + +Count across an array field with `json_each`, guarded the same way: + +```sh +uvx dirsql "SELECT t.value AS tag, COUNT(*) AS n FROM './**/metadata.json' AS m, json_each(m.content, '$.tags') AS t WHERE json_valid(m.content) GROUP BY tag ORDER BY n DESC, tag" --format json +``` + +### Frontmatter + +Files whose frontmatter contains a given line (`%` matches across newlines): + +```sh +uvx dirsql "SELECT path FROM './**/*.md' WHERE content LIKE '---%status: draft%'" --format json +``` + +One frontmatter field from every file: + +```sh +uvx dirsql "WITH fm AS (SELECT path, substr(content, instr(content, 'title:') + 6) AS rest FROM './**/*.md' WHERE content LIKE '---%title:%') SELECT path, trim(substr(rest, 1, instr(rest, char(10)) - 1)) AS title FROM fm ORDER BY path" --format json +``` + +### Occurrences of a string per file + +grep stops at matching lines; SQL can count them: + +```sh +uvx dirsql "SELECT path, (length(content) - length(replace(content, 'TODO', ''))) / length('TODO') AS todos FROM './**/*.md' WHERE todos > 0 ORDER BY todos DESC" --format json +``` + +### Join two directories + +Source modules with no matching doc page: + +```sh +uvx dirsql "SELECT s.path FROM './src/*.py' AS s LEFT JOIN './docs/**/*.md' AS d ON replace(d.basename, '.md', '') = replace(s.basename, '.py', '') WHERE d.path IS NULL ORDER BY s.path" --format json +``` + +### Top-k by meaning + +Needs the embeddings plugin. The first run downloads a model of roughly a hundred megabytes, with progress on stderr: + +```sh +uvx --with dirsql-plugin-embeddings dirsql " + SELECT path, + vec_distance_cosine(emb, embed('how do I cook pasta?')) AS distance + FROM (SELECT path, embed(content) AS emb FROM './notes/*.md') + WHERE emb IS NOT NULL + ORDER BY distance + LIMIT 3" --format json +``` + +`WHERE emb IS NOT NULL` matters: an unreadable file embeds to `NULL`, and SQLite sorts `NULL` first. For ranked paths only, `uvx dirsql-plugin-embeddings './notes/*.md' "how do I cook pasta?" -k 3` prints `pathdistance` lines. diff --git a/docs/context/troubleshooting.md b/docs/context/troubleshooting.md new file mode 100644 index 000000000..b8e3b1be7 --- /dev/null +++ b/docs/context/troubleshooting.md @@ -0,0 +1,64 @@ +## Troubleshooting + +Diagnostics go to stderr and the exit code is `1`. + +### `no such table: **/*.md; did you mean './**/*.md'?` + +A bare glob. Paths relative to the directory you run in start with `./`: + +```sh +uvx dirsql "SELECT path FROM './**/*.md'" --format json +``` + +### `near ".": syntax error` and `hint: paths used as table names must be quoted` + +`FROM ./` reaches SQLite's parser as punctuation. Quote the path: + +```sh +uvx dirsql "SELECT COUNT(*) AS files FROM './'" --format json +``` + +### `no such column: disabled` + +`content ->> disabled` names a column. The key is a string literal: + +```sh +uvx dirsql "SELECT path, content ->> 'disabled' AS disabled FROM './**/metadata.json' WHERE json_valid(content)" --format json +``` + +### `SQLite error: malformed JSON` + +One matched file is not valid JSON (an empty file counts), and that fails the whole query. Guard with `json_valid`: + +```sh +uvx dirsql "SELECT path, content ->> 'name' AS name FROM './**/*.json' WHERE json_valid(content)" --format json +``` + +### `no such table: files; did you mean FROM './**'?` + +There is no implicit table. Query the tree directly: + +```sh +uvx dirsql "SELECT path FROM './**'" --format json +``` + +### Empty `[]` for files you can see + +- **You named a directory**, which lists one level only. `'./**'` and `'./docs/**'` are the recursive forms. +- **The files are gitignored.** `--no-ignore` scans them: + + ```sh + uvx dirsql "SELECT path FROM './**'" --format json --no-ignore + ``` + + Naming the directory outright also works: `'./build'` scans `build/` even when it is ignored. +- **The files are under `node_modules`**, which is skipped unless the path names it: `'./node_modules/**'`. +- **The files start with a dot, or sit under a directory that does.** Spell the dot: `'./.git/**'`, `'./**/.env'`. + +### `table ./ may not be modified` + +dirsql is read-only. Mutations belong in a script that consumes the query's JSON. + +### A table appeared, not JSON + +`--format json` was not passed and stdout was a terminal. Pass it. diff --git a/docs/context/usage.md b/docs/context/usage.md new file mode 100644 index 000000000..9d34b595f --- /dev/null +++ b/docs/context/usage.md @@ -0,0 +1,54 @@ +## Usage + +dirsql answers a question about a directory tree with SQL. Nothing to install; run it from the directory in question: + +```sh +uvx dirsql "SELECT basename, size FROM './**' ORDER BY size DESC LIMIT 5" --format json +``` + +Always pass `--format json`. Without it the output depends on stdout: a terminal gets a table, a pipe gets JSON, and you cannot know which you have. With it, stdout is one JSON array of row objects. Errors go to stderr with exit code `1`. + +### Path-tables + +A quoted path stands in for a table name. Paths are relative to the directory you run in. + +| You write | dirsql scans | +| --- | --- | +| `'./'` | files directly here, no deeper | +| `'./docs'` | files directly inside `docs/`, no deeper | +| `'./*.md'` | markdown directly here | +| `'./**'` | every file, any depth | +| `'./docs/**'` | every file under `docs/`, any depth | +| `'./**/*.md'` | markdown at any depth | +| `'./notes/today.md'` | that one file | + +`*` matches within one directory level and `**` matches any depth. A directory name lists one level, like `ls docs`, so write `'./**'` to scan the whole tree. Rows are files only, never directories. `'/var/log/*.log'`, `'../notes'` and `'~/notes/*.md'` also resolve; those report absolute `path` values. + +Two mistakes fail every time: + +1. **The path must be quoted.** `FROM ./` is a syntax error; write `FROM './'`. +2. **`content ->> 'key'` takes a string literal.** `content ->> key` fails with `no such column: key`. + +### Columns + +Every row has `path`, `basename`, `dir`, `ext` (no dot, `NULL` when absent), `size` (bytes), `mtime` and `ctime` (Unix seconds). A hidden `content` column holds the file's text: it is left out of `SELECT *`, read only when you name it, and `NULL` for unreadable or non-UTF-8 files. + +### What a scan skips + +- **`.gitignore` is respected.** Pass `--no-ignore` to scan gitignored files too, or name the ignored directory outright: `'./build'` scans `build/`. +- **`node_modules` is skipped** unless the path names it: `'./node_modules/**'`. +- **`'./**'` does not enter `.git`.** Naming it scans it: `'./.git/**'`. +- **To match dotfiles, spell the dot:** `'./.github/**'`, `'./**/.env'`. + +### Escalation + +1. A path-table, as above. Fits a question you ask once. +2. The same tree queried repeatedly, or a parser you want to keep: declare a table in a `.dirsql.toml` and pass it with `-c .dirsql.toml`. See [config](https://dirsql.dev/reference/config). +3. One row per record inside each file, inline: `--on-file ` attaches a parser to every path-table in the query, and its output replaces the stat columns. See [hooks](https://dirsql.dev/reference/hooks). +4. Search by meaning: `uvx --with dirsql-plugin-embeddings dirsql "" --format json` adds `embed()` and `vec_distance_cosine()`. See [search by meaning](https://dirsql.dev/howto/search-by-meaning). + +### Read-only + +dirsql never writes to, moves or changes the files it scans, and it rejects mutating SQL. Do not pass `--persist`: it writes a cache database into the directory. + +Full docs: [dirsql.dev](https://dirsql.dev/) ([path-tables](https://dirsql.dev/reference/path-tables), [columns](https://dirsql.dev/reference/columns), [CLI](https://dirsql.dev/reference/cli), [query JSON](https://dirsql.dev/howto/query-json)). diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a2a85e694..29439a0db 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -11,6 +11,7 @@ The `dirsql` binary has these modes: | `dirsql query ""` | Explicit synonym for the default one-shot query. | | `dirsql server` | Start a long-lived HTTP server exposing a SQL view of a directory. See [HTTP API](./http-api.md). | | `dirsql init` | Generate a `.dirsql.toml`. | +| `dirsql context` | Print the usage guide for this version, for an agent to read. | | `dirsql` (bare) | Open a [REPL](#the-repl) over the current directory, reading statements until EOF. | Bare `dirsql` does **not** start the server — that is `dirsql server`. @@ -439,6 +440,17 @@ All failures exit `1` with a message on stderr: On success, `init` exits `0`. +## `dirsql context` + +```sh +uvx dirsql context +``` + +Prints a markdown guide for an agent that is about to query files: the +first line is `dirsql `, then sections on usage, recipes and +troubleshooting. The guide is compiled into the binary, so it describes the +version that prints it. It takes no flags and exits `0`. + ## Plugins A **plugin** is an ordinary Python package that ships a `dirsql.toml` config diff --git a/packages/python/changelog.d/2026-10-05-cli-context.md b/packages/python/changelog.d/2026-10-05-cli-context.md new file mode 100644 index 000000000..bd6d70e9e --- /dev/null +++ b/packages/python/changelog.d/2026-10-05-cli-context.md @@ -0,0 +1,3 @@ +**Added** + +- **`dirsql context` prints a usage guide for agents.** The first line is `dirsql `, then markdown sections on usage, recipes, and fixes for common errors, matched to the installed version. The launcher passes `context` through without adding plugin or extension flags, as it does for `init`. (#1264) diff --git a/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins.py b/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins.py index e31ef7ed5..67cdcbf51 100644 --- a/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins.py +++ b/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins.py @@ -22,8 +22,8 @@ def with_discovered_plugins(argv: list[str]) -> list[str]: """Return ``argv`` with each installed plugin's fragment appended as ``-c`` (plus ``--include-default`` when the user passed no ``-c``). ``--no-plugin`` - / ``DIRSQL_NO_PLUGIN`` skip discovery, consuming the flag. ``init`` takes no - config, so it is left untouched. Raises if a declared plugin is missing its + / ``DIRSQL_NO_PLUGIN`` skip discovery, consuming the flag. ``init`` and ``context`` take no + config, so they are left untouched. Raises if a declared plugin is missing its module or fragment (the launcher surfaces a clean error). Appending is safe because config flags are subcommand-local (#609): the @@ -34,7 +34,7 @@ def with_discovered_plugins(argv: list[str]) -> list[str]: """ if discovery_disabled(argv): return [a for a in argv if a != NO_PLUGIN_FLAG] - if argv and argv[0] == "init": + if argv and argv[0] in ("init", "context"): return argv fragments = discovered_fragments() if not fragments: diff --git a/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins_test.py b/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins_test.py index 64a9b89d2..e0fd8c048 100644 --- a/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins_test.py +++ b/packages/python/dirsql/cli/discover_plugins/with_discovered_plugins_test.py @@ -32,6 +32,15 @@ def it_leaves_init_untouched(): ): assert with_discovered_plugins([init, "--force"]) == [init, "--force"] + def it_leaves_context_untouched(): + context = "".join(["con", "text"]) + with ( + patch.object(module, "discovery_disabled", return_value=False), + patch.object(module, "discovered_fragments", side_effect=AssertionError), + patch.object(module, "user_passed_config", side_effect=AssertionError), + ): + assert with_discovered_plugins([context]) == [context] + def it_is_a_no_op_when_no_plugins_are_installed(): with ( patch.object(module, "discovery_disabled", return_value=False), diff --git a/packages/python/dirsql/cli/resolve_config_extensions.py b/packages/python/dirsql/cli/resolve_config_extensions.py index 58826b220..c9b2e0040 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions.py +++ b/packages/python/dirsql/cli/resolve_config_extensions.py @@ -27,7 +27,7 @@ def with_resolved_extensions(argv: list[str]) -> list[str]: """Return ``argv`` plus ``--extension`` flags when a TOML config names an extension by package name; otherwise return ``argv`` unchanged. Raises if a package name cannot be resolved (the launcher surfaces a clean error).""" - if argv and argv[0] == "init": + if argv and argv[0] in ("init", "context"): return argv config_paths = [ p for p in config_paths_from_argv(argv) if not p.endswith(_NATIVE_SUFFIXES) diff --git a/packages/python/dirsql/cli/resolve_config_extensions_test.py b/packages/python/dirsql/cli/resolve_config_extensions_test.py index fd6581a6d..90f7f31e3 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions_test.py +++ b/packages/python/dirsql/cli/resolve_config_extensions_test.py @@ -42,6 +42,13 @@ def it_passes_init_through_untouched(): scan.assert_not_called() resolver.assert_not_called() + def it_passes_context_through_untouched(): + argv = ["".join(["con", "text"])] + with _patch([{"path": "R:pkg", "entrypoint": None}]) as (scan, resolver): + assert rce.with_resolved_extensions(argv) is argv + scan.assert_not_called() + resolver.assert_not_called() + def it_matches_init_by_value_not_identity_or_ordering(): # Only the exact first argument "init" skips resolution: a # runtime-built "init" still skips; other subcommands (sorting above diff --git a/packages/python/e2e-attestations/claude-1264-context.json b/packages/python/e2e-attestations/claude-1264-context.json new file mode 100644 index 000000000..cd0215f7f --- /dev/null +++ b/packages/python/e2e-attestations/claude-1264-context.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1791216630, + "exit_code": 0, + "commit": "886e5785c2a90371f02bc2683417b9c73892b5c5", + "branch": "claude/1264-context" +} diff --git a/packages/python/migrations.d/2026-10-05-cli-context.md b/packages/python/migrations.d/2026-10-05-cli-context.md new file mode 100644 index 000000000..4ecff695a --- /dev/null +++ b/packages/python/migrations.d/2026-10-05-cli-context.md @@ -0,0 +1,26 @@ +### `dirsql context` is a subcommand (#1264) + +#### Summary + +`dirsql context` now prints the agent usage guide and exits 0. Before, the +word `context` as the first argument was run as SQL. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- `dirsql context` used to be read as the SQL statement `context` and fail + with a syntax error, exit 1. It now prints the guide. + +#### Verification + +```bash +dirsql context | head -n 1 +# -> dirsql +``` diff --git a/packages/rust/changelog.d/2026-10-05-cli-context.md b/packages/rust/changelog.d/2026-10-05-cli-context.md new file mode 100644 index 000000000..4ca9f838d --- /dev/null +++ b/packages/rust/changelog.d/2026-10-05-cli-context.md @@ -0,0 +1,3 @@ +**Added** + +- **`dirsql context` prints a usage guide for agents.** The first line is `dirsql `, then markdown sections on usage (path-table syntax, columns, what a scan skips), recipes, and fixes for common errors. The guide is compiled into the binary, so it always describes the version that prints it. It takes no flags and exits 0. (#1264) diff --git a/packages/rust/migrations.d/2026-10-05-cli-context.md b/packages/rust/migrations.d/2026-10-05-cli-context.md new file mode 100644 index 000000000..4ecff695a --- /dev/null +++ b/packages/rust/migrations.d/2026-10-05-cli-context.md @@ -0,0 +1,26 @@ +### `dirsql context` is a subcommand (#1264) + +#### Summary + +`dirsql context` now prints the agent usage guide and exits 0. Before, the +word `context` as the first argument was run as SQL. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- `dirsql context` used to be read as the SQL statement `context` and fail + with a syntax error, exit 1. It now prints the guide. + +#### Verification + +```bash +dirsql context | head -n 1 +# -> dirsql +``` diff --git a/packages/rust/src/cli/context.rs b/packages/rust/src/cli/context.rs new file mode 100644 index 000000000..4a765e1e1 --- /dev/null +++ b/packages/rust/src/cli/context.rs @@ -0,0 +1,47 @@ +//! `dirsql context`: the agent usage guide, compiled in so it always +//! describes the binary that prints it. + +// `docs/` in the crate root is a symlink to the workspace docs, and it ships in +// the published crate, so these paths resolve both in the repo and from crates.io. +const USAGE: &str = include_str!("../../docs/context/usage.md"); +const RECIPES: &str = include_str!("../../docs/context/recipes.md"); +const TROUBLESHOOTING: &str = include_str!("../../docs/context/troubleshooting.md"); + +/// The guide as printed: a `dirsql ` line, then each section. +pub fn guide() -> String { + format!( + "dirsql {}\n\n{USAGE}\n{RECIPES}\n{TROUBLESHOOTING}", + env!("CARGO_PKG_VERSION") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_line_names_the_crate_version() { + let guide = guide(); + assert_eq!( + guide.lines().next(), + Some(concat!("dirsql ", env!("CARGO_PKG_VERSION"))) + ); + } + + #[test] + fn the_sections_follow_the_version_line_in_order() { + let guide = guide(); + let usage = guide.find("\n## Usage\n").unwrap(); + let recipes = guide.find("\n## Recipes\n").unwrap(); + let troubleshooting = guide.find("\n## Troubleshooting\n").unwrap(); + assert!(usage < recipes && recipes < troubleshooting); + } + + #[test] + fn every_section_is_printed_whole() { + let guide = guide(); + for section in [USAGE, RECIPES, TROUBLESHOOTING] { + assert!(guide.contains(section)); + } + } +} diff --git a/packages/rust/src/cli/mod.rs b/packages/rust/src/cli/mod.rs index 1ba36f585..32f8dd133 100644 --- a/packages/rust/src/cli/mod.rs +++ b/packages/rust/src/cli/mod.rs @@ -16,6 +16,7 @@ //! //! - [`server`] — bind/serve/shutdown plumbing. //! - [`router`] — axum routes + request handlers (thin HTTP adapters). +//! - [`context`] — the agent usage guide behind `dirsql context`. //! - [`execute`] — the transport-agnostic query pipeline shared by the //! HTTP handler and the one-shot `dirsql query` subcommand. //! - [`repl`] — the read-eval-print loop behind bare `dirsql`. @@ -31,6 +32,7 @@ use tokio::task::JoinHandle; use crate::DirSQL; +pub mod context; pub mod execute; pub mod init; pub mod repl; diff --git a/packages/rust/src/cli/run.rs b/packages/rust/src/cli/run.rs index e7a70faa8..b9db5f050 100644 --- a/packages/rust/src/cli/run.rs +++ b/packages/rust/src/cli/run.rs @@ -6,6 +6,7 @@ //! `docs/reference/cli.md`. //! - `server`: the HTTP server documented in `docs/reference/cli.md`. //! - `init`: writes a fixed starter `.dirsql.toml`; see `docs/reference/cli.md`. +//! - `context`: prints the agent usage guide; see [`context`](super::context). //! - No subcommand and no SQL (bare `dirsql`): an interactive REPL over the //! current directory; see [`repl`](super::repl). //! @@ -174,6 +175,10 @@ enum Command { /// over `POST /query` and `GET /events`. Config flags follow the /// subcommand: `dirsql server -c `. Runs until `SIGINT` / `SIGTERM`. Server(ServerArgs), + + /// Print the agent usage guide for this dirsql version as markdown: + /// path-table syntax, verified recipes, and fixes for common errors. + Context, } #[derive(Debug, Args)] @@ -333,6 +338,10 @@ pub fn run_cli(argv: Vec) -> i32 { Some(Command::Init(args)) => run_init(args), Some(Command::Query(args)) => run_query(args).await, Some(Command::Server(args)) => run_server(args).await, + Some(Command::Context) => { + print!("{}", super::context::guide()); + 0 + } None => run_default(cli).await, } }); @@ -1080,6 +1089,23 @@ mod tests { assert_eq!(run_cli(vec!["dirsql".into(), "--help".into()]), 0); } + #[test] + fn context_parses_as_its_own_subcommand_not_as_sql() { + let cli = Cli::try_parse_from(["dirsql", "context"]).unwrap(); + assert!(matches!(cli.command, Some(Command::Context))); + assert!(cli.sql.is_none()); + } + + #[test] + fn context_takes_no_flags() { + assert!(Cli::try_parse_from(["dirsql", "context", "--format", "json"]).is_err()); + } + + #[test] + fn run_cli_returns_zero_for_context() { + assert_eq!(run_cli(vec!["dirsql".into(), "context".into()]), 0); + } + #[test] fn run_cli_returns_clap_usage_code_for_an_unknown_flag() { assert_eq!(run_cli(vec!["dirsql".into(), "--nope".into()]), 2); diff --git a/packages/rust/tests/context_examples_e2e.rs b/packages/rust/tests/context_examples_e2e.rs new file mode 100644 index 000000000..83072e219 --- /dev/null +++ b/packages/rust/tests/context_examples_e2e.rs @@ -0,0 +1,173 @@ +//! Drift guard for `dirsql context`: every fenced `uvx dirsql` example the +//! guide prints runs against the real binary over a real fixture tree and +//! must exit 0 with a JSON array on stdout. + +#![cfg(feature = "cli")] + +use std::fs; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::TempDir; + +const RUNNABLE: &str = "uvx dirsql "; + +// The embeddings plugin downloads a model on first use, so its example cannot +// run here; it is the only fenced command allowed to skip the guard. +const PLUGIN_ONLY: &str = "uvx --with dirsql-plugin-embeddings dirsql "; + +fn guide() -> String { + let out = std::process::Command::cargo_bin("dirsql") + .unwrap() + .arg("context") + .output() + .unwrap(); + assert!( + out.status.success(), + "dirsql context failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() +} + +fn fenced_blocks(text: &str) -> Vec { + let mut blocks = Vec::new(); + let mut body: Option = None; + for line in text.lines() { + let fence = line.trim_start().starts_with("```"); + match body.take() { + Some(b) if fence => blocks.push(b), + Some(mut b) => { + b.push_str(line.trim_start()); + b.push('\n'); + body = Some(b); + } + None if fence => body = Some(String::new()), + None => {} + } + } + assert!(body.is_none(), "unterminated fence in the guide"); + blocks +} + +/// Whitespace separates words; single or double quotes group them. +fn shell_words(cmd: &str) -> Vec { + let mut words = Vec::new(); + let mut cur = String::new(); + let mut in_word = false; + let mut quote: Option = None; + for c in cmd.chars() { + match quote { + Some(q) if c == q => quote = None, + Some(_) => cur.push(c), + None if c == '\'' || c == '"' => { + quote = Some(c); + in_word = true; + } + None if c.is_whitespace() => { + if in_word { + words.push(std::mem::take(&mut cur)); + in_word = false; + } + } + None => { + cur.push(c); + in_word = true; + } + } + } + assert!(quote.is_none(), "unbalanced quote in: {cmd}"); + if in_word { + words.push(cur); + } + words +} + +fn write(root: &Path, rel: &str, body: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); +} + +fn fixture() -> TempDir { + let root = TempDir::new().unwrap(); + let r = root.path(); + write(r, "README.md", "# Fixture\n\nTODO: describe\n"); + write( + r, + "docs/intro.md", + "---\ntitle: Introduction\nstatus: draft\n---\n\n# Intro\n\nTODO: write\nTODO: review\n", + ); + write( + r, + "docs/guide/setup.md", + "---\ntitle: Setup\nstatus: published\n---\n\n# Setup\n", + ); + write(r, "docs/app.md", "# app\n"); + write(r, "src/app.py", "print('app')\n"); + write(r, "src/db.py", "print('db')\n"); + write( + r, + "plugins/alpha/metadata.json", + r#"{"name":"alpha","version":"1.4.0","disabled":false,"tags":["search","index"]}"#, + ); + write( + r, + "plugins/beta/metadata.json", + r#"{"name":"beta","version":"0.9.2","disabled":true,"tags":["export"]}"#, + ); + write(r, "plugins/empty/metadata.json", ""); + write(r, "data/big.bin", &"x".repeat(4096)); + write(r, ".gitignore", "build/\n"); + write(r, "build/out.bin", "built"); + write(r, "node_modules/dep/index.js", "module.exports = 1;\n"); + fs::File::options() + .write(true) + .open(r.join("docs/app.md")) + .unwrap() + .set_modified(SystemTime::now() - Duration::from_secs(400 * 86_400)) + .unwrap(); + root +} + +#[test] +fn every_fenced_example_in_the_guide_runs_clean() { + let dir = fixture(); + let mut ran = 0; + for block in fenced_blocks(&guide()) { + let cmd = block.trim(); + if cmd.starts_with(PLUGIN_ONLY) { + continue; + } + let args = cmd.strip_prefix(RUNNABLE).unwrap_or_else(|| { + panic!("a fenced example must start with `{RUNNABLE}` or `{PLUGIN_ONLY}`: {cmd}") + }); + let args = shell_words(args); + assert!( + args.windows(2).any(|w| w == ["--format", "json"]), + "example must pass `--format json`: {cmd}" + ); + let out = std::process::Command::cargo_bin("dirsql") + .unwrap() + .args(&args) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!( + out.status.success(), + "example failed ({}):\n{cmd}\nstderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let rows: Value = serde_json::from_slice(&out.stdout) + .unwrap_or_else(|e| panic!("stdout is not JSON ({e}):\n{cmd}")); + assert!(rows.is_array(), "expected a JSON array:\n{cmd}"); + ran += 1; + } + assert!( + ran >= 10, + "expected at least 10 runnable examples, ran {ran}" + ); +} diff --git a/packages/rust/tests/context_integration.rs b/packages/rust/tests/context_integration.rs new file mode 100644 index 000000000..ac8a88fd5 --- /dev/null +++ b/packages/rust/tests/context_integration.rs @@ -0,0 +1,75 @@ +//! Integration tests for `dirsql context`, the agent usage guide compiled +//! into the binary. Spawns the real `dirsql` binary. + +#![cfg(feature = "cli")] + +use assert_cmd::prelude::*; + +fn run_context() -> std::process::Output { + std::process::Command::cargo_bin("dirsql") + .expect("`dirsql` binary must be built with --features cli") + .arg("context") + .output() + .expect("spawning dirsql failed") +} + +#[test] +fn context_exits_zero_with_nothing_on_stderr() { + let out = run_context(); + assert!( + out.status.success(), + "dirsql context failed: status={:?} stderr={}", + out.status, + String::from_utf8_lossy(&out.stderr), + ); + assert!( + out.stderr.is_empty(), + "unexpected stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn context_starts_with_the_running_version() { + let out = run_context(); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + stdout.lines().next(), + Some(format!("dirsql {}", env!("CARGO_PKG_VERSION")).as_str()), + "first line must name the running version; stdout:\n{stdout}" + ); +} + +#[test] +fn context_prints_usage_recipes_and_troubleshooting_in_order() { + let out = run_context(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let position = |heading: &str| { + stdout + .lines() + .position(|line| line == heading) + .unwrap_or_else(|| panic!("missing `{heading}` heading; stdout:\n{stdout}")) + }; + let usage = position("## Usage"); + let recipes = position("## Recipes"); + let troubleshooting = position("## Troubleshooting"); + assert!( + usage < recipes && recipes < troubleshooting, + "sections out of order: usage={usage} recipes={recipes} troubleshooting={troubleshooting}" + ); +} + +#[test] +fn context_takes_no_arguments() { + let out = std::process::Command::cargo_bin("dirsql") + .unwrap() + .args(["context", "--format", "json"]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "a flag after `context` must be a usage error; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/packages/ts/changelog.d/2026-10-05-cli-context.md b/packages/ts/changelog.d/2026-10-05-cli-context.md new file mode 100644 index 000000000..de8827fc3 --- /dev/null +++ b/packages/ts/changelog.d/2026-10-05-cli-context.md @@ -0,0 +1,3 @@ +**Added** + +- **`dirsql context` prints a usage guide for agents.** The first line is `dirsql `, then markdown sections on usage, recipes, and fixes for common errors, matched to the installed version. The launcher passes `context` through without resolving config extensions, as it does for `init`. (#1264) diff --git a/packages/ts/e2e-attestations/claude-1264-context.json b/packages/ts/e2e-attestations/claude-1264-context.json new file mode 100644 index 000000000..1fc749abb --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1264-context.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791207981, + "exit_code": 0, + "commit": "e3136ddfb6e8a4273ab52e06020658d1047b88ec", + "branch": "claude/1264-context" +} diff --git a/packages/ts/migrations.d/2026-10-05-cli-context.md b/packages/ts/migrations.d/2026-10-05-cli-context.md new file mode 100644 index 000000000..4ecff695a --- /dev/null +++ b/packages/ts/migrations.d/2026-10-05-cli-context.md @@ -0,0 +1,26 @@ +### `dirsql context` is a subcommand (#1264) + +#### Summary + +`dirsql context` now prints the agent usage guide and exits 0. Before, the +word `context` as the first argument was run as SQL. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- `dirsql context` used to be read as the SQL statement `context` and fail + with a syntax error, exit 1. It now prints the guide. + +#### Verification + +```bash +dirsql context | head -n 1 +# -> dirsql +``` diff --git a/packages/ts/src/cli/resolve-config-extensions.test.ts b/packages/ts/src/cli/resolve-config-extensions.test.ts index 99407a3d7..b4df2ca3e 100644 --- a/packages/ts/src/cli/resolve-config-extensions.test.ts +++ b/packages/ts/src/cli/resolve-config-extensions.test.ts @@ -27,6 +27,13 @@ describe("withResolvedExtensions", () => { expect(resolveConfigsExtensionSpecs).not.toHaveBeenCalled(); }); + it("passes `context` through untouched without scanning or resolving", async () => { + const argv = ["context"]; + expect(await withResolvedExtensions(argv)).toBe(argv); + expect(configPathsFromArgv).not.toHaveBeenCalled(); + expect(resolveConfigsExtensionSpecs).not.toHaveBeenCalled(); + }); + it("scans the whole argv for config paths", async () => { vi.mocked(resolveConfigsExtensionSpecs).mockReturnValue(null); const argv = ["query", "SELECT 1", "-c", "/frag/dirsql.toml"]; diff --git a/packages/ts/src/cli/resolve-config-extensions.ts b/packages/ts/src/cli/resolve-config-extensions.ts index 01b22684f..efe5f2357 100644 --- a/packages/ts/src/cli/resolve-config-extensions.ts +++ b/packages/ts/src/cli/resolve-config-extensions.ts @@ -27,7 +27,7 @@ const NATIVE_CONFIG_SUFFIXES = [".py", ".js", ".mjs", ".cjs"]; export async function withResolvedExtensions( argv: string[], ): Promise { - if (argv[0] === "init") { + if (argv[0] === "init" || argv[0] === "context") { return argv; } const configPaths = getCore()