Files
gbrain/src/cli.ts
T
1d78013c07 v0.28.5 fix(wave): PGLite upgrade wedge + embedding dim corruption + bun-link foot-gun (#697)
* fix(engines): pre-add v0.20 + v0.26.3 forward-reference columns in bootstrap

The forward-reference bootstrap (PostgresEngine + PGLiteEngine
applyForwardReferenceBootstrap) covered v0.18 + v0.19 + v0.26.5 columns
but missed two later groups. Brains upgrading from v0.14-era to current
master crash before the migration ladder runs:

1. v0.20 Cathedral II — content_chunks.search_vector,
   parent_symbol_path, doc_comment, symbol_name_qualified.
   `CREATE INDEX idx_chunks_search_vector` and
   `CREATE INDEX idx_chunks_symbol_qualified` in schema.sql/PGLITE_SCHEMA_SQL
   crash with "column search_vector does not exist" / "column
   symbol_name_qualified does not exist".

2. v0.26.3 — mcp_request_log.agent_name, params, error_message.
   `CREATE INDEX idx_mcp_log_agent_time ON mcp_request_log(agent_name,...)`
   crashes with "column agent_name does not exist".

Reproduces deterministically on a v0.13/v0.14 brain upgraded straight
to current master. The user hits the wall before any of v15-v36 can run.

Both engines now probe for these columns and pre-add them via
`ALTER TABLE ADD COLUMN IF NOT EXISTS` before SCHEMA_SQL runs. Migrations
v26, v27, v33 still run later via runMigrations and remain idempotent
(they handle backfill on top of the bootstrap-added columns).

Test coverage extended in test/schema-bootstrap-coverage.test.ts:
REQUIRED_BOOTSTRAP_COVERAGE now lists 6 new forward references; the
strip-and-rebuild block drops the corresponding indexes/triggers so the
test exercises a brain that pre-dates v0.20 + v0.26.3 migrations.

Repro: brain on schema v13/v14 + run `gbrain init --migrate-only` against
current master → fails. With this patch → succeeds; ladder runs to v36.

* fix(engines): pre-add v0.27 subagent_messages.provider_id in bootstrap

PR #682 covered v0.20 (chunks) + v0.26.3 (mcp_request_log) but missed
v0.27's subagent_messages.provider_id. The composite index
`idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`
in PGLITE_SCHEMA_SQL crashes on brains pinned at v0.18-v0.26 because
provider_id is the SECOND column in the composite — array-extraction
patterns that scan only first-column references miss it entirely.

This is the wedge surfaced by issue #670 (v0.22.0 → v0.27.0 init
--migrate-only crashes with "column 'provider_id' does not exist") and
contributing to #661/#657.

Both engines now probe for subagent_messages.provider_id and pre-add
the column via ALTER TABLE ADD COLUMN IF NOT EXISTS before SCHEMA_SQL
runs. Migration v36 (subagent_provider_neutral_persistence_v0_27) still
runs later via runMigrations and remains idempotent.

Note on the test side: REQUIRED_BOOTSTRAP_COVERAGE is hand-maintained
and just gained a v0.27 entry. v0.28.5's Step 3 replaces this array
with a SQL parser that auto-derives coverage from PGLITE_SCHEMA_SQL,
including composite-index columns. This commit is the targeted
follow-up to PR #682's cherry-pick; A2's parser closes the class
permanently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): conditional schema-init on connect (closes #651)

Adds `hasPendingMigrations(engine)` next to `runMigrations` in migrate.ts:
single getConfig('version') probe, returns true when current < LATEST_VERSION,
defensively returns true on getConfig failure (treats wedged-config as pending).

`connectEngine` in cli.ts now wraps `engine.initSchema()` in a probe gate:
short-lived CLI calls (gbrain stats, query, doctor, etc.) on already-migrated
brains skip the bootstrap-probe + SCHEMA_SQL replay + ledger-check entirely.
Wedged brains still auto-heal — the probe says "yes pending" and initSchema
runs as before.

Building on oyi77's investigation in PR #652. Same correctness as #652's
unconditional initSchema-on-every-connect, but no perf regression on the
hot path. Failure non-fatal: if probe or init throws, log a hint and let
subsequent operations surface the real error in context.

Test coverage in test/migrate.test.ts: 3 cases covering fully-migrated
(false), version-rewound (true), and missing-version-config (defensive
true). Pairs with v0.28.5's X1 (post-upgrade auto-apply) — the upgrade
path runs initSchema explicitly while every other code path that goes
through connectEngine gets the cheap probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(upgrade): post-upgrade auto-applies pending schema migrations (X1)

Prior behavior: `gbrain upgrade` → `gbrain post-upgrade` → `apply-migrations`
only WARNs at apply-migrations.ts:296-302 when schema version is behind
LATEST_VERSION, telling the user to run `gbrain init --migrate-only`. 11
wedge incidents over 2 years have proven users don't read that WARN —
they file an issue instead.

This commit makes `runPostUpgrade` explicitly call `engine.initSchema()`
after the orchestrator migration pass, mirroring `init --migrate-only`'s
flow. Side-effect: `gbrain upgrade` now walks away with a healthy brain
in the cluster A wedge case (#670, #661, #657, #651, #625, #615, #609).

Defensive: wrapped in try/catch so a connection or DDL failure falls
back to the existing user-facing WARN. The hint to run
`gbrain init --migrate-only` is preserved as the manual escape hatch.

Pairs with v0.28.5's A1 (hasPendingMigrations probe in connectEngine):
the upgrade path runs initSchema explicitly here, while every other code
path that goes through connectEngine gets the cheap probe.

Codex outside-voice review caught this gap during plan review: "the plan
still does not prove `upgrade` will actually run schema migrations."
This is the load-bearing fix that makes v0.28.5's headline outcome
("run upgrade, brain works") literally true for cluster A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(bootstrap): auto-derive coverage from PGLITE_SCHEMA_SQL (A2)

Replaces the hand-maintained REQUIRED_BOOTSTRAP_COVERAGE assertion with a
SQL-parser-backed structural check. The new test:

1. parseIndexColumnReferences(PGLITE_SCHEMA_SQL) extracts every column
   referenced by every CREATE INDEX — including composite-index second
   and third columns. Codex outside-voice review caught that earlier
   first-col-only patterns missed v0.27's
   `idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`,
   which is exactly how the v0.28.5 wedge happened.
2. parseBaseTableColumns(PGLITE_SCHEMA_SQL) extracts every column declared
   in CREATE TABLE bodies (including via ALTER TABLE ADD COLUMN inside
   the schema blob).
3. parseAlterAddColumns(pglite-engine.ts source) extracts every column
   that applyForwardReferenceBootstrap adds.
4. Static contract: every (table, column) pair from step 1 must appear in
   either step 2 or step 3. Otherwise the test fails loud, names every
   uncovered pair, and points at the bootstrap function for the fix.

Self-updating: any future CREATE INDEX added to PGLITE_SCHEMA_SQL on a
column that bootstrap doesn't yet provide fails this test at PR time. No
human required to remember to update an array. Closes the 11-incident
wedge class identified in CLAUDE.md (#239, #243, #266, #357, #366, #374,
#375, #378, #395, #396).

Helper parsers also have their own unit tests covering composite-index
second columns, function-wrapped columns (lower(col)), HNSW operator-class
suffixes (vector_cosine_ops), and ALTER TABLE column extraction. Existing
REQUIRED_BOOTSTRAP_COVERAGE-based tests preserved as a coarse-grained
lower bound; the new parser-based test is the load-bearing structural
gate going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: support Voyage 2048d schema setup

* fix: harden Voyage schema templating

* feat: Voyage 4 embedding support + doctor eval

- Add voyage-4-large/4/4-lite/4-nano + domain models to Voyage recipe
- Fix AI SDK compatibility: strip encoding_format (Voyage rejects 'float'),
  patch response to add prompt_tokens from total_tokens
- Add embedding_provider doctor check: live smoke test verifying model,
  API key, dimensions, and DB column alignment
- Add embedding provider eval qrels for post-migration quality testing

Closes: Voyage AI integration for gbrain embedding pipeline

* fix: adaptive embed batch sizing for Voyage token limits

Voyage's tokenizer is 3-4x denser than OpenAI tiktoken, causing batches
of 50+ texts to exceed the 120K token-per-batch limit even when DB
token counts (from tiktoken) suggest they'd fit.

Changes:
- Add max_batch_tokens to EmbeddingTouchpoint type (provider-declared limit)
- Set Voyage recipe to 120K token limit
- Gateway embed() now auto-splits batches using conservative char-to-token
  estimate (1:1 ratio, 80% budget utilization)
- On token-limit errors, embedSubBatch recursively halves and retries
  (down to single-text batches before giving up)
- Reduce embedding.ts BATCH_SIZE from 100 to 50 as a secondary guard
- Add tests for batch splitting logic and error pattern matching

Fixes infinite retry loops where the same oversized batch would fail
repeatedly because WHERE embedding IS NULL re-fetches identical rows.

* fix(init): error on existing-brain dim mismatch + embedding-migration recipe

Adds A4 hard-error path: when `gbrain init --embedding-dimensions N` is
run against an existing brain whose `content_chunks.embedding` column is
a different `vector(M)`, init exits 1 with an inline four-step ALTER
recipe and a pointer to docs/embedding-migrations.md.

This kills the silent-corruption pattern surfaced by issue #673: the
v0.27 schema seeded `('embedding_dimensions', '1536')` regardless of the
flag, so users got a config saying 768 but a column at 1536 — first
sync write blew up with "expected 1536, got 768."

A4's contract:
  1. Connect to engine BEFORE saveConfig so we can read the live column type
  2. If column exists AND dim != requested, exit 1 (loud failure)
  3. If column doesn't exist (fresh init) OR dim matches, proceed normally

Recipe in docs/embedding-migrations.md (and inlined in init's error
output) covers all four destructive steps codex's plan-review caught:
  1. DROP INDEX IF EXISTS idx_chunks_embedding (HNSW won't survive ALTER)
  2. ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(N)
  3. UPDATE content_chunks SET embedding = NULL, embedded_at = NULL
  4. CREATE INDEX HNSW *only if N <= 2000* (pgvector cap)

Step 4 is conditional: dims > 2000 (e.g. Voyage 4 Large 2048d) cannot
be HNSW-indexed in pgvector; the recipe explicitly says "Skip reindex"
in that case so the user doesn't paste a CREATE INDEX that crashes.

Helper `readContentChunksEmbeddingDim` and message builder
`embeddingMismatchMessage` live in src/core/embedding-dim-check.ts so
doctor 8b (next commit) can reuse the same source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gateway): correct dim-mismatch error to point at manual ALTER recipe (#672)

Previous error message recommended running `gbrain migrate --embedding-model
… --embedding-dimensions …`, but `gbrain migrate` only handles engine
migration (postgres ↔ pglite), not embedding reconfiguration. Following
that hint produced a different error and confused users further.

New message:
  - Names the actual options: change models OR migrate the existing brain
  - Inlines a one-line quick recipe (DROP INDEX → ALTER → UPDATE NULL →
    config set → embed --stale)
  - Points at docs/embedding-migrations.md (added in commit 306fc0e1)
    for the full four-step recipe with HNSW conditional handling

Closes #672. Note: #671 (config show hides embedding_model / dimensions)
appears to be already fixed on master — `Object.entries(loadConfig())`
in config.ts:24 correctly enumerates all keys including embedding_*. Will
close #671 with that note when shipping v0.28.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(types): doctor 8b uses portable executeRaw + Voyage fetch-shim cast

#665's doctor 8b dim-probe used `engine.sql\`...\`` directly (Postgres
template literal) which doesn't typecheck against the BrainEngine
interface (only PostgresEngine has the .sql getter; PGLite does not).
Refactored to use `readContentChunksEmbeddingDim` from
src/core/embedding-dim-check.ts — same helper init's A4 hard-error
path uses, runs portably on both engines.

#680's Voyage fetch-shim passes a custom fetch handler to
`createOpenAICompatible` for the encoding_format + prompt_tokens
normalization. The SDK accepts the field at runtime but the typed
parameter on the pinned version doesn't expose it. Cast to the
parameter type so the shim ships without a type error.

Both fixes are mechanical cleanup of cherry-picked PRs that didn't
typecheck against current master's stricter shape. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): mark cli.ts executable so bun-linked installs work

`package.json` declares `"bin": { "gbrain": "src/cli.ts" }`, and bun's
linker creates `~/.bun/bin/gbrain` as a symlink to the file. The shebang
`#!/usr/bin/env bun` works only when the target file is executable —
otherwise bun runs it as a script (because it sees the script via the
shebang interpreter), but executing the symlinked target itself fails:

  $ ls -la ~/.bun/bin/gbrain
  lrwxrwxrwx ... -> ../install/global/node_modules/gbrain/src/cli.ts
  $ ~/.bun/bin/gbrain --version
  /opt/homebrew/bin/bash: line 1: /Users/brandon/.bun/bin/gbrain: Permission denied

This bites the postinstall hook that calls `gbrain apply-migrations`
(masked by the `||` fallback) and any subprocess that invokes the
binary by absolute path (e.g., subagent_messages migration v0.16's
`execSync('gbrain init --migrate-only', ...)`).

Setting the mode in-tree to 755 fixes both. No content change.

* test(ci): guard against src/cli.ts mode-bit regression (cluster C)

Cluster C cherry-pick (#683) restored the executable bit on src/cli.ts.
This commit adds scripts/check-cli-executable.sh that asserts the git
index mode is 100755 and wires it into `bun run verify` (and check:all).

Why a CI guard: bun-link installs symlink to src/cli.ts directly. If the
mode bit ever regresses to 100644, the very first `gbrain --version`
fails with `permission denied` — the exact symptom that motivated #683.
This guard runs in <100ms, fast enough for the inner verify loop.

Failure mode: clear instructions on what command to run to fix
(`chmod +x src/cli.ts && git add --chmod=+x src/cli.ts`) plus a pointer
back to issue #683 so future maintainers know why the guard exists.

Note: darwin and linux only. Windows preserves the git-stored mode
regardless of filesystem chmod, so the index-mode check works the same
on every platform CI uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(upgrade): detect bun-link, warn on npm squatter (#656, #658)

Rewrites detectInstallMethod() in src/commands/upgrade.ts:247 with three
layered signals per v0.28.5 plan cluster D + codex finding C1:

1. bun-link signal (closes #656): when argv[1] is a symlink, walk up
   from realpath(argv[1]) up to 6 levels looking for a .git/config whose
   contents include `garrytan/gbrain` (case-insensitive substring).
   Returns 'bun-link'. Best-effort: forks, tarballs, and detached source
   trees fall through to the existing chain.

2. canonical bun authenticity check (closes #658 detection half): when
   the install lives in node_modules, read package.json and verify
   repository.url contains `garrytan/gbrain` OR src/cli.ts coexists
   (squatter ships compiled binary, not source). On 'suspect' verdict,
   print printSquatterRecovery() — names both git-clone AND
   release-binary recovery paths so users without a local clone can
   still recover.

3. Source-marker fallback inside (2). Codex flagged this is spoofable
   by a determined squatter; accepted — best-effort warning, not
   assertion. The structural fix is publishing under @garrytan/gbrain
   (tracked v0.29 follow-up).

The squatter's `name: gbrain` field doesn't disambiguate (codex caught
this in plan review of my original heuristic). repository.url is the
field a careless squatter is least likely to set correctly; src/cli.ts
presence is the secondary signal.

bun-link installs return 'bun-link' from the switch in runUpgrade, which
prints the source-clone upgrade path (`git pull && bun install && bun
link`) instead of trying `bun update gbrain` which doesn't apply.

README updated with the corresponding "DO NOT use `bun add -g gbrain`"
callout naming both #658 and the v0.29 scoped-name plan.

Tests in test/upgrade.test.ts cover return-type extension, bun-link
signal shape, classifyBunInstall's two-signal check, and the recovery
message contents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.28.5 release: PGLite upgrade wedge + embedding dim corruption + bun-link foot-gun

Fix wave bundling 9 community PRs to unwedge users stuck since v0.27.

Cluster A — PGLite upgrade wedge (#670, #661, #657, #651, #625, #615, #609):
  - Bootstrap now covers v0.20+v0.26.3+v0.27 forward references (both engines)
  - hasPendingMigrations() probe gates initSchema() in connectEngine
  - Post-upgrade auto-applies pending schema migrations (X1)
  - SQL-parser-backed bootstrap coverage replaces hand-maintained array (A2)

Cluster B — Embedding dim corruption (#673, #672, #666, #640):
  - Schema templating cascade fixed end-to-end (#641 from @100yenadmin)
  - gbrain doctor 8b live embedding-provider probe (#665)
  - Voyage adaptive batch sizing for 120K-token cap (#680)
  - gbrain init A4 hard-error on existing-brain dim mismatch
  - docs/embedding-migrations.md with conditional-HNSW four-step recipe
  - #672 misleading migrate-suggestion error replaced with inline recipe

Cluster C — CLI exec bit (#683, dupe of #655):
  - src/cli.ts mode 100644 → 100755 (#683 from @brandonlipman)
  - scripts/check-cli-executable.sh CI guard against future regression

Cluster D — bun add -g foot-gun (#656, #658):
  - 3-signal detectInstallMethod rewrite (bun-link, repo.url, source-marker)
  - Loud-red recovery message names source-clone AND release-binary paths
  - README "DO NOT use bun add -g gbrain" callout

Contributors: @brandonlipman (#682, #683), @mdcruz88 (#668), @ChenyqThu
(#627), @alan-mathison-enigma (#610), @oyi77 (#652 building block),
@abkrim (#655), @100yenadmin (#641).

VERSION 0.27.0 → 0.28.5
package.json 0.27.0 → 0.28.5
schema-embedded.ts regenerated via bun run build:schema
llms-full.txt regenerated via bun run build:llms

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): v0.28.5 fix-wave end-to-end coverage

PGLite-only E2E covering the three regression scenarios v0.28.5 was shipped
to fix:

  1. cluster A — pre-v0.20 brain (missing v0.20 + v0.26.3 + v0.27 columns)
     re-runs initSchema cleanly. Strips the column set v0.28.5's bootstrap
     claims to restore (search_vector, parent_symbol_path, doc_comment,
     symbol_name_qualified, agent_name, params, error_message, provider_id),
     resets the version row to 13, then re-runs initSchema. Asserts every
     column comes back AND version reaches LATEST_VERSION.

     Closes the gap that pre-v0.28.5 produced 11 wedge incidents.

  2. cluster B — fresh init at non-default dims templates the column
     correctly (768d AND 2048d cases). The 2048d case explicitly verifies
     idx_chunks_embedding is NOT created (codex finding #8 — pgvector's
     HNSW cap is 2000).

  3. A4 — existing-brain dim mismatch helper produces a recipe that inlines
     all four steps (DROP INDEX, ALTER TYPE, NULL, conditional reindex).
     Validates the conditional CREATE INDEX HNSW for dims <= 2000 AND its
     omission for dims > 2000. The recipe a user copy-pastes won't crash
     them on Voyage 4 Large.

Plus a hasPendingMigrations() lifecycle test covering the four states
(fresh / migrated / rewound / re-applied) — pairs with the unit test in
test/migrate.test.ts but exercises the engine end-to-end.

PGLite-only because none of these cases need real Postgres. Postgres-side
bootstrap is covered by test/e2e/postgres-bootstrap.test.ts.

Run: bun test test/e2e/v0_28_5-fix-wave.test.ts (no DATABASE_URL needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: refactor embedding-dim-check.test.ts to canonical PGLite pattern

Test-isolation lint (R3+R4) requires PGLiteEngine in beforeAll() context
with afterAll() disconnect. Refactored to single-engine-per-file pattern;
the fresh-brain test uses a one-off engine inside its own try/finally so
the file-level engine stays at LATEST schema for the migrated-brain test.
No behavior change to the assertions.

`bun run verify` now passes clean (privacy + jsonb + progress +
test-isolation + wasm + admin-build + cli-exec + typecheck).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(doctor): make 8b embedding-provider probe non-fatal (CI green)

CI Tier 1 was failing on `gbrain doctor exits 0 on healthy DB` because the
v0.28.5 doctor 8b check (cherry-picked from #665) pushed `status: 'fail'`
in two non-fatal scenarios:
  1. No API key configured (`isAvailable('embedding')` returns false)
  2. Probe throws (network blip, transient 5xx, DNS, rate limit)

Both are noise in CI and on offline workstations — the brain is healthy,
the provider just isn't reachable from this environment. The v0.28.5 plan
P1 decision called for non-fatal-on-offline behavior:

  > Doctor 8b probes live every run (taken as-is). Non-fatal on network
  > failure (warns rather than errors); silently skipped when no API key
  > configured.

This commit aligns the implementation with that decision:
  - !available → status 'ok' with "Skipped (no provider credentials)"
    message so the run is visible in --json output without failing exit code
  - catch block → status 'warn' (was 'fail') so probe failures surface
    informationally without crashing CI / autopilot's periodic doctor runs

The mismatch slipped past plan-time review because #665 was cherry-picked
before P1 was finalized; the type-fix pass in 4c26e484 only adjusted the
DB-column probe shape, not the API-availability gate.

CI Tier 1 (Mechanical) — `test/e2e/mechanical.test.ts:1220` —
"gbrain doctor exits 0 on healthy DB" now passes against a fresh Postgres
without `OPENAI_API_KEY` / `VOYAGE_API_KEY` set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Brandon Lipman <brandon@offdeck.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
2026-05-06 20:58:19 -07:00

838 lines
32 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { installSigchldHandler } from './core/zombie-reap.ts';
installSigchldHandler();
import { readFileSync } from 'fs';
import { loadConfig, toEngineConfig } from './core/config.ts';
import type { BrainEngine } from './core/engine.ts';
import { operations, OperationError } from './core/operations.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { serializeMarkdown } from './core/markdown.ts';
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
import { VERSION } from './version.ts';
// Build CLI name -> operation lookup
const cliOps = new Map<string, Operation>();
for (const op of operations) {
const name = op.cliHints?.name;
if (name && !op.cliHints?.hidden) {
cliOps.set(name, op);
}
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror']);
async function main() {
// Parse global flags (--quiet / --progress-json / --progress-interval)
// BEFORE command dispatch, so `gbrain --progress-json doctor` works.
// The stripped argv is what the command sees.
const rawArgs = process.argv.slice(2);
const { cliOpts, rest: args } = parseGlobalFlags(rawArgs);
setCliOptions(cliOpts);
let command = args[0];
if (!command || command === '--help' || command === '-h') {
printHelp();
return;
}
if (command === '--version' || command === 'version') {
console.log(`gbrain ${VERSION}`);
return;
}
if (command === '--tools-json') {
const { printToolsJson } = await import('./commands/tools-json.ts');
printToolsJson();
return;
}
const subArgs = args.slice(1);
// DX alias: `ask` is a natural-language alias for `query`
if (command === 'ask') {
command = 'query';
}
// Per-command --help
if (subArgs.includes('--help') || subArgs.includes('-h')) {
const op = cliOps.get(command);
if (op) {
printOpHelp(op);
return;
}
}
// CLI-only commands
if (CLI_ONLY.has(command)) {
await handleCliOnly(command, subArgs);
return;
}
// Shared operations
const op = cliOps.get(command);
if (!op) {
console.error(`Unknown command: ${command}`);
console.error('Run gbrain --help for available commands.');
process.exit(1);
}
const engine = await connectEngine();
try {
const params = parseOpArgs(op, subArgs);
// Validate required params before calling handler
for (const [key, def] of Object.entries(op.params)) {
if (def.required && params[key] === undefined) {
const cliName = op.cliHints?.name || op.name;
const positional = op.cliHints?.positional || [];
const usage = positional.map(p => `<${p}>`).join(' ');
console.error(`Usage: gbrain ${cliName} ${usage}`);
process.exit(1);
}
}
const ctx = makeContext(engine, params);
const result = await op.handler(ctx, params);
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
} catch (e: unknown) {
if (e instanceof OperationError) {
console.error(`Error [${e.code}]: ${e.message}`);
if (e.suggestion) console.error(` Fix: ${e.suggestion}`);
process.exit(1);
}
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
} finally {
await engine.disconnect();
}
}
function parseOpArgs(op: Operation, args: string[]): Record<string, unknown> {
const params: Record<string, unknown> = {};
const positional = op.cliHints?.positional || [];
let posIdx = 0;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2).replace(/-/g, '_');
const paramDef = op.params[key];
if (paramDef?.type === 'boolean') {
params[key] = true;
} else if (i + 1 < args.length) {
params[key] = args[++i];
if (paramDef?.type === 'number') params[key] = Number(params[key]);
}
} else if (posIdx < positional.length) {
const key = positional[posIdx++];
const paramDef = op.params[key];
params[key] = paramDef?.type === 'number' ? Number(arg) : arg;
}
}
// Read stdin for content params
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
const stdinContent = readFileSync('/dev/stdin', 'utf-8');
const MAX_STDIN = 5_000_000; // 5MB
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
params[op.cliHints.stdin] = stdinContent;
}
return params;
}
function makeContext(engine: BrainEngine, params: Record<string, unknown>): OperationContext {
return {
engine,
config: loadConfig() || { engine: 'postgres' },
logger: { info: console.log, warn: console.warn, error: console.error },
dryRun: (params.dry_run as boolean) || false,
// Local CLI invocation — the user owns the machine; do not apply remote-caller
// confinement (e.g., cwd-locked file_upload).
remote: false,
cliOpts: getCliOptions(),
};
}
function formatResult(opName: string, result: unknown): string {
switch (opName) {
case 'get_page': {
const r = result as any;
if (r.error === 'ambiguous_slug') {
return `Ambiguous slug. Did you mean:\n${r.candidates.map((c: string) => ` ${c}`).join('\n')}\n`;
}
return serializeMarkdown(r.frontmatter || {}, r.compiled_truth || '', r.timeline || '', {
type: r.type, title: r.title, tags: r.tags || [],
});
}
case 'list_pages': {
const pages = result as any[];
if (pages.length === 0) return 'No pages found.\n';
return pages.map(p =>
`${p.slug}\t${p.type}\t${p.updated_at?.toString().slice(0, 10) || '?'}\t${p.title}`,
).join('\n') + '\n';
}
case 'search':
case 'query': {
const results = result as any[];
if (results.length === 0) return 'No results.\n';
return results.map(r =>
`[${r.score?.toFixed(4) || '?'}] ${r.slug} -- ${r.chunk_text?.slice(0, 100) || ''}${r.stale ? ' (stale)' : ''}`,
).join('\n') + '\n';
}
case 'get_tags': {
const tags = result as string[];
return tags.length > 0 ? tags.join(', ') + '\n' : 'No tags.\n';
}
case 'get_stats': {
const s = result as any;
const lines = [
`Pages: ${s.page_count}`,
`Chunks: ${s.chunk_count}`,
`Embedded: ${s.embedded_count}`,
`Links: ${s.link_count}`,
`Tags: ${s.tag_count}`,
`Timeline: ${s.timeline_entry_count}`,
];
if (s.pages_by_type) {
lines.push('', 'By type:');
for (const [k, v] of Object.entries(s.pages_by_type)) {
lines.push(` ${k}: ${v}`);
}
}
return lines.join('\n') + '\n';
}
case 'get_health': {
const h = result as any;
// Health score weights: missing_embeddings is the heaviest (2 pts), other
// graph quality issues are 1 pt each. link_coverage / timeline_coverage below
// 50% on entity pages indicates the graph needs population.
const score = Math.max(0, 10
- (h.missing_embeddings > 0 ? 2 : 0)
- (h.stale_pages > 0 ? 1 : 0)
- (h.orphan_pages > 0 ? 1 : 0)
- ((h.link_coverage ?? 1) < 0.5 ? 1 : 0)
- ((h.timeline_coverage ?? 1) < 0.5 ? 1 : 0));
const lines = [
`Health score: ${score}/10`,
`Embed coverage: ${(h.embed_coverage * 100).toFixed(1)}%`,
`Missing embeddings: ${h.missing_embeddings}`,
`Stale pages: ${h.stale_pages}`,
`Orphan pages: ${h.orphan_pages}`,
];
if (h.link_coverage !== undefined) {
lines.push(`Link coverage (entities): ${(h.link_coverage * 100).toFixed(1)}%`);
}
if (h.timeline_coverage !== undefined) {
lines.push(`Timeline coverage (entities): ${(h.timeline_coverage * 100).toFixed(1)}%`);
}
if (Array.isArray(h.most_connected) && h.most_connected.length > 0) {
lines.push('Most connected entities:');
for (const e of h.most_connected) {
lines.push(` ${e.slug}: ${e.link_count} links`);
}
}
return lines.join('\n') + '\n';
}
case 'get_timeline': {
const entries = result as any[];
if (entries.length === 0) return 'No timeline entries.\n';
return entries.map(e =>
`${e.date} ${e.summary}${e.source ? ` [${e.source}]` : ''}`,
).join('\n') + '\n';
}
case 'get_versions': {
const versions = result as any[];
if (versions.length === 0) return 'No versions.\n';
return versions.map(v =>
`#${v.id} ${v.snapshot_at?.toString().slice(0, 19) || '?'} ${v.compiled_truth?.slice(0, 60) || ''}...`,
).join('\n') + '\n';
}
default:
return JSON.stringify(result, null, 2) + '\n';
}
}
async function handleCliOnly(command: string, args: string[]) {
// Commands that don't need a database connection
if (command === 'init') {
const { runInit } = await import('./commands/init.ts');
await runInit(args);
return;
}
if (command === 'auth') {
const { runAuth } = await import('./commands/auth.ts');
await runAuth(args);
return;
}
if (command === 'upgrade') {
const { runUpgrade } = await import('./commands/upgrade.ts');
await runUpgrade(args);
return;
}
if (command === 'post-upgrade') {
const { runPostUpgrade } = await import('./commands/upgrade.ts');
await runPostUpgrade(args);
return;
}
if (command === 'check-update') {
const { runCheckUpdate } = await import('./commands/check-update.ts');
await runCheckUpdate(args);
return;
}
if (command === 'integrations') {
const { runIntegrations } = await import('./commands/integrations.ts');
await runIntegrations(args);
return;
}
if (command === 'providers') {
const { runProviders } = await import('./commands/providers.ts');
const [sub, ...rest] = args;
await runProviders(sub, rest);
return;
}
if (command === 'auth') {
const { runAuth } = await import('./commands/auth.ts');
await runAuth(args);
return;
}
if (command === 'resolvers') {
const { runResolvers } = await import('./commands/resolvers.ts');
await runResolvers(args);
return;
}
if (command === 'integrity') {
const { runIntegrity } = await import('./commands/integrity.ts');
await runIntegrity(args);
return;
}
if (command === 'publish') {
const { runPublish } = await import('./commands/publish.ts');
await runPublish(args);
return;
}
if (command === 'check-backlinks') {
const { runBacklinks } = await import('./commands/backlinks.ts');
await runBacklinks(args);
return;
}
if (command === 'frontmatter') {
const { runFrontmatter } = await import('./commands/frontmatter.ts');
await runFrontmatter(args);
return;
}
if (command === 'lint') {
const { runLint } = await import('./commands/lint.ts');
await runLint(args);
return;
}
if (command === 'check-resolvable') {
const { runCheckResolvable } = await import('./commands/check-resolvable.ts');
await runCheckResolvable(args);
return;
}
if (command === 'mounts') {
// No DB needed: mounts.json is a local config file. Registry will
// connect mount engines lazily on first use by op dispatch.
const { runMounts } = await import('./commands/mounts.ts');
await runMounts(args);
return;
}
if (command === 'routing-eval') {
const { runRoutingEvalCli } = await import('./commands/routing-eval.ts');
await runRoutingEvalCli(args);
return;
}
if (command === 'skillify') {
const { runSkillify } = await import('./commands/skillify.ts');
// `args` here is subArgs (command already stripped by caller), so
// args[0] is the subcommand (scaffold|check).
await runSkillify(args);
return;
}
if (command === 'skillpack') {
const { runSkillpack } = await import('./commands/skillpack.ts');
// subArgs already has `skillpack` stripped; args[0] is the subcommand.
await runSkillpack(args);
return;
}
if (command === 'friction') {
const { runFriction } = await import('./commands/friction.ts');
process.exit(runFriction(args));
}
if (command === 'claw-test') {
const { runClawTest } = await import('./commands/claw-test.ts');
process.exit(await runClawTest(args));
}
if (command === 'report') {
const { runReport } = await import('./commands/report.ts');
await runReport(args);
return;
}
if (command === 'apply-migrations') {
// Does not need connectEngine — each phase (schema, smoke, host-rewrite)
// manages its own subprocess or file-layer access directly. Avoids
// connecting a second time when the orchestrator shells out to
// `gbrain init --migrate-only` and `gbrain jobs smoke`.
const { runApplyMigrations } = await import('./commands/apply-migrations.ts');
await runApplyMigrations(args);
return;
}
if (command === 'repair-jsonb') {
const { runRepairJsonbCli } = await import('./commands/repair-jsonb.ts');
await runRepairJsonbCli(args);
return;
}
if (command === 'skillpack-check') {
// Agent-readable health report. Shells out to doctor + apply-migrations
// internally; does not need its own DB connection.
const { runSkillpackCheck } = await import('./commands/skillpack-check.ts');
await runSkillpackCheck(args);
return;
}
if (command === 'doctor') {
// Doctor runs filesystem checks first (no DB needed), then DB checks.
// --fast skips DB checks entirely.
const { runDoctor } = await import('./commands/doctor.ts');
const { getDbUrlSource } = await import('./core/config.ts');
if (args.includes('--fast')) {
// Pass the DB URL source so doctor can tell "no config at all" from
// "user chose --fast while config is present".
await runDoctor(null, args, getDbUrlSource());
} else {
try {
const eng = await connectEngine();
await runDoctor(eng, args);
await eng.disconnect();
} catch {
// DB unavailable — still run filesystem checks
await runDoctor(null, args, getDbUrlSource());
}
}
return;
}
if (command === 'smoke-test') {
// Run smoke tests — no DB connection needed, the script handles its own checks
const { execSync } = await import('child_process');
const { resolve, dirname } = await import('path');
const { fileURLToPath } = await import('url');
const scriptDir = dirname(fileURLToPath(import.meta.url));
const scriptPath = resolve(scriptDir, '..', 'scripts', 'smoke-test.sh');
try {
execSync(`bash "${scriptPath}"`, { stdio: 'inherit', env: { ...process.env } });
} catch (e: any) {
// Non-zero exit = some tests failed (exit code = failure count)
process.exit(e.status ?? 1);
}
return;
}
if (command === 'dream') {
// Dream mirrors doctor's pattern: filesystem phases run without a DB,
// so an engine connection failure is non-fatal. runCycle honestly
// reports DB phases as skipped when engine is null.
const { runDream } = await import('./commands/dream.ts');
let eng: BrainEngine | null = null;
try {
eng = await connectEngine();
} catch {
// DB unavailable — lint + backlinks still run against the brain dir.
}
try {
await runDream(eng, args);
} finally {
if (eng) await eng.disconnect();
}
return;
}
// `eval cross-modal` is a pure API-call command — no DB, no brain. Bypass
// connectEngine entirely so first-run users (no `gbrain init` yet) can
// run the quality gate. Mirrors the dream/doctor no-DB pattern but
// doesn't even attempt the connect (T3=A in plans/radiant-napping-lerdorf.md).
// The handler self-configures the AI gateway from loadConfig() + process.env.
if (command === 'eval' && args[0] === 'cross-modal') {
const { runEvalCrossModal } = await import('./commands/eval-cross-modal.ts');
process.exit(await runEvalCrossModal(args.slice(1)));
}
// All remaining CLI-only commands need a DB connection
const engine = await connectEngine();
try {
switch (command) {
case 'import': {
const { runImport } = await import('./commands/import.ts');
await runImport(engine, args);
break;
}
case 'export': {
const { runExport } = await import('./commands/export.ts');
await runExport(engine, args);
break;
}
case 'files': {
const { runFiles } = await import('./commands/files.ts');
await runFiles(engine, args);
break;
}
case 'embed': {
const { runEmbed } = await import('./commands/embed.ts');
await runEmbed(engine, args);
break;
}
case 'serve': {
const { runServe } = await import('./commands/serve.ts');
await runServe(engine, args);
return; // serve doesn't disconnect
}
case 'call': {
const { runCall } = await import('./commands/call.ts');
await runCall(engine, args);
break;
}
case 'config': {
const { runConfig } = await import('./commands/config.ts');
await runConfig(engine, args);
break;
}
// doctor is handled before connectEngine() above
case 'migrate': {
const { runMigrateEngine } = await import('./commands/migrate-engine.ts');
await runMigrateEngine(engine, args);
break;
}
case 'eval': {
const { runEvalCommand } = await import('./commands/eval.ts');
await runEvalCommand(engine, args);
break;
}
case 'jobs': {
const { runJobs } = await import('./commands/jobs.ts');
await runJobs(engine, args);
break;
}
case 'agent': {
const { runAgent } = await import('./commands/agent.ts');
await runAgent(engine, args);
break;
}
case 'book-mirror': {
const { runBookMirrorCmd } = await import('./commands/book-mirror.ts');
await runBookMirrorCmd(engine, args);
break;
}
case 'sync': {
const { runSync } = await import('./commands/sync.ts');
await runSync(engine, args);
break;
}
case 'extract': {
const { runExtract } = await import('./commands/extract.ts');
await runExtract(engine, args);
break;
}
case 'features': {
const { runFeatures } = await import('./commands/features.ts');
await runFeatures(engine, args);
break;
}
case 'autopilot': {
const { runAutopilot } = await import('./commands/autopilot.ts');
await runAutopilot(engine, args);
return; // autopilot doesn't disconnect (long-running)
}
case 'graph-query': {
const { runGraphQuery } = await import('./commands/graph-query.ts');
await runGraphQuery(engine, args);
break;
}
case 'reconcile-links': {
// v0.20.0 Cathedral II Layer 8 D3: batch-recompute doc↔impl edges
// for any markdown page that cites code files. Idempotent; safe to
// re-run. Closes the v0.19.0 Layer 6 order-dependency bug where
// guides imported before their code never got their edges written.
const { runReconcileLinksCli } = await import('./commands/reconcile-links.ts');
await runReconcileLinksCli(engine, args);
break;
}
case 'orphans': {
const { runOrphans } = await import('./commands/orphans.ts');
await runOrphans(engine, args);
break;
}
case 'sources': {
const { runSources } = await import('./commands/sources.ts');
await runSources(engine, args);
break;
}
case 'pages': {
// v0.26.5: page-level operator commands (purge-deleted escape hatch).
const { runPages } = await import('./commands/pages.ts');
await runPages(engine, args);
break;
}
case 'storage': {
const { runStorage } = await import('./commands/storage.ts');
await runStorage(engine, args);
break;
}
case 'code-def': {
const { runCodeDef } = await import('./commands/code-def.ts');
await runCodeDef(engine, args);
break;
}
case 'code-refs': {
const { runCodeRefs } = await import('./commands/code-refs.ts');
await runCodeRefs(engine, args);
break;
}
case 'reindex-code': {
// v0.20.0 Cathedral II Layer 13 (E2): explicit code-page reindex
// for users upgrading from v0.19.0. Cost-preview gated; TTY prompt
// or ConfirmationRequired envelope for non-TTY/JSON callers.
const { runReindexCodeCli } = await import('./commands/reindex-code.ts');
await runReindexCodeCli(engine, args);
break;
}
case 'code-callers': {
// v0.20.0 Cathedral II Layer 10 (C4): "who calls <symbol>?"
const { runCodeCallers } = await import('./commands/code-callers.ts');
await runCodeCallers(engine, args);
break;
}
case 'code-callees': {
// v0.20.0 Cathedral II Layer 10 (C5): "what does <symbol> call?"
const { runCodeCallees } = await import('./commands/code-callees.ts');
await runCodeCallees(engine, args);
break;
}
case 'repos': {
// v0.19.0: `gbrain repos ...` is an alias into the v0.18.0 sources
// subsystem. The repos abstraction (Garry's OpenClaw baseline) was
// redundant with sources and carried per-user config state that
// couldn't participate in federation / RLS / multi-tenancy. We
// keep the alias so scripts like `gbrain repos add .` keep
// working, with a nudge toward the canonical command.
console.error('[gbrain] Note: "repos" is an alias for "sources" as of v0.19.0. Prefer `gbrain sources <subcommand>`.');
const { runSources } = await import('./commands/sources.ts');
await runSources(engine, args);
break;
}
}
} finally {
if (command !== 'serve') await engine.disconnect();
}
}
async function connectEngine(): Promise<BrainEngine> {
const config = loadConfig();
if (!config) {
console.error('No brain configured. Run: gbrain init');
process.exit(1);
}
// Configure the AI gateway BEFORE engine connect — initSchema needs embedding dims.
// Env is read once here; the gateway never reads process.env at call time (Codex C3).
const { configureGateway } = await import('./core/ai/gateway.ts');
configureGateway({
embedding_model: config.embedding_model,
embedding_dimensions: config.embedding_dimensions,
expansion_model: config.expansion_model,
chat_model: config.chat_model,
chat_fallback_chain: config.chat_fallback_chain,
base_urls: config.provider_base_urls,
env: { ...process.env },
});
const { createEngine } = await import('./core/engine-factory.ts');
const engine = await createEngine(toEngineConfig(config));
const noRetry = process.argv.includes('--no-retry-connect') ||
process.env.GBRAIN_NO_RETRY_CONNECT === '1';
const { connectWithRetry } = await import('./core/db.ts');
await connectWithRetry(engine, toEngineConfig(config), { noRetry });
// Auto-apply pending schema migrations on connect (#651). Cheap probe
// first so already-migrated brains don't pay the bootstrap-probe +
// SCHEMA_SQL replay + ledger-check cost on every short-lived CLI call.
// This is the conditional version of #652 (oyi77's investigation):
// same correctness, no perf regression on the hot path.
try {
const { hasPendingMigrations } = await import('./core/migrate.ts');
if (await hasPendingMigrations(engine)) {
await engine.initSchema();
}
} catch (err) {
// Non-fatal: if probe or initSchema fails, surface a hint and continue
// with the connected engine. Subsequent operations will surface the
// real schema error in context.
console.warn(` Schema probe/migrate failed: ${(err as Error).message}`);
console.warn(' Try: gbrain init --migrate-only');
}
return engine;
}
function printOpHelp(op: Operation) {
const positional = (op.cliHints?.positional || []).map(p => `<${p}>`).join(' ');
const name = op.cliHints?.name || op.name;
console.log(`Usage: gbrain ${name} ${positional} [options]\n`);
console.log(op.description + '\n');
const entries = Object.entries(op.params);
if (entries.length > 0) {
console.log('Options:');
for (const [key, def] of entries) {
const isPos = op.cliHints?.positional?.includes(key);
const req = def.required ? ' (required)' : '';
const prefix = isPos ? ` <${key}>` : ` --${key.replace(/_/g, '-')}`;
console.log(`${prefix.padEnd(28)} ${def.description || ''}${req}`);
}
}
}
function printHelp() {
// Gather shared operations grouped by category
const cliNames = Array.from(cliOps.entries())
.map(([name, op]) => ({ name, desc: op.description }));
console.log(`gbrain ${VERSION} -- personal knowledge brain
USAGE
gbrain <command> [options]
SETUP
init [--pglite|--supabase|--url] Create brain (PGLite default, no server)
migrate --to <supabase|pglite> Transfer brain between engines
upgrade Self-update
check-update [--json] Check for new versions
doctor [--json] [--fast] Health check (resolver, skills, pgvector, RLS, embeddings)
integrations [subcommand] Manage integration recipes (senses + reflexes)
PAGES
get <slug> Read a page
put <slug> [< file.md] Write/update a page
delete <slug> Delete a page
list [--type T] [--tag T] [-n N] List pages
SEARCH
search <query> Keyword search (tsvector)
query <question> [--no-expand] Hybrid search (RRF + expansion)
ask <question> [--no-expand] Alias for query
IMPORT/EXPORT
import <dir> [--no-embed] Import markdown directory
sync [--repo <path>] [flags] Git-to-brain incremental sync
sync --watch [--interval N] Continuous sync (loops until stopped)
sync --install-cron Install persistent sync daemon
export [--dir ./out/] Export to markdown
export --restore-only [--repo <p>] Restore missing supabase-only files
[--type T] [--slug-prefix S] With optional filters
FILES
files list [slug] List stored files
files upload <file> --page <slug> Upload file to storage
files upload-raw <file> --page <s> Smart upload (size routing + .redirect.yaml)
files signed-url <path> Generate signed URL (1-hour)
files sync <dir> Bulk upload directory
files verify Verify all uploads
EMBEDDINGS
embed [<slug>|--all|--stale] Generate/refresh embeddings
LINKS
link <from> <to> [--type T] Create typed link
unlink <from> <to> Remove link
backlinks <slug> Incoming links
graph <slug> [--depth N] Traverse link graph (returns nodes)
graph-query <slug> [--type T] Edge-based traversal with type/direction filters
[--depth N] [--direction in|out|both]
TAGS
tags <slug> List tags
tag <slug> <tag> Add tag
untag <slug> <tag> Remove tag
TIMELINE
timeline [<slug>] View timeline
timeline-add <slug> <date> <text> Add timeline entry
TOOLS
extract <links|timeline|all> Extract links/timeline (idempotent)
[--source fs|db] fs (default) walks .md files; db iterates engine pages
[--dir <brain>] brain dir for fs source
[--type T] [--since DATE] filters (db source)
[--dry-run] [--json]
publish <page.md> [--password] Shareable HTML (strips private data, optional AES-256)
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
orphans [--json] [--count] Find pages with no inbound wikilinks
dream [--dry-run] [--json] Run the overnight maintenance cycle once (cron-friendly).
See also: autopilot --install (continuous daemon).
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
report --type <name> --content ... Save timestamped report to brain/reports/
SOURCES (multi-repo / multi-brain)
sources list Show registered sources
sources add <id> --path <p> Register a source (id = short name, e.g. 'wiki')
sources remove <id> Remove a source + its pages
sync --all Sync all sources with a local_path
sync --source <id> Sync one specific source
repos ... DEPRECATED alias for 'sources' (v0.19.0)
CODE INDEXING (v0.19.0 / v0.20.0 Cathedral II)
code-def <symbol> [--lang l] Find the definition of a symbol across code pages
code-refs <symbol> [--lang l] Find all references to a symbol (JSON-first)
code-callers <symbol> Who calls this symbol? (v0.20.0 A1)
code-callees <symbol> What does this symbol call? (v0.20.0 A1)
query <q> --lang <l> Filter hybrid search to one language (v0.20.0)
query <q> --symbol-kind <k> Filter to symbol type (function|class|method|...) (v0.20.0)
reconcile-links [--dry-run] Batch-recompute doc↔impl edges (v0.20.0)
reindex-code [--source id] [--yes] Explicit code-page reindex (v0.20.0)
sync --strategy code Sync code files into the brain
JOBS (Minions)
jobs submit <name> [--params JSON] Submit background job [--follow] [--dry-run]
jobs list [--status S] [--limit N] List jobs
jobs get <id> Job details + history
jobs cancel <id> Cancel job
jobs retry <id> Re-queue failed/dead job
jobs prune [--older-than 30d] Clean old jobs
jobs stats Job health dashboard
jobs work [--queue Q] Start worker daemon (Postgres only)
ADMIN
stats Brain statistics
health Brain health dashboard
history <slug> Page version history
revert <slug> <version-id> Revert to version
features [--json] [--auto-fix] Scan usage + recommend unused features
autopilot [--repo] [--interval N] Self-maintaining brain daemon
config [show|get|set] <key> [val] Brain config
storage status [--repo <path>] Storage tier status and health
[--json] (git-tracked vs supabase-only)
serve MCP server (stdio)
serve --http [--port N] HTTP MCP server with OAuth 2.1
--token-ttl N Access token TTL in seconds (default: 3600)
--enable-dcr Enable Dynamic Client Registration
--public-url URL Public issuer URL (required behind proxy/tunnel)
call <tool> '<json>' Raw tool invocation
version Version info
--tools-json Tool discovery (JSON)
Run gbrain <command> --help for command-specific help.
`);
}
main().catch(e => {
console.error(e.message || e);
process.exit(1);
});