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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
235 changes: 235 additions & 0 deletions packages/opencode/src/cli/cmd/codegraph.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).mcp
if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return undefined
return mcp as Record<string, unknown>
}

// 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.")
}
2 changes: 2 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading