diff --git a/CHANGELOG.md b/CHANGELOG.md index ca445455ec..67f65e4b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ file is the human-readable summary of what changed and why. ## Unreleased +### CodeGraph + +- **`codev codegraph enable` wires the CodeGraph MCP server** — for standalone + installs that aren't managed by CoDev Hub. It makes the `codegraph` binary + resolvable (installing `@colbymchenry/codegraph` globally via npm or bun when + it's missing, or printing the manual install command when neither package + manager exists), writes the MCP server entry into the global `codev.json(c)` + with a comment-preserving edit (idempotent, and byte-identical to what CoDev + Hub and CodeGraph's own installer write, so the writers never fight), and + offers to index the current project — otherwise you can index any project + later by running `codegraph init` in the project root directory. + `codev codegraph disable` + removes the entry again, leaving other MCP servers untouched. + ([#128](https://github.com/quickbeard/codev-code/pull/128)) + ## 1.18.3-1 ### Sessions diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 562fa54a12..1b6d43516c 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -71,7 +71,7 @@ from the squash message first, or use a plain merge. | File | Change | Why | On conflict | | --- | --- | --- | --- | | `packages/core/src/global.ts`, `packages/core/test/global.test.ts`, `packages/opencode/test/cli/mcp-add.test.ts` | `const app = "opencode"` → `"codev"`; test expectations `tmpdir()/opencode` → `tmpdir()/codev` and `.config/opencode/` → `.config/codev/` | Config/data/cache/state dirs under `~/.config/codev` etc.; upstream tests assert the literal dir name. | Keep ours (the one constant + expectations); take upstream's surrounding changes. | -| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file) and added `configure` to the help-snapshot TOP_LEVEL list; test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | +| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file) and `CodegraphCommand` (cli/cmd/codegraph.ts, new file) and added `configure` + `codegraph` to the help-snapshot TOP_LEVEL list; test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point, and `codev codegraph` is the standalone CodeGraph MCP wiring for installs not managed by CoDev Hub. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | | `package.json` (root) | `name` `opencode` → `codev-code`; `repository.url` → `github.com/quickbeard/codev-code`; added `release` script delegating to packages/opencode; added `bump` script pointing at the fork-only `script/bump-version.ts`; removed the `sso`, `dev:stats`, and `random` scripts | The root manifest carries the fork's name (nothing reads it — root is private and unpublished; `packages/script` reads only `packageManager` — so this is cosmetic and also removes the name collision with the `packages/opencode` workspace member, whose name MUST stay `opencode`: build.ts derives bun compile targets and dist dir names from it). `bun run release` and `bun run bump` work from the repo root. `sso` logs into upstream's AWS org (`--sso-session=opencode`) and `dev:stats` runs inside an sst shell against upstream's production stage — neither works without anomalyco credentials; `random` is inert cruft (`echo 'Random script'`). | Keep our name + release/bump lines and the removals (drop any new upstream-infra-only scripts too); take upstream's other fields. | | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key and release line; take upstream's other fields including `version`. | | `bun.lock` | Mirrors the bin key rename above and the fork-only workspace packages/deps (`@codev/gateway`) | Lockfile records workspace bin mappings and workspace deps. Known quirk: the root workspace stanza still says `"name": "opencode"` — bun never syncs the root manifest's name into an existing lock (verified: neither `bun install` nor `--lockfile-only` rewrites it); it self-corrects only on a from-scratch regeneration and is harmless meanwhile. Don't hand-edit it. | Regenerate with `bun install`; never hand-merge. | diff --git a/packages/opencode/src/cli/cmd/codegraph.ts b/packages/opencode/src/cli/cmd/codegraph.ts new file mode 100644 index 0000000000..3cedeadfe2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/codegraph.ts @@ -0,0 +1,235 @@ +// Fork-only command: standalone CodeGraph integration for CoDev Code. +// +// CoDev Hub wires CodeGraph for its managed installs, but a standalone +// `codev` install has nothing to do that wiring. `codev codegraph enable` +// closes the gap: it makes the `codegraph` binary resolvable (installing the +// npm package if needed), writes the MCP server entry into the global config, +// and offers to index the current project. `disable` reverses the wiring. +// +// The entry written here is byte-identical to what CoDev Hub's shim and +// codegraph's own installer (custom `codev` target) write, so the three +// writers are interchangeable: whichever runs later sees an already-correct +// entry and changes nothing. +import path from "path" +import { confirm, intro, isCancel, log, outro, spinner } from "@clack/prompts" +import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" +import { Global } from "@opencode-ai/core/global" +import { UI } from "../ui" +import { Filesystem } from "@/util/filesystem" +import { cmd } from "./cmd" + +// The npm package that ships the `codegraph` CLI + MCP server. +const CODEGRAPH_PACKAGE = "@colbymchenry/codegraph" + +const SERVER_ENTRY = { + type: "local", + command: ["codegraph", "serve", "--mcp"], + enabled: true, +} + +const FORMATTING = { tabSize: 2, insertSpaces: true } + +const LATER_HINT = "You can index any project later by running `codegraph init` in the project root directory." + +export const CodegraphCommand = cmd({ + command: "codegraph", + describe: "manage the CodeGraph code-intelligence MCP server", + builder: (yargs) => yargs.command(CodegraphEnableCommand).command(CodegraphDisableCommand).demandCommand(), + async handler() {}, +}) + +export const CodegraphEnableCommand = cmd({ + command: "enable", + describe: "install CodeGraph and wire its MCP server into codev", + builder: (yargs) => + yargs.option("yes", { + alias: "y", + describe: "answer yes to prompts (index the current project)", + type: "boolean", + default: false, + }), + handler: async (args) => { + UI.empty() + intro("Enable CodeGraph") + + const problem = await ensureCodegraphBinary() + if (problem) { + log.error(problem) + outro("Not enabled") + process.exitCode = 1 + return + } + + const wire = await writeServerEntry() + if (wire.status === "error") { + log.error(wire.message) + outro("Not enabled") + process.exitCode = 1 + return + } + if (wire.status === "unchanged") log.info(`CodeGraph MCP server already configured in ${wire.file}`) + if (wire.status === "written") log.success(`CodeGraph MCP server configured in ${wire.file}`) + + await offerProjectIndex(args.yes) + outro("Done") + }, +}) + +export const CodegraphDisableCommand = cmd({ + command: "disable", + describe: "remove the CodeGraph MCP server from codev", + handler: async () => { + UI.empty() + intro("Disable CodeGraph") + + const removed: string[] = [] + for (const file of globalConfigCandidates()) { + const state = await readCandidate(file) + if (!state.exists) continue + if (state.malformed) { + log.warn(`${file} has JSON syntax errors — left untouched`) + continue + } + if (state.entry === undefined) continue + + let text = applyEdits( + state.text, + modify(state.text, ["mcp", "codegraph"], undefined, { formattingOptions: FORMATTING }), + ) + // Drop the wrapper when the removed entry was the last server, so the + // config returns to its pre-enable shape. + const remaining = mcpMapOf(parse(text, [], { allowTrailingComma: true })) + if (remaining && Object.keys(remaining).length === 0) { + text = applyEdits(text, modify(text, ["mcp"], undefined, { formattingOptions: FORMATTING })) + } + await Filesystem.write(file, text) + removed.push(file) + } + + if (removed.length === 0) { + log.info("CodeGraph MCP server is not configured") + outro("Done") + return + } + for (const file of removed) log.success(`Removed CodeGraph MCP server from ${file}`) + outro("Done") + }, +}) + +// Make the bare `codegraph` command resolvable — the MCP entry references it +// by name, so it must be on PATH when a session starts. Probe first; else +// install the npm package globally with the first available package manager. +// Returns a problem message, or null when the binary is ready. +async function ensureCodegraphBinary() { + if (Bun.which("codegraph")) return null + + const managers = [ + { name: "npm", install: ["npm", "install", "--global", CODEGRAPH_PACKAGE] }, + { name: "bun", install: ["bun", "add", "--global", CODEGRAPH_PACKAGE] }, + ] + for (const manager of managers) { + if (!Bun.which(manager.name)) continue + const progress = spinner() + progress.start(`Installing ${CODEGRAPH_PACKAGE} with ${manager.name}`) + const proc = Bun.spawn(manager.install, { stdout: "pipe", stderr: "pipe" }) + const exitCode = await proc.exited + if (exitCode === 0) { + progress.stop(`Installed ${CODEGRAPH_PACKAGE}`) + // A zero exit with the binary still unresolvable means the manager's + // global bin dir isn't on PATH — the entry works once it is, so warn + // rather than fail. + if (!Bun.which("codegraph")) + log.warn("codegraph was installed but is not on PATH — sessions may need a restarted shell to find it") + return null + } + const stderrText = await new Response(proc.stderr).text() + const detail = stderrText.trim().split("\n").at(-1) + progress.stop(`${manager.name} install failed${detail ? `: ${detail}` : ""}`, 1) + } + + return ( + `Could not install codegraph automatically. Install it with \`npm install -g ${CODEGRAPH_PACKAGE}\` ` + + `(or \`bun add -g ${CODEGRAPH_PACKAGE}\`), then re-run \`codev codegraph enable\`.` + ) +} + +// Candidates the fork's config loader reads from ~/.config/codev, in load +// order (codev.json first, codev.jsonc merged over it). Kept in sync with +// `codev mcp add`'s resolveConfigPath (cli/cmd/mcp.ts), global scope. +function globalConfigCandidates() { + return [path.join(Global.Path.config, "codev.json"), path.join(Global.Path.config, "codev.jsonc")] +} + +// Write SERVER_ENTRY into the global config: the file that already carries an +// mcp.codegraph key wins (update in place — CoDev Hub prefers codev.jsonc, so +// the entry may live there), else the first existing candidate, else a fresh +// codev.json — matching `codev mcp add`'s greenfield default. The fork's +// loader injects `$schema` on first read, so a fresh file doesn't need one. +async function writeServerEntry() { + const states = await Promise.all(globalConfigCandidates().map(readCandidate)) + const current = states.find((state) => state.entry !== undefined) + if (current && JSON.stringify(current.entry) === JSON.stringify(SERVER_ENTRY)) { + return { status: "unchanged" as const, file: current.file } + } + + const target = current ?? states.find((state) => state.exists) ?? states[0] + if (target.malformed) { + return { status: "error" as const, message: `${target.file} has JSON syntax errors — fix it and re-run` } + } + const edits = modify(target.text, ["mcp", "codegraph"], SERVER_ENTRY, { formattingOptions: FORMATTING }) + await Filesystem.write(target.file, applyEdits(target.text, edits)) + return { status: "written" as const, file: target.file } +} + +async function readCandidate(file: string) { + if (!(await Filesystem.exists(file))) { + return { file, exists: false, malformed: false, text: "{}", entry: undefined } + } + const raw = await Filesystem.readText(file) + const text = raw.trim() === "" ? "{}" : raw + const errors: ParseError[] = [] + const parsed: unknown = parse(text, errors, { allowTrailingComma: true }) + return { + file, + exists: true, + malformed: errors.length > 0, + text, + entry: errors.length > 0 ? undefined : mcpMapOf(parsed)?.codegraph, + } +} + +function mcpMapOf(config: unknown) { + if (!config || typeof config !== "object") return undefined + const mcp = (config as Record).mcp + if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return undefined + return mcp as Record +} + +// Per-project step: the MCP wiring above is global, but the knowledge graph +// is built per project by `codegraph init`. Offer it for the current project +// when it isn't indexed yet; `--yes` auto-accepts, and a non-interactive run +// just prints how to do it later. +async function offerProjectIndex(yes: boolean) { + if (await Filesystem.exists(path.join(process.cwd(), ".codegraph"))) { + log.info("This project is already indexed (.codegraph exists).") + return + } + if (!yes && !process.stdout.isTTY) { + log.info(LATER_HINT) + return + } + const answer = yes ? true : await confirm({ message: "Index this project with CodeGraph now?" }) + if (isCancel(answer) || !answer) { + log.info(LATER_HINT) + return + } + + const proc = Bun.spawn(["codegraph", "init"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }) + const exitCode = await proc.exited + if (exitCode !== 0) { + log.warn(`codegraph init exited with code ${exitCode}. ${LATER_HINT}`) + return + } + log.success("Project indexed.") + log.info("Index other projects anytime by running `codegraph init` in the project root directory.") +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index eccaa4b937..9afbedd436 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -15,6 +15,7 @@ import { ServeCommand } from "./cli/cmd/serve" import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" import { McpCommand } from "./cli/cmd/mcp" +import { CodegraphCommand } from "./cli/cmd/codegraph" import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" @@ -81,6 +82,7 @@ const cli = yargs(args) .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) + .command(CodegraphCommand) .command(TuiThreadCommand) .command(ConfigureCommand) .command(AttachCommand) diff --git a/packages/opencode/test/cli/codegraph.test.ts b/packages/opencode/test/cli/codegraph.test.ts new file mode 100644 index 0000000000..5f5d23017e --- /dev/null +++ b/packages/opencode/test/cli/codegraph.test.ts @@ -0,0 +1,216 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { existsSync } from "node:fs" +import { chmod, mkdir, writeFile } from "node:fs/promises" +import path from "path" +import { cliIt } from "../lib/cli-process" + +// The canonical entry — must stay byte-identical to what CoDev Hub's shim and +// codegraph's own installer write for CoDev Code, so the three writers treat +// each other's work as already done. +const SERVER_ENTRY = { + type: "local", + command: ["codegraph", "serve", "--mcp"], + enabled: true, +} + +// Enable needs a resolvable `codegraph` binary (and probes npm when it is +// missing), so these tests seed fake executables on PATH — posix shell +// scripts, hence the win32 skip. Disable is pure config editing and runs on +// every platform. +const describePosix = process.platform === "win32" ? describe.skip : describe + +// The missing-binary test only works when the host has no real codegraph on +// PATH — the child inherits the tail of PATH so the harness can still resolve +// `bun`, and a real codegraph there would short-circuit the install probe. +const describeNoRealCodegraph = process.platform === "win32" || Bun.which("codegraph") ? describe.skip : describe + +const FAKE_CODEGRAPH = '#!/bin/sh\nif [ "$1" = "init" ]; then mkdir -p .codegraph; fi\nexit 0\n' + +async function seedBin(home: string, name: string, script: string) { + const dir = path.join(home, "bin") + await mkdir(dir, { recursive: true }) + const file = path.join(dir, name) + await writeFile(file, script) + await chmod(file, 0o755) + return dir +} + +function configPath(home: string, name = "codev.json") { + return path.join(home, ".config", "codev", name) +} + +describePosix("codev codegraph enable (subprocess)", () => { + cliIt.concurrent( + "wires the MCP server and indexes the project with --yes", + ({ home, opencode }) => + Effect.gen(function* () { + const bin = yield* Effect.promise(() => seedBin(home, "codegraph", FAKE_CODEGRAPH)) + const result = yield* opencode.spawn(["codegraph", "enable", "--yes"], { + env: { PATH: `${bin}:${process.env.PATH ?? ""}` }, + }) + opencode.expectExit(result, 0) + + const config = yield* Effect.promise(() => Bun.file(configPath(home)).json()) + expect(config.mcp.codegraph).toEqual(SERVER_ENTRY) + // --yes accepted the indexing prompt: the fake binary created + // .codegraph in the spawn cwd (home). + expect(existsSync(path.join(home, ".codegraph"))).toBe(true) + }), + 60_000, + ) + + cliIt.concurrent( + "is idempotent — a second run reports already configured and leaves the file byte-identical", + ({ home, opencode }) => + Effect.gen(function* () { + const bin = yield* Effect.promise(() => seedBin(home, "codegraph", FAKE_CODEGRAPH)) + const env = { PATH: `${bin}:${process.env.PATH ?? ""}` } + opencode.expectExit(yield* opencode.spawn(["codegraph", "enable", "--yes"], { env }), 0) + const before = yield* Effect.promise(() => Bun.file(configPath(home)).text()) + + const second = yield* opencode.spawn(["codegraph", "enable", "--yes"], { env }) + opencode.expectExit(second, 0) + expect(second.stdout).toContain("already configured") + expect(yield* Effect.promise(() => Bun.file(configPath(home)).text())).toBe(before) + }), + 60_000, + ) + + cliIt.concurrent( + "edits an existing codev.jsonc in place, preserving comments and sibling servers", + ({ home, opencode }) => + Effect.gen(function* () { + const bin = yield* Effect.promise(() => seedBin(home, "codegraph", FAKE_CODEGRAPH)) + const jsonc = configPath(home, "codev.jsonc") + yield* Effect.promise(async () => { + await mkdir(path.dirname(jsonc), { recursive: true }) + await writeFile( + jsonc, + `{ + // keep me + "mcp": { + "mine": { "type": "local", "command": ["mine"], "enabled": true } + } +} +`, + ) + }) + + const result = yield* opencode.spawn(["codegraph", "enable"], { + env: { PATH: `${bin}:${process.env.PATH ?? ""}` }, + }) + opencode.expectExit(result, 0) + + const text = yield* Effect.promise(() => Bun.file(jsonc).text()) + expect(text).toContain("// keep me") + expect(text).toContain('"mine"') + expect(text).toContain('"codegraph"') + // The existing .jsonc is the write target — no shadowing codev.json. + expect(existsSync(configPath(home))).toBe(false) + }), + 60_000, + ) + + cliIt.concurrent( + "skips indexing without --yes in a non-interactive run and prints the later hint", + ({ home, opencode }) => + Effect.gen(function* () { + const bin = yield* Effect.promise(() => seedBin(home, "codegraph", FAKE_CODEGRAPH)) + const result = yield* opencode.spawn(["codegraph", "enable"], { + env: { PATH: `${bin}:${process.env.PATH ?? ""}` }, + }) + opencode.expectExit(result, 0) + expect(result.stdout).toContain("codegraph init") + expect(existsSync(path.join(home, ".codegraph"))).toBe(false) + }), + 60_000, + ) +}) + +describeNoRealCodegraph("codev codegraph enable — missing binary (subprocess)", () => { + cliIt.concurrent( + "attempts an npm install and still wires when the binary stays unresolvable", + ({ home, opencode }) => + Effect.gen(function* () { + // A fake npm that "succeeds" without installing anything: enable + // trusts the exit code, warns that codegraph isn't on PATH, and + // wires the entry anyway (it works once PATH is fixed). + const bin = yield* Effect.promise(() => seedBin(home, "npm", "#!/bin/sh\nexit 0\n")) + const result = yield* opencode.spawn(["codegraph", "enable"], { + env: { PATH: `${bin}:${process.env.PATH ?? ""}` }, + }) + opencode.expectExit(result, 0) + expect(result.stdout).toContain("not on PATH") + + const config = yield* Effect.promise(() => Bun.file(configPath(home)).json()) + expect(config.mcp.codegraph).toEqual(SERVER_ENTRY) + }), + 60_000, + ) +}) + +describe("codev codegraph disable (subprocess)", () => { + cliIt.concurrent( + "removes the entry and drops an emptied mcp wrapper", + ({ home, opencode }) => + Effect.gen(function* () { + const file = configPath(home) + yield* Effect.promise(async () => { + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, JSON.stringify({ theme: "dark", mcp: { codegraph: SERVER_ENTRY } }, null, 2)) + }) + + const result = yield* opencode.spawn(["codegraph", "disable"]) + opencode.expectExit(result, 0) + + const config = yield* Effect.promise(() => Bun.file(file).json()) + expect(config.mcp).toBeUndefined() + expect(config.theme).toBe("dark") + }), + 60_000, + ) + + cliIt.concurrent( + "preserves sibling servers", + ({ home, opencode }) => + Effect.gen(function* () { + const file = configPath(home) + yield* Effect.promise(async () => { + await mkdir(path.dirname(file), { recursive: true }) + await writeFile( + file, + JSON.stringify( + { + mcp: { + codegraph: SERVER_ENTRY, + mine: { type: "local", command: ["mine"], enabled: true }, + }, + }, + null, + 2, + ), + ) + }) + + const result = yield* opencode.spawn(["codegraph", "disable"]) + opencode.expectExit(result, 0) + + const config = yield* Effect.promise(() => Bun.file(file).json()) + expect(config.mcp.codegraph).toBeUndefined() + expect(config.mcp.mine.command).toEqual(["mine"]) + }), + 60_000, + ) + + cliIt.concurrent( + "no-ops when nothing is wired", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["codegraph", "disable"]) + opencode.expectExit(result, 0) + expect(result.stdout).toContain("not configured") + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index deb3a7727b..29fccf0cd7 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -41,6 +41,23 @@ Options: --pure run without external plugins [boolean]" `; +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev codegraph --help 1`] = ` +"codev codegraph + +manage the CodeGraph code-intelligence MCP server + +Commands: + codev codegraph enable install CodeGraph and wire its MCP server into codev + codev codegraph disable remove the CodeGraph MCP server from codev + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean]" +`; + exports[`codev CLI help-text snapshots every documented command emits stable help text: codev configure --help 1`] = ` "codev configure diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index b846547b6a..553049e5a1 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -45,6 +45,7 @@ function normalize(text: string): string { const TOP_LEVEL = [ "acp", "mcp", + "codegraph", "configure", "attach", "run",