Skip to content

CLI

The atif-sql console script (packages/atif-cli/pyproject.toml:42) is the repo’s public contract: one cyclopts router (packages/atif-cli/src/atif_cli/app.py:52) dispatches the ten subcommands below, and a root-level --version resolves the installed atif-sql distribution (:60).

Three conventions hold across every subcommand. --format takes auto, table, json, or csv from the OutputFormat StrEnum at packages/atif-cli/src/atif_cli/output.py:58, and auto resolves to table when stdout is a TTY and json otherwise (packages/atif-cli/src/atif_cli/output.py:74). Exit codes come from one table, EXIT_CODES at packages/atif-cli/src/atif_cli/errors.py:28: 0 ok, 2 empty-session / no-embeddings, 64 invalid input or SQL parse error, 65 catalog error / validation error / embedding mismatch, 70 runtime error, 78 terminal state or suspicious scan, 127 harbor missing. A number in that table is a wire contract: keys may be added, never renumbered. And stderr carries WARNING and above only, so a piped read emits data on stdout and nothing on stderr unless something is wrong; ATIF_SQL_LOG_LEVEL widens it (INFO, DEBUG) and is the only way to reach the INFO surface, because the entry point replaces loguru’s default handler and with it the LOGURU_LEVEL that would have parameterized it (packages/atif-cli/src/atif_cli/app.py:1141-1148).

analyze, embed, and search call Amazon Bedrock and spend money; the other seven are offline.

Four subcommands take --agent, which selects the transcript format and, with it, the default source and corpus roots: convert, materialize, status, and query. The accepted spellings are claude-code (the default) and codex, and an unknown one exits 64 naming both (packages/atif-cli/src/atif_cli/app.py:257). codex moves the source root to $CODEX_HOME (default ~/.codex) /sessions and the corpus root to ~/.atif-sql/corpus/codex; an explicit flag or ATIF_SQL_* env var still wins (packages/atif-corpus/src/atif_corpus/infrastructure/settings.py).

atif-sql convert [OPTIONS] SESSION-JSONL

Convert one Claude Code session JSONL or one Codex CLI rollout JSONL to ATIF plus a loss report and edges. packages/atif-cli/src/atif_cli/app.py:282

Flags:

  • SESSION-JSONL / --session-jsonl — required path to the transcript: ~/.claude/projects/<proj>/<session>.jsonl, or ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<uuid>.jsonl under --agent codex. :283
  • --agentclaude-code or codex; picks the converter and the fidelity policy. Defaults from ATIF_SQL_AGENT, then to claude-code. :284
  • --include-subagents / --no-subagents — stage <session>/subagents/**.jsonl side-files alongside the main chain; default True, with the negative form named explicitly. :228
  • --trajectory-out — write the trajectory JSON here and edges.jsonl beside it, instead of stdout. :229

Exit codes: 0 ok, 2 empty session, 64 invalid input, 65 validation, 70 conversion. :250

atif-sql materialize [OPTIONS]

Sync the materialized corpus with the raw transcript corpus in one scan-plan-convert-write pass. packages/atif-cli/src/atif_cli/app.py:448

A transcript whose session id fails the boundary in packages/atif-corpus/src/atif_corpus/domain/session_id.py (^[A-Za-z0-9][A-Za-z0-9._-]*$, at most 255 characters) is skipped with a logged reason and counted: the report carries rejected and rejected_session_ids beside unreadable and unreadable_session_ids, and the table form prints one REJECTED line per name on stderr. Nothing from such a session is written, and a corpus directory an older version wrote under that name is kept rather than removed as a ghost.

The convert-write stage runs across a process pool by default. Each worker builds its own converter once and writes through the same per-session staging directory and atomic swap the single-process path uses, so the artifacts are byte-identical either way and a crash still costs at most the session in flight. --workers 1 is the single-process reference path. The report’s convert_seconds is the per-session sum, so with several workers it can exceed total_seconds, which stays the wall clock; workers in the report is the pool size the pass actually used, which is never more than the number of sessions planned (packages/atif-corpus/src/atif_corpus/application/materialize.py:385).

Flags:

  • --agentclaude-code (default) or codex; picks the discovery layout and the default roots. :426
  • --force / --no-force — re-materialize every quiescent session regardless of the watermark; default False. :341
  • --quiesce-seconds — source-silence threshold; defaults from settings (contract: 300). :342
  • --source-root — override the raw transcript root, otherwise ATIF_SQL_SOURCE_ROOT or <CLAUDE_CONFIG_DIR>/projects. :343
  • --corpus-root — override the materialized corpus root, otherwise env or ~/.atif-sql/corpus/<slug>. :344
  • --sessions — comma-separated session-id filter; only these sessions are planned this pass. :345
  • --workers — processes for the convert-write stage; default ATIF_SQL_MATERIALIZE_WORKERS, else min(8, cpu_count). 1 is the single-process path. :455
  • --format — report format. :346

Exit codes: 0 ok, 64 --workers below 1; 78 the corpus at this root holds the other agent’s sessions, refused with nothing removed; 78 suspicious scan — the source scan found zero sessions while the corpus holds materialized ones, so ghost removal was refused and nothing was deleted. Check --source-root; a retry over the same root cannot succeed. packages/atif-cli/src/atif_cli/app.py:429-443

  • --columnar / --no-columnar — write the typed columnar artifacts (session.parquet, steps.parquet, tool_calls.parquet, tool_results.parquet) beside the four JSON artifacts, staged and swapped with them; default True. --no-columnar writes exactly the contract’s JSON artifacts and query reads those sessions from trajectory.json. packages/atif-cli/src/atif_cli/app.py:452
  • --format — report format. :346

The report carries convert_seconds and artifact_seconds (the time spent writing the columnar files; 0.0 under --no-columnar), printed as columnar: N.NNs in the table form.

What the artifacts cost, measured on a 300-session, 1.6 GB Claude Code corpus (frozen snapshot, one machine, /usr/bin/time): a full --force pass took 91 s at a 797 MB peak with them and 55 s at a 670 MB peak without, and they add 455 MB on disk. Most of the extra memory is the producer’s DuckDB and pyarrow imports (about 90 MB) plus a bounded working set; most of the extra time is the typed conversion of tool results. Every query after that reads typed columns: the three panel statements dropped from 2.5 to 8 s and 4.5 to 8.6 GB peak to about 0.9 s and 490 MB each. --no-columnar is the right call for a corpus that’s written far more often than it’s queried.

Exit codes: 0 ok, 78 the corpus at this root holds the other agent’s sessions, refused with nothing removed; 78 suspicious scan — the source scan found zero sessions while the corpus holds materialized ones, so ghost removal was refused and nothing was deleted. Check --source-root; a retry over the same root cannot succeed. packages/atif-cli/src/atif_cli/app.py:429-443

atif-sql status [OPTIONS]

Report corpus freshness: watermark age, counts, bytes, staleness. packages/atif-cli/src/atif_cli/app.py:444

Flags:

  • --agentclaude-code (default) or codex; the scan and the reported agent field follow it. :529
  • --source-root — override the raw transcript root. :414
  • --corpus-root — override the materialized corpus root. :415
  • --quiesce-seconds — source-silence threshold used to replay materialize’s planning decision. :416
  • --format — report format. :417

Both roots resolve through _corpus_settings (:433), so a --source-root given without --corpus-root re-derives the corpus root from the overridden source’s slug unless ATIF_SQL_CORPUS_ROOT is set (:206).

The report also says how query will read this corpus. The table form prints query path: columnar|json|mixed|empty (N of M complete sessions carry typed columnar artifacts); the JSON form carries query_path, columnar_sessions, and json_sessions. columnar means every complete session has current parquet artifacts, json means none does (a corpus materialized before the artifacts existed, or with --no-columnar), and mixed means the registry will union the two. status applies the same per-session predicate the registry does (meta.columnar_schema current and all four files present and non-empty), so it can’t report columnar for a session query would read from JSON. packages/atif-cli/src/atif_cli/app.py:639

atif-sql query [OPTIONS] [ARGS]

Run one SQL statement against the atif-duck catalog and emit results. packages/atif-cli/src/atif_cli/app.py:529

Flags:

  • SQL — positional-only statement; omitting it without --examples is a parse error. :498
  • --examples — short-circuit to the examples listing, honoring --category and --requires, without opening DuckDB. :501
  • --category — forwarded to the examples listing. :502
  • --requires — forwarded to the examples listing. :503
  • --agentclaude-code (default) or codex; selects which corpus the statement reads. :628
  • --corpus-root — override the materialized corpus root. :504
  • --formattable on a TTY, a JSON array of row objects on a pipe. :505

The statement runs against a hardened connection: reads reach the registered views and nothing else, and nothing under the corpus root is writable. Before registration the connection is sized to the host: a memory cap derived from available RAM (half of physical RAM or 8 GiB, whichever is larger, never above 80% of what’s available) and a thread count of one per 2 GiB of that cap, capped at the CPUs the process may use. ATIF_SQL_QUERY_MEMORY_LIMIT (a DuckDB size such as 6GB) and ATIF_SQL_QUERY_THREADS override both; a malformed value exits 64. The spill directory is a private mkdtemp (mode 0700) under the system temp dir, the only directory the sandbox grants, and it’s removed when the process exits. Extension auto-install and auto-load are off, and the lance extension is loaded only when it’s already installed, so registration never reaches the network (packages/atif-cli/src/atif_cli/app.py, _configure_query_resources).

Two layers keep caller SQL from writing the corpus. DuckDB’s file grants are read-write and it has no read-only grant, so COPY ... TO <granted parquet> (USE_TMP_FILE false) would overwrite one; the CLI therefore refuses every statement kind that names a file before executing anything, using DuckDB’s own parser: COPY, EXPORT, ATTACH, DETACH, INSTALL, LOAD, PREPARE and EXECUTE exit 70 with kind sandbox_refused, for any uid, and a batch containing one of them runs nothing. And because a 0444 file mode doesn’t bind root, query refuses to run as uid 0 (exit 77, kind root_refused) unless ATIF_SQL_ALLOW_ROOT=1 is set, which logs a warning. search and analyze refuse root the same way.

What caller SQL can still see: duckdb_settings() and current_setting(...) return the sandbox’s own configuration, including the corpus root, the spill directory, the memory cap and every granted parquet path, which names every session id. DuckDB can’t hide a setting from SQL and the grants have to be per file, so this is accepted: the caller is the local user, who can list the corpus and SELECT session_id FROM sessions anyway, and query isn’t a privilege boundary (see SECURITY.md).

The views themselves carry no corpus path as statement text. The registry hands its globs and file lists to read_json(?) as bound parameters and builds the parquet readers through DuckDB’s relation API, so a corpus root such as o'brien ?; --$1 and transcript content carrying SQL text both register as data (packages/atif-duck/src/atif_duck/infrastructure/registry.py). A session directory whose name fails the session id boundary (packages/atif-duck/src/atif_duck/domain/session_id.py) registers nothing and is logged once.

Sessions that carry current columnar artifacts are served from their parquet files, so no JSON is parsed for them at query time; the rest are read from trajectory.json, and the views union the two. The per-session parquet files the registry bound are granted to the sandbox the same way the analytics parquets are (as individual allowed_paths entries, packages/atif-cli/src/atif_cli/app.py:221), and they’re written read-only (0444), so a COPY ... TO at one of them fails at the filesystem even though DuckDB’s grant is read-write. atif-sql status says which path a corpus takes.

Exit codes: 64 parse error or a malformed ATIF_SQL_QUERY_* override, 65 catalog error, 65 embedding mismatch, 70 runtime error, 70 sandbox_refused (a statement kind the sandbox never runs), 77 root_refused. :550

atif-sql analyze [OPTIONS]

Run the analytics pipelines — cluster, terms, community, plus the LLM classify, trajectory, conflicts, friction, and perceived stages. packages/atif-cli/src/atif_cli/app.py:659

Flags:

  • --since-days — restrict LLM stages to sessions whose last step is within N days; default 30, and structural stages always run over the full store. :629
  • --limit — cap the number of sessions, newest-first, per LLM stage. :630
  • --max-sessions — hard per-run session ceiling per LLM pipeline; overrides ATIF_SQL_LLM_MAX_SESSIONS_PER_RUN, default 50. :631
  • --max-cost-usd — hard per-run dollar ceiling across all LLM pipelines, checked against running actual usage; overrides ATIF_SQL_LLM_MAX_COST_USD_PER_RUN, default 25.0. :632
  • --no-dry-run — execute the LLM stages for real, which costs money; the default is a dry run emitting plan dicts and cost estimates. :633
  • --structural-only — run only cluster, terms, and community, the hourly cron lane that fires at minute 17. :634
  • --llm-only — run only classify, trajectory, conflicts, friction, and perceived, the nightly lane. :635
  • --skip-cluster — opt out of the cluster stage. :636
  • --skip-terms — opt out of the terms stage. :637
  • --skip-community — opt out of the community stage. :638
  • --skip-classify — opt out of the classify stage. :639
  • --skip-trajectory — opt out of the trajectory stage. :640
  • --skip-conflicts — opt out of the conflicts stage. :641
  • --skip-friction — opt out of the friction stage. :642
  • --skip-perceived — opt out of the perceived stage. :643
  • --force-cluster — recompute clustering even when the mtime sidecar says the input is unchanged. :644
  • --force-community — recompute community detection even when the mtime sidecar says the input is unchanged. :645
  • --corpus-root — override the materialized corpus root. :646
  • --format — summary format. :647
atif-sql embed [OPTIONS]

Embed unembedded corpus steps with Cohere Embed v4 and append them to LanceDB. packages/atif-cli/src/atif_cli/app.py:766

Flags:

  • --limit — cap the number of steps embedded this run. :736
  • --all — explicitly embed every unembedded step, a full backfill. :737
  • --dry-run — preview only; emit the plan JSON with keys pipeline, candidates, batches, batch_size, concurrency, model, limit, dry_run and make no embedding calls. :738
  • --corpus-root — override the materialized corpus root. :739
  • --format — output format. :740

A real run requires an explicit scope: a bare atif-sql embed exits 64 with a hint rather than starting an unbounded backfill. :778

Exit codes: 0 success, 64 missing --limit or --all, 70 runtime (Bedrock, DuckDB, or Lance failure — transient, safe to retry), 78 terminal state, where the store or its config needs operator action and unattended lanes suppress retries. :767

atif-sql search [OPTIONS] QUERY_TEXT

Semantic top-k nearest-neighbor search over step embeddings. packages/atif-cli/src/atif_cli/app.py:860

Flags:

  • QUERY_TEXT — required positional-only text, embedded with Cohere Embed v4 in search_query mode. :829
  • -k / --k — top-k; default 10. This is the CLI’s only short flag. :832
  • --session-id — confine the kNN to one session. :833
  • --corpus-root — override the materialized corpus root. :834
  • --format — output format. :835

Output columns are uuid, session_id, snippet, and sim (:927), ranked by cosine distance ascending so the highest similarity comes first (:935).

Exit codes: 0 success, 2 no embeddings yet, 65 embedding mismatch when the store was written by another provider, 70 runtime. :864

atif-sql examples [OPTIONS]

List tested example queries for every view and macro, derived from the static atif-duck catalog. packages/atif-cli/src/atif_cli/app.py:995

Flags:

  • --category — filter to one of view, table-macro, scalar-macro, validated against CATEGORY_VALUES. :965
  • --requires — filter to one of core, analytics, vss, validated against REQUIRES_VALUES. :966
  • --format — a TTY table grouped by requires, or a JSON object carrying note and examples. :967

Both value sets are Literal aliases in the producer package: Requires at packages/atif-duck/src/atif_duck/domain/examples.py:48 and Category at :53, exported as tuples at :55 and :56.

Exit codes: 0 ok, 64 unknown --category or --requires value. packages/atif-cli/src/atif_cli/app.py:1020

atif-sql schema [OPTIONS]

List every registered view with its columns and every macro signature. packages/atif-cli/src/atif_cli/app.py:1087

Flags:

  • --format — a TTY listing, or a JSON object carrying views, macros, and examples_hint. :1057

The answer comes from the static VIEW_SCHEMA and MACRO_SIGNATURES dicts with no DuckDB import and no view registration. :1067

atif-sql cron COMMAND

Inspect and manually install the atif-sql refresh cron lanes. packages/atif-cli/src/atif_cli/cron.py:38

The group is attached to the root router by app.command(cron_app) at packages/atif-cli/src/atif_cli/app.py:66, and its three lanes — materialize on */10 * * * *, structural on 17 * * * *, llm on 20 10 * * * — are declared once in LANES at packages/atif-cli/src/atif_cli/cron.py:47.

atif-sql cron install [OPTIONS]

Print the crontab block for the three refresh lanes and never write it. packages/atif-cli/src/atif_cli/cron.py:180

Flags:

Without the flag the script is located by walking up from this module (:147); a tree where scripts/atif-sql-refresh.sh is unreachable exits 64 demanding --script (:175).

atif-sql cron status [OPTIONS]

Report each lane’s lock holder plus the last run and skip parsed from the refresh log. packages/atif-cli/src/atif_cli/cron.py:199

Flags:

  • --script — path to atif-sql-refresh.sh, whose .run/ sibling holds the locks and the log. packages/atif-cli/src/atif_cli/cron.py:201
  • --tail — how many trailing log lines to include; 0 disables, default 10. :202
  • --format — human lines on a TTY, JSON on a pipe. :203

The lock probe acquires and releases nonblocking, so the command perturbs no running lane. :127