fix(installer): opencode .jsonc + AGENTS.md (0.7.8) (#163)
* release: 0.7.7 (multi-agent installer — Cursor, Codex, opencode) * fix(installer): opencode .jsonc + AGENTS.md (0.7.8) v0.7.7 wrote ~/.config/opencode/opencode.json, but opencode reads opencode.jsonc by default — so the codegraph MCP entry never appeared in any opencode session. Also installs AGENTS.md so opencode's model reaches for codegraph_* tools instead of native Grep. - Prefer existing .jsonc, fall back to .json, default new installs to .jsonc. - Surgical edits via jsonc-parser preserve user comments and formatting across install / re-install / uninstall round-trips. - Install AGENTS.md (global ~/.config/opencode/AGENTS.md, local ./AGENTS.md) with the shared INSTRUCTIONS_TEMPLATE — same marker-delimited approach Codex uses. - +9 opencode-specific tests covering filename precedence, comment preservation, AGENTS.md install + sibling-content preservation, uninstall reverses both files. 575/575 tests pass. Hand-verified end-to-end: opencode session calls codegraph_node + codegraph_callers for a structural query, zero Grep calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: overhaul CLAUDE.md and add scripts/release.sh + Cursor rules file Replaces the old Claude-only CLAUDE.md with a comprehensive guide covering the full project architecture, multi-agent installer, test conventions, NodeKind/EdgeKind reference, and release workflow. Key additions: - Documents the layered pipeline, all module paths, and the multi-target installer (targets/, registry.ts, AgentTarget interface). - Adds the Cursor `--path` quirk and the "update all three surfaces" rule when changing MCP tool guidance. - Documents `npm run eval`, `test:eval`, and the full set of build/test commands including single-file patterns. - `scripts/release.sh` — idempotent bash script that tags the current commit, pushes the tag, and creates a GitHub Release whose notes are extracted from the matching `## [X.Y.Z]` block in CHANGELOG.md. Safe to re-run after partial failure. - `.cursor/rules/codegraph.mdc` — Cursor-specific agent instructions (tool decision table, rules of thumb, index-lag warning) written by the installer and kept in sync with server-instructions.ts and instructions-template.ts. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7d87126ee8
commit
58c1414ce5
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: CodeGraph MCP usage guide — when to use which tool
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
<!-- CODEGRAPH_START -->
|
||||||
|
## CodeGraph
|
||||||
|
|
||||||
|
This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot.
|
||||||
|
|
||||||
|
### When to prefer codegraph over native search
|
||||||
|
|
||||||
|
Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open.
|
||||||
|
|
||||||
|
| Question | Tool |
|
||||||
|
|---|---|
|
||||||
|
| "Where is X defined?" / "Find symbol named X" | `codegraph_search` |
|
||||||
|
| "What calls function Y?" | `codegraph_callers` |
|
||||||
|
| "What does Y call?" | `codegraph_callees` |
|
||||||
|
| "What would break if I changed Z?" | `codegraph_impact` |
|
||||||
|
| "Show me Y's signature / source / docstring" | `codegraph_node` |
|
||||||
|
| "Give me focused context for a task/area" | `codegraph_context` |
|
||||||
|
| "Survey an unfamiliar module/topic" | `codegraph_explore` |
|
||||||
|
| "What files exist under path/" | `codegraph_files` |
|
||||||
|
| "Is the index healthy?" | `codegraph_status` |
|
||||||
|
|
||||||
|
### Rules of thumb
|
||||||
|
|
||||||
|
- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
|
||||||
|
- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call.
|
||||||
|
- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call.
|
||||||
|
- **`codegraph_explore` is the heavy hitter** for unfamiliar areas — it returns full source from all relevant files in one call, but is token-heavy. If your harness supports parallel subagents (e.g., Claude Code's Task tool), spawn one for explore-class questions to keep main session context clean.
|
||||||
|
- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn.
|
||||||
|
|
||||||
|
### If `.codegraph/` doesn't exist
|
||||||
|
|
||||||
|
The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"*
|
||||||
|
<!-- CODEGRAPH_END -->
|
||||||
@@ -7,6 +7,29 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
|
|||||||
This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||||
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.7.8] - 2026-05-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **opencode**: install actually wires up the MCP server now. v0.7.7 wrote
|
||||||
|
`~/.config/opencode/opencode.json`, but opencode reads `opencode.jsonc` by
|
||||||
|
default — so the `codegraph` entry never showed up in any opencode session.
|
||||||
|
The installer now prefers an existing `.jsonc`, falls back to `.json` when
|
||||||
|
only that exists, and creates `.jsonc` for greenfield installs. **Re-run
|
||||||
|
`codegraph install --target=opencode` after upgrading** so the entry lands
|
||||||
|
in the file opencode actually reads.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **opencode**: installer now writes `AGENTS.md` (global
|
||||||
|
`~/.config/opencode/AGENTS.md`, local `./AGENTS.md`) with the same
|
||||||
|
codegraph usage guidance the other agents already received. Without it,
|
||||||
|
opencode's model would call native `Grep` instead of the `codegraph_*`
|
||||||
|
tools it could see in its MCP list.
|
||||||
|
- User comments and formatting in `opencode.jsonc` survive install /
|
||||||
|
re-install / uninstall round-trips — surgical edits via `jsonc-parser`
|
||||||
|
rather than full-file rewrites.
|
||||||
|
|
||||||
|
[0.7.8]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.8
|
||||||
|
|
||||||
## [0.7.7] - 2026-05-17
|
## [0.7.7] - 2026-05-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,192 +4,134 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
CodeGraph is a local-first code intelligence system that builds a semantic knowledge graph from any codebase. It provides structural understanding of code relationships using tree-sitter for AST parsing and SQLite for storage.
|
CodeGraph is a local-first code intelligence library + CLI + MCP server. It parses any supported codebase with tree-sitter, stores symbols/edges/files in SQLite (FTS5), and exposes a knowledge graph to AI agents (Claude Code, Cursor, Codex CLI, opencode) over MCP. Per-project data lives in `.codegraph/`. Extraction is deterministic — derived from AST, not LLM-summarized.
|
||||||
|
|
||||||
**Key characteristics:**
|
Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer, indexer, and MCP server.
|
||||||
- Headless library (no UI) - purely an API
|
|
||||||
- Node.js runtime (works standalone, in Electron, or any Node environment)
|
|
||||||
- Per-project data stored in `.codegraph/` directory
|
|
||||||
- Deterministic extraction from AST, not AI-generated summaries
|
|
||||||
|
|
||||||
## Build and Development Commands
|
## Build, Test, Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
npm run build # tsc + copy schema.sql and *.wasm into dist/; chmods dist/bin/codegraph.js
|
||||||
npm run build # Compile TypeScript and copy assets
|
npm run dev # tsc --watch
|
||||||
|
npm run clean # rm -rf dist
|
||||||
|
|
||||||
# Test
|
npm test # vitest run (all)
|
||||||
npm test # Run all tests once
|
npm run test:watch
|
||||||
npm run test:watch # Run tests in watch mode
|
npm run test:eval # only __tests__/evaluation/
|
||||||
|
npm run eval # build then run __tests__/evaluation/runner.ts via tsx
|
||||||
|
|
||||||
# Clean
|
npm run cli # build then run the local dist binary
|
||||||
npm run clean # Remove dist/ directory
|
|
||||||
|
# Single test file / pattern
|
||||||
|
npx vitest run __tests__/installer-targets.test.ts
|
||||||
|
npx vitest run __tests__/extraction.test.ts -t "TypeScript"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running a Single Test
|
`copy-assets` (called from `build`) copies `src/db/schema.sql` and all `src/extraction/wasm/*.wasm` files into `dist/`. **Any new SQL or grammar wasm must be copied or it won't ship.**
|
||||||
|
|
||||||
```bash
|
Node engines: `>=18.0.0 <25.0.0`. There is a hard exit on Node 25.x (see `src/bin/node-version-check.ts`).
|
||||||
npx vitest run __tests__/extraction.test.ts # Run specific test file
|
|
||||||
npx vitest run __tests__/extraction.test.ts -t "TypeScript" # Run tests matching pattern
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Core Module Structure
|
### Layered pipeline
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
files → ExtractionOrchestrator (tree-sitter) → DB (nodes/edges/files)
|
||||||
├── index.ts # Main CodeGraph class - public API entry point
|
↓
|
||||||
├── types.ts # All TypeScript interfaces and types
|
ReferenceResolver (imports, name-matching, framework patterns)
|
||||||
├── db/ # SQLite database layer
|
↓
|
||||||
│ ├── index.ts # DatabaseConnection class
|
GraphQueryManager / GraphTraverser (callers, callees, impact)
|
||||||
│ ├── queries.ts # QueryBuilder with prepared statements
|
↓
|
||||||
│ └── schema.sql # Table definitions with FTS5 search
|
ContextBuilder (markdown/JSON for AI consumption)
|
||||||
├── extraction/ # Tree-sitter AST parsing
|
|
||||||
│ ├── index.ts # ExtractionOrchestrator
|
|
||||||
│ ├── tree-sitter.ts # Universal parser wrapper
|
|
||||||
│ └── grammars.ts # Language detection and grammar loading
|
|
||||||
├── resolution/ # Reference resolver
|
|
||||||
│ ├── index.ts # ReferenceResolver orchestrator
|
|
||||||
│ ├── import-resolver.ts
|
|
||||||
│ ├── name-matcher.ts
|
|
||||||
│ └── frameworks/ # Framework-specific patterns (React, Express, Laravel, etc.)
|
|
||||||
├── graph/ # Graph traversal and queries
|
|
||||||
│ ├── index.ts # GraphQueryManager
|
|
||||||
│ ├── traversal.ts # GraphTraverser (BFS/DFS, impact radius)
|
|
||||||
│ └── queries.ts # High-level graph queries
|
|
||||||
├── context/ # Context building for AI assistants
|
|
||||||
│ ├── index.ts # ContextBuilder
|
|
||||||
│ └── formatter.ts # Markdown/JSON output formatting
|
|
||||||
├── sync/ # Incremental update system
|
|
||||||
│ ├── index.ts
|
|
||||||
│ └── git-hooks.ts # Post-commit hook management
|
|
||||||
├── installer/ # Interactive installer
|
|
||||||
│ ├── index.ts # Installer orchestrator
|
|
||||||
│ ├── banner.ts # ASCII art banner
|
|
||||||
│ ├── claude-md-template.ts # CLAUDE.md template generator
|
|
||||||
│ ├── config-writer.ts # Configuration file writing
|
|
||||||
│ └── prompts.ts # User prompts
|
|
||||||
├── mcp/ # Model Context Protocol server
|
|
||||||
│ ├── index.ts # MCPServer class
|
|
||||||
│ ├── tools.ts # MCP tool definitions
|
|
||||||
│ └── transport.ts # Stdio transport
|
|
||||||
└── bin/codegraph.ts # CLI entry point
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Classes
|
The public API surface is `src/index.ts` — the `CodeGraph` class wires all the layers and re-exports types. Library users only touch this file; the MCP server and CLI also drive it.
|
||||||
|
|
||||||
- **CodeGraph** (`src/index.ts`): Main entry point. Lifecycle methods (`init`, `open`, `close`), indexing (`indexAll`, `sync`), graph queries (`traverse`, `getCallGraph`, `getImpactRadius`), context building (`buildContext`)
|
### Module layout
|
||||||
|
|
||||||
- **ExtractionOrchestrator** (`src/extraction/index.ts`): Coordinates file scanning, parsing, and storing. Uses tree-sitter native bindings for each supported language
|
- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`.
|
||||||
|
- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`. Backed by `better-sqlite3` (native) when available, transparently falls back to `node-sqlite3-wasm`. `codegraph status` surfaces which backend is live; wasm is the slow path.
|
||||||
|
- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
|
||||||
|
- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges.
|
||||||
|
- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries).
|
||||||
|
- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
|
||||||
|
- `src/search/` — full-text query parser and helpers for FTS5.
|
||||||
|
- `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers.
|
||||||
|
- `src/mcp/` — MCP server (`MCPServer`, `tools.ts`, `transport.ts`). `server-instructions.ts` is what the server returns in the MCP `initialize` response — keep it in sync with the user-facing tool guidance.
|
||||||
|
- `src/installer/` — see below.
|
||||||
|
- `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`.
|
||||||
|
- `src/ui/` — terminal UI (shimmer progress, worker).
|
||||||
|
|
||||||
- **GraphTraverser** (`src/graph/traversal.ts`): BFS/DFS traversal, call graph construction, impact radius calculation, path finding
|
### NodeKind / EdgeKind
|
||||||
|
|
||||||
- **ReferenceResolver** (`src/resolution/index.ts`): Resolves unresolved references after full indexing using framework patterns, import resolution, and name matching
|
Defined in `src/types.ts`. Both extractors and resolvers must use these exact strings.
|
||||||
|
|
||||||
### Database Schema
|
- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`.
|
||||||
|
- **EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`.
|
||||||
|
|
||||||
SQLite database with:
|
### Multi-agent installer
|
||||||
- `nodes`: Code symbols (functions, classes, methods, etc.)
|
|
||||||
- `edges`: Relationships (calls, imports, extends, contains, etc.)
|
|
||||||
- `files`: Tracked source files with content hashes
|
|
||||||
- `unresolved_refs`: References pending resolution
|
|
||||||
- `nodes_fts`: FTS5 virtual table for full-text search
|
|
||||||
|
|
||||||
### Supported Languages
|
`src/installer/` is the entry point for `codegraph install` (and the bare `codegraph`/`npx @colbymchenry/codegraph` invocation). Architecture:
|
||||||
|
|
||||||
TypeScript, JavaScript, TSX, JSX, Svelte, Python, Go, Rust, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dart, Liquid, Pascal
|
- `targets/registry.ts` lists every supported agent.
|
||||||
|
- `targets/types.ts` defines the `AgentTarget` interface — adding a 5th agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location, MCP-server JSON/TOML/JSONC writing, and instructions-file path.
|
||||||
|
- Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`.
|
||||||
|
- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency.
|
||||||
|
- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips.
|
||||||
|
- `instructions-template.ts` is the agent-agnostic instructions file written to each target (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`). It explicitly says "trust codegraph results, don't re-verify with grep" — earlier versions prescribed Claude-specific "spawn an Explore agent" and confused other agents.
|
||||||
|
- `claude-md-template.ts` is the legacy Claude-only template, retained for compatibility paths.
|
||||||
|
- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex.
|
||||||
|
|
||||||
### Node and Edge Types
|
### Cursor MCP working-directory quirk
|
||||||
|
|
||||||
**NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`
|
Cursor launches MCP subprocesses with the wrong cwd and doesn't pass `rootUri` in `initialize`. The installer injects `--path` into Cursor's MCP args — absolute path for local installs, `${workspaceFolder}` for global installs. If you touch Cursor wiring, preserve this.
|
||||||
|
|
||||||
**EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`
|
### MCP server instructions
|
||||||
|
|
||||||
## CLI Usage
|
`src/mcp/server-instructions.ts` is sent back to the agent in the MCP `initialize` response. This is the *first* thing every agent sees about how to use the tools — treat it as the authoritative tool guidance and keep it in sync with `instructions-template.ts` and `.cursor/rules/codegraph.mdc`.
|
||||||
|
|
||||||
```bash
|
## Tests
|
||||||
codegraph init [path] # Initialize in project
|
|
||||||
codegraph index [path] # Full index
|
|
||||||
codegraph sync [path] # Incremental update
|
|
||||||
codegraph status [path] # Show statistics
|
|
||||||
codegraph query <search> # Search symbols
|
|
||||||
codegraph context <task> # Build context for AI
|
|
||||||
codegraph hooks install # Install git auto-sync
|
|
||||||
codegraph serve --mcp # Start MCP server
|
|
||||||
```
|
|
||||||
|
|
||||||
## MCP Tools Best Practices
|
Tests live in `__tests__/` and mirror the module they cover. Notable ones beyond the obvious:
|
||||||
|
|
||||||
Use these tools **directly in the main session** for fast code exploration (replaces the need for Explore agents in most cases):
|
- `installer-targets.test.ts` — parameterized contract suite across all 4 agent targets (see installer notes above).
|
||||||
|
- `evaluation/` — `runner.ts` + `test-cases.ts` exercise codegraph against synthetic projects and score the results; run via `npm run eval` (builds first). Not part of `npm test`.
|
||||||
|
- `sqlite-backend.test.ts` — covers native + wasm backend selection and fallback.
|
||||||
|
- `pr19-improvements.test.ts`, `frameworks-integration.test.ts` — regression coverage for specific past PRs/incidents; don't rename these, the names anchor to git history.
|
||||||
|
|
||||||
| Tool | Use For |
|
Tests create temp dirs with `fs.mkdtempSync` and clean up in `afterEach`. They write real files and exercise real SQLite — there is no DB mocking.
|
||||||
|------|---------|
|
|
||||||
| `codegraph_explore` | **Deep exploration** — comprehensive context for a topic in ONE call |
|
|
||||||
| `codegraph_context` | Quick context for a task (lighter than explore) |
|
|
||||||
| `codegraph_search` | Find symbols by name (functions, classes, types) |
|
|
||||||
| `codegraph_callers` | Find what calls a function |
|
|
||||||
| `codegraph_callees` | Find what a function calls |
|
|
||||||
| `codegraph_impact` | See what's affected by changing a symbol |
|
|
||||||
| `codegraph_node` | Get details + source code for a symbol |
|
|
||||||
|
|
||||||
### Important
|
|
||||||
CodeGraph provides **code context**, not product requirements. For new features, still ask the user about:
|
|
||||||
- UX preferences and behavior
|
|
||||||
- Edge cases and error handling
|
|
||||||
- Acceptance criteria
|
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
Releases are published to npm **and** mirrored as GitHub Releases on the
|
Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenry/codegraph/releases). `CHANGELOG.md` is the source of truth; GitHub Release notes are extracted from it.
|
||||||
[Releases page](https://github.com/colbymchenry/codegraph/releases), which is
|
|
||||||
where most users look for change history. `CHANGELOG.md` at the repo root is
|
|
||||||
the source of truth — each GitHub Release's notes are extracted from it.
|
|
||||||
|
|
||||||
### Writing changelog entries
|
### Writing changelog entries
|
||||||
|
|
||||||
When the user asks for a changelog entry for a new version:
|
When asked for an entry for a new version:
|
||||||
|
|
||||||
1. Add a new `## [X.Y.Z] - YYYY-MM-DD` block at the **top** of `CHANGELOG.md`
|
1. Add a new `## [X.Y.Z] - YYYY-MM-DD` block at the **top** of `CHANGELOG.md` (under the intro, above the previous version).
|
||||||
(directly under the intro, above the previous version).
|
2. Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — omit empty sections.
|
||||||
2. Group changes under `### Added`, `### Changed`, `### Fixed`, `### Removed`,
|
3. Write from the **user's perspective**, not the implementation's. Lead with the observable symptom or capability; mention internals only if a user needs them (e.g., to work around an existing bad install).
|
||||||
`### Deprecated`, `### Security` — only include sections that have entries.
|
4. Add the link reference at the bottom: `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`.
|
||||||
3. Write entries from the **user's perspective**, not the implementation's.
|
|
||||||
Lead with the observable symptom or capability, then mention internals only
|
|
||||||
if a user needs them (e.g., to work around an existing bad install).
|
|
||||||
4. Add the link reference at the bottom:
|
|
||||||
`[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`
|
|
||||||
|
|
||||||
### Release commands (the user runs these)
|
### Release flow (the user runs these)
|
||||||
|
|
||||||
After the changelog entry is written and the version is bumped in `package.json`:
|
After the changelog entry is written and `package.json` is bumped:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add package.json package-lock.json CHANGELOG.md
|
git add package.json package-lock.json CHANGELOG.md
|
||||||
git commit -m "release: X.Y.Z (<one-line summary>)"
|
git commit -m "release: X.Y.Z (<one-line summary>)"
|
||||||
git push
|
git push
|
||||||
|
|
||||||
npm publish
|
npm publish
|
||||||
|
./scripts/release.sh # idempotent: tags vX.Y.Z, pushes, creates GitHub Release with notes from CHANGELOG.md
|
||||||
git tag vX.Y.Z
|
|
||||||
git push origin vX.Y.Z
|
|
||||||
gh release create vX.Y.Z \
|
|
||||||
--title "vX.Y.Z" \
|
|
||||||
--notes-file <(awk '/^## \[X.Y.Z\]/,/^## \[/{ if (/^## \[/ && !/X.Y.Z/) exit; print }' CHANGELOG.md)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** run `npm publish`, `git tag`, `git push`, or `gh release create`
|
`scripts/release.sh` is safe to re-run after a partial failure — it skips steps already done (tag exists locally, tag on origin, release published). It extracts release notes from `CHANGELOG.md` by matching the `## [X.Y.Z]` block.
|
||||||
yourself — these are publish actions that affect shared state. Write the file,
|
|
||||||
hand the user the commands.
|
|
||||||
|
|
||||||
## Test Structure
|
**Do not run `npm publish`, `git push`, `git tag`, or `./scripts/release.sh` yourself** — these are publish actions on shared state. Write the file, hand the user the commands.
|
||||||
|
|
||||||
Tests are in `__tests__/` directory with files mirroring the module structure:
|
## House rules
|
||||||
- `foundation.test.ts` - Database, config, directory management
|
|
||||||
- `extraction.test.ts` - Tree-sitter parsing for all languages
|
|
||||||
- `resolution.test.ts` - Reference resolution
|
|
||||||
- `graph.test.ts` - Traversal and graph queries
|
|
||||||
- `context.test.ts` - Context building
|
|
||||||
- `sync.test.ts` - Incremental updates and git hooks
|
|
||||||
|
|
||||||
Tests use temporary directories created with `fs.mkdtempSync` and cleaned up after each test.
|
- The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently.
|
||||||
|
- When changing what the MCP tools do or how agents should use them, update **all three** of `src/mcp/server-instructions.ts`, `src/installer/instructions-template.ts`, and `.cursor/rules/codegraph.mdc` — they're written to different places but say the same thing.
|
||||||
|
- CodeGraph provides **code context**, not product requirements. For new features, ask the user about UX, edge cases, and acceptance criteria — the graph won't tell you.
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ describe('Installer targets — contract', () => {
|
|||||||
// and any target with no JSON config — they get covered
|
// and any target with no JSON config — they get covered
|
||||||
// by their own dedicated tests below.
|
// by their own dedicated tests below.
|
||||||
const paths = target.describePaths(location);
|
const paths = target.describePaths(location);
|
||||||
const jsonPath = paths.find((p) => p.endsWith('.json'));
|
// Match .json or .jsonc — opencode prefers .jsonc.
|
||||||
|
const jsonPath = paths.find((p) => /\.jsonc?$/.test(p));
|
||||||
if (!jsonPath) return;
|
if (!jsonPath) return;
|
||||||
|
|
||||||
// Seed pre-existing config.
|
// Seed pre-existing config.
|
||||||
@@ -184,6 +185,152 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
for (const f of third.files) expect(f.action).toBe('unchanged');
|
for (const f of third.files) expect(f.action).toBe('unchanged');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opencode: prefers .jsonc when both .json and .jsonc exist', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
|
||||||
|
fs.writeFileSync(path.join(dir, 'opencode.jsonc'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
|
||||||
|
|
||||||
|
const result = opencode.install('global', { autoAllow: true });
|
||||||
|
const written = result.files.find((f) => /\.jsonc$/.test(f.path))!;
|
||||||
|
expect(written).toBeDefined();
|
||||||
|
expect(written.action).not.toBe('not-found');
|
||||||
|
// The .json file is left alone.
|
||||||
|
const jsonText = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8');
|
||||||
|
expect(jsonText).not.toContain('codegraph');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: uses .json when only .json exists (no .jsonc)', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
|
||||||
|
|
||||||
|
const result = opencode.install('global', { autoAllow: true });
|
||||||
|
expect(result.files[0].path).toMatch(/opencode\.json$/);
|
||||||
|
expect(fs.existsSync(path.join(dir, 'opencode.jsonc'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: defaults to .jsonc for fresh installs (no existing file)', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const result = opencode.install('global', { autoAllow: true });
|
||||||
|
expect(result.files[0].path).toMatch(/opencode\.jsonc$/);
|
||||||
|
expect(result.files[0].action).toBe('created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: preserves line and block comments through install + idempotent re-run', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, 'opencode.jsonc');
|
||||||
|
const original = [
|
||||||
|
'{',
|
||||||
|
' // top-level note about my opencode setup',
|
||||||
|
' "$schema": "https://opencode.ai/config.json",',
|
||||||
|
' /* multi-line block comment',
|
||||||
|
' describing the providers section */',
|
||||||
|
' "providers": {',
|
||||||
|
' "anthropic": { "model": "claude-opus-4-7" } // pinned',
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(file, original);
|
||||||
|
|
||||||
|
opencode.install('global', { autoAllow: true });
|
||||||
|
const afterInstall = fs.readFileSync(file, 'utf-8');
|
||||||
|
expect(afterInstall).toContain('// top-level note about my opencode setup');
|
||||||
|
expect(afterInstall).toContain('/* multi-line block comment');
|
||||||
|
expect(afterInstall).toContain('// pinned');
|
||||||
|
expect(afterInstall).toContain('"codegraph"');
|
||||||
|
expect(afterInstall).toContain('"providers"');
|
||||||
|
|
||||||
|
// Idempotent re-run reports unchanged, file is byte-identical.
|
||||||
|
const second = opencode.install('global', { autoAllow: true });
|
||||||
|
expect(second.files[0].action).toBe('unchanged');
|
||||||
|
expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: install writes AGENTS.md with the marker-delimited codegraph block', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
opencode.install('global', { autoAllow: true });
|
||||||
|
const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md');
|
||||||
|
expect(fs.existsSync(agentsMd)).toBe(true);
|
||||||
|
const body = fs.readFileSync(agentsMd, 'utf-8');
|
||||||
|
expect(body).toContain('<!-- CODEGRAPH_START -->');
|
||||||
|
expect(body).toContain('<!-- CODEGRAPH_END -->');
|
||||||
|
expect(body).toContain('codegraph_callers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: AGENTS.md install preserves pre-existing user content outside markers', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const agentsMd = path.join(dir, 'AGENTS.md');
|
||||||
|
fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n');
|
||||||
|
|
||||||
|
opencode.install('global', { autoAllow: true });
|
||||||
|
const body = fs.readFileSync(agentsMd, 'utf-8');
|
||||||
|
expect(body).toContain('# My personal opencode instructions');
|
||||||
|
expect(body).toContain('Always respond in pirate.');
|
||||||
|
expect(body).toContain('<!-- CODEGRAPH_START -->');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: uninstall strips only the codegraph block from AGENTS.md', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const agentsMd = path.join(dir, 'AGENTS.md');
|
||||||
|
fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n');
|
||||||
|
|
||||||
|
opencode.install('global', { autoAllow: true });
|
||||||
|
opencode.uninstall('global');
|
||||||
|
|
||||||
|
const body = fs.readFileSync(agentsMd, 'utf-8');
|
||||||
|
expect(body).toContain('# My personal opencode instructions');
|
||||||
|
expect(body).toContain('Always respond in pirate.');
|
||||||
|
expect(body).not.toContain('CODEGRAPH_START');
|
||||||
|
expect(body).not.toContain('codegraph_callers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: local install writes ./opencode.jsonc and ./AGENTS.md in cwd', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const result = opencode.install('local', { autoAllow: true });
|
||||||
|
const paths = result.files.map((f) => f.path);
|
||||||
|
// macOS realpath shenanigans (/var vs /private/var) — suffix match.
|
||||||
|
expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true);
|
||||||
|
expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
|
||||||
|
const opencode = getTarget('opencode')!;
|
||||||
|
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, 'opencode.jsonc');
|
||||||
|
fs.writeFileSync(file, [
|
||||||
|
'{',
|
||||||
|
' // important comment',
|
||||||
|
' "$schema": "https://opencode.ai/config.json",',
|
||||||
|
' "mcp": {',
|
||||||
|
' "other": { "type": "local", "command": ["x"], "enabled": true }',
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
opencode.install('global', { autoAllow: true });
|
||||||
|
const afterInstall = fs.readFileSync(file, 'utf-8');
|
||||||
|
expect(afterInstall).toContain('"codegraph"');
|
||||||
|
expect(afterInstall).toContain('"other"');
|
||||||
|
|
||||||
|
opencode.uninstall('global');
|
||||||
|
const afterUninstall = fs.readFileSync(file, 'utf-8');
|
||||||
|
expect(afterUninstall).not.toContain('codegraph');
|
||||||
|
expect(afterUninstall).toContain('// important comment');
|
||||||
|
expect(afterUninstall).toContain('"other"');
|
||||||
|
});
|
||||||
|
|
||||||
it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
|
it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
|
||||||
const codex = getTarget('codex')!;
|
const codex = getTarget('codex')!;
|
||||||
codex.install('global', { autoAllow: false });
|
codex.install('global', { autoAllow: false });
|
||||||
|
|||||||
Generated
+9
-2
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@colbymchenry/codegraph",
|
"name": "@colbymchenry/codegraph",
|
||||||
"version": "0.7.7",
|
"version": "0.7.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@colbymchenry/codegraph",
|
"name": "@colbymchenry/codegraph",
|
||||||
"version": "0.7.7",
|
"version": "0.7.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.3.0",
|
"@clack/prompts": "^1.3.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"fast-string-width": "^3.0.2",
|
"fast-string-width": "^3.0.2",
|
||||||
"fast-wrap-ansi": "^0.2.0",
|
"fast-wrap-ansi": "^0.2.0",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"node-sqlite3-wasm": "^0.8.30",
|
"node-sqlite3-wasm": "^0.8.30",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"sisteransi": "^1.0.5",
|
"sisteransi": "^1.0.5",
|
||||||
@@ -1347,6 +1348,12 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonc-parser": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@colbymchenry/codegraph",
|
"name": "@colbymchenry/codegraph",
|
||||||
"version": "0.7.7",
|
"version": "0.7.8",
|
||||||
"description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.",
|
"description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"fast-string-width": "^3.0.2",
|
"fast-string-width": "^3.0.2",
|
||||||
"fast-wrap-ansi": "^0.2.0",
|
"fast-wrap-ansi": "^0.2.0",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"node-sqlite3-wasm": "^0.8.30",
|
"node-sqlite3-wasm": "^0.8.30",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"sisteransi": "^1.0.5",
|
"sisteransi": "^1.0.5",
|
||||||
|
|||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tag the current commit with the version in package.json and publish a
|
||||||
|
# matching GitHub Release whose body is the corresponding CHANGELOG.md entry.
|
||||||
|
#
|
||||||
|
# Run AFTER you have:
|
||||||
|
# - bumped package.json
|
||||||
|
# - added a `## [X.Y.Z] - YYYY-MM-DD` block at the top of CHANGELOG.md
|
||||||
|
# - committed, pushed to origin, and run `npm publish`
|
||||||
|
#
|
||||||
|
# Idempotent: safe to re-run after a partial failure. Skips steps that are
|
||||||
|
# already done (tag created, tag pushed, release published).
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/release.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
|
REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||||
|
if [ -z "${REPO}" ]; then
|
||||||
|
echo "error: could not derive owner/repo from origin remote URL" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q "^## \[${VERSION}\]" CHANGELOG.md; then
|
||||||
|
echo "error: no '## [${VERSION}]' entry found in CHANGELOG.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NOTES=$(awk -v v="${VERSION}" '
|
||||||
|
/^## \[/ {
|
||||||
|
if (p) exit
|
||||||
|
if ($0 ~ "^## \\[" v "\\]") p = 1
|
||||||
|
}
|
||||||
|
p
|
||||||
|
' CHANGELOG.md)
|
||||||
|
|
||||||
|
if [ -z "${NOTES}" ]; then
|
||||||
|
echo "error: failed to extract changelog notes for ${VERSION}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git rev-parse "${TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "✓ tag ${TAG} already exists locally"
|
||||||
|
else
|
||||||
|
echo "→ tagging ${TAG}"
|
||||||
|
git tag "${TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git ls-remote --exit-code --tags origin "${TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "✓ tag ${TAG} already on origin"
|
||||||
|
else
|
||||||
|
echo "→ pushing ${TAG} to origin"
|
||||||
|
git push origin "${TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then
|
||||||
|
echo "✓ release ${TAG} already published"
|
||||||
|
else
|
||||||
|
echo "→ creating GitHub Release ${TAG} on ${REPO}"
|
||||||
|
gh release create "${TAG}" \
|
||||||
|
--repo "${REPO}" \
|
||||||
|
--title "${TAG}" \
|
||||||
|
--notes "${NOTES}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "done: https://github.com/${REPO}/releases/tag/${TAG}"
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* opencode target.
|
* opencode target.
|
||||||
*
|
*
|
||||||
* - MCP server entry to `~/.config/opencode/opencode.json` (global,
|
* - MCP server entry to `~/.config/opencode/opencode.jsonc` (global,
|
||||||
* XDG-style; `%APPDATA%/opencode/opencode.json` on Windows) or
|
* XDG-style; `%APPDATA%/opencode/opencode.jsonc` on Windows) or
|
||||||
* `./opencode.json` (local).
|
* `./opencode.jsonc` (local). Falls back to `opencode.json` when a
|
||||||
* - No instructions file built in (opencode doesn't have a
|
* `.json` file already exists; defaults new installs to `.jsonc`
|
||||||
* conventional agent-rules surface as of 2026-05).
|
* because that's what opencode itself creates on first run.
|
||||||
|
* - Instructions to `~/.config/opencode/AGENTS.md` (global) or
|
||||||
|
* `./AGENTS.md` (local). opencode reads AGENTS.md for agent
|
||||||
|
* instructions — same convention Codex CLI uses.
|
||||||
* - No permissions concept.
|
* - No permissions concept.
|
||||||
*
|
*
|
||||||
* Config shape uses opencode's wrapper:
|
* Config shape uses opencode's wrapper:
|
||||||
@@ -17,11 +20,16 @@
|
|||||||
* The shape differs from Claude/Cursor — opencode uses `mcp.<name>`
|
* The shape differs from Claude/Cursor — opencode uses `mcp.<name>`
|
||||||
* (not `mcpServers`), takes `command` as a string array combining
|
* (not `mcpServers`), takes `command` as a string array combining
|
||||||
* binary + args, and includes an explicit `enabled` flag.
|
* binary + args, and includes an explicit `enabled` flag.
|
||||||
|
*
|
||||||
|
* Reads + writes go through `jsonc-parser` so any `//` and `/* *\/`
|
||||||
|
* comments the user has added to their `.jsonc` survive idempotent
|
||||||
|
* re-runs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
|
||||||
import {
|
import {
|
||||||
AgentTarget,
|
AgentTarget,
|
||||||
DetectionResult,
|
DetectionResult,
|
||||||
@@ -30,10 +38,16 @@ import {
|
|||||||
WriteResult,
|
WriteResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
import {
|
import {
|
||||||
|
atomicWriteFileSync,
|
||||||
jsonDeepEqual,
|
jsonDeepEqual,
|
||||||
readJsonFile,
|
removeMarkedSection,
|
||||||
writeJsonFile,
|
replaceOrAppendMarkedSection,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import {
|
||||||
|
CODEGRAPH_SECTION_END,
|
||||||
|
CODEGRAPH_SECTION_START,
|
||||||
|
INSTRUCTIONS_TEMPLATE,
|
||||||
|
} from '../instructions-template';
|
||||||
|
|
||||||
function globalConfigDir(): string {
|
function globalConfigDir(): string {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -47,10 +61,39 @@ function globalConfigDir(): string {
|
|||||||
return path.join(xdg, 'opencode');
|
return path.join(xdg, 'opencode');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configBaseDir(loc: Location): string {
|
||||||
|
return loc === 'global' ? globalConfigDir() : process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick existing .jsonc, then .json, default to .jsonc for new files.
|
||||||
|
// opencode auto-creates .jsonc on first run, so that's the dominant
|
||||||
|
// real-world case and the sensible default for greenfield installs.
|
||||||
function configPath(loc: Location): string {
|
function configPath(loc: Location): string {
|
||||||
return loc === 'global'
|
const dir = configBaseDir(loc);
|
||||||
? path.join(globalConfigDir(), 'opencode.json')
|
const jsonc = path.join(dir, 'opencode.jsonc');
|
||||||
: path.join(process.cwd(), 'opencode.json');
|
const json = path.join(dir, 'opencode.json');
|
||||||
|
if (fs.existsSync(jsonc)) return jsonc;
|
||||||
|
if (fs.existsSync(json)) return json;
|
||||||
|
return jsonc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function instructionsPath(loc: Location): string {
|
||||||
|
return path.join(configBaseDir(loc), 'AGENTS.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfigText(file: string): string {
|
||||||
|
if (!fs.existsSync(file)) return '';
|
||||||
|
return fs.readFileSync(file, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(text: string): Record<string, any> {
|
||||||
|
if (!text.trim()) return {};
|
||||||
|
const errors: any[] = [];
|
||||||
|
const result = parseJsonc(text, errors, { allowTrailingComma: true });
|
||||||
|
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return result as Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } {
|
function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } {
|
||||||
@@ -61,6 +104,8 @@ function getOpencodeServerEntry(): { type: string; command: string[]; enabled: b
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
|
||||||
|
|
||||||
class OpencodeTarget implements AgentTarget {
|
class OpencodeTarget implements AgentTarget {
|
||||||
readonly id = 'opencode' as const;
|
readonly id = 'opencode' as const;
|
||||||
readonly displayName = 'opencode';
|
readonly displayName = 'opencode';
|
||||||
@@ -72,7 +117,7 @@ class OpencodeTarget implements AgentTarget {
|
|||||||
|
|
||||||
detect(loc: Location): DetectionResult {
|
detect(loc: Location): DetectionResult {
|
||||||
const file = configPath(loc);
|
const file = configPath(loc);
|
||||||
const config = readJsonFile(file);
|
const config = parseConfig(readConfigText(file));
|
||||||
const alreadyConfigured = !!config.mcp?.codegraph;
|
const alreadyConfigured = !!config.mcp?.codegraph;
|
||||||
const installed = loc === 'global'
|
const installed = loc === 'global'
|
||||||
? fs.existsSync(globalConfigDir())
|
? fs.existsSync(globalConfigDir())
|
||||||
@@ -81,39 +126,48 @@ class OpencodeTarget implements AgentTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||||
const file = configPath(loc);
|
const files: WriteResult['files'] = [];
|
||||||
const existing = readJsonFile(file);
|
files.push(writeMcpEntry(loc));
|
||||||
const before = existing.mcp?.codegraph;
|
files.push(writeInstructionsEntry(loc));
|
||||||
const after = getOpencodeServerEntry();
|
return { files };
|
||||||
|
|
||||||
if (jsonDeepEqual(before, after)) {
|
|
||||||
return { files: [{ path: file, action: 'unchanged' }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = !fs.existsSync(file);
|
|
||||||
if (!existing.$schema) existing.$schema = 'https://opencode.ai/config.json';
|
|
||||||
if (!existing.mcp) existing.mcp = {};
|
|
||||||
existing.mcp.codegraph = after;
|
|
||||||
writeJsonFile(file, existing);
|
|
||||||
return {
|
|
||||||
files: [{ path: file, action: created ? 'created' : 'updated' }],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uninstall(loc: Location): WriteResult {
|
uninstall(loc: Location): WriteResult {
|
||||||
|
const files: WriteResult['files'] = [];
|
||||||
const file = configPath(loc);
|
const file = configPath(loc);
|
||||||
const config = readJsonFile(file);
|
|
||||||
if (!config.mcp?.codegraph) {
|
if (!fs.existsSync(file)) {
|
||||||
return { files: [{ path: file, action: 'not-found' }] };
|
files.push({ path: file, action: 'not-found' });
|
||||||
|
} else {
|
||||||
|
const text = readConfigText(file);
|
||||||
|
const config = parseConfig(text);
|
||||||
|
if (!config.mcp?.codegraph) {
|
||||||
|
files.push({ path: file, action: 'not-found' });
|
||||||
|
} else {
|
||||||
|
// Drop our key surgically. Leaves siblings + comments untouched.
|
||||||
|
let edits = modify(text, ['mcp', 'codegraph'], undefined, {
|
||||||
|
formattingOptions: FORMATTING,
|
||||||
|
});
|
||||||
|
let updated = applyEdits(text, edits);
|
||||||
|
|
||||||
|
// If `mcp` is now an empty object, drop the wrapper too.
|
||||||
|
const afterParsed = parseConfig(updated);
|
||||||
|
if (afterParsed.mcp && typeof afterParsed.mcp === 'object' &&
|
||||||
|
Object.keys(afterParsed.mcp).length === 0) {
|
||||||
|
edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING });
|
||||||
|
updated = applyEdits(updated, edits);
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicWriteFileSync(file, updated);
|
||||||
|
files.push({ path: file, action: 'removed' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delete config.mcp.codegraph;
|
|
||||||
if (Object.keys(config.mcp).length === 0) {
|
const instr = instructionsPath(loc);
|
||||||
delete config.mcp;
|
const instrAction = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
|
||||||
}
|
files.push({ path: instr, action: instrAction });
|
||||||
// If the file is now degenerate (only $schema or empty), leave it
|
|
||||||
// — the user may have other config we shouldn't nuke.
|
return { files };
|
||||||
writeJsonFile(file, config);
|
|
||||||
return { files: [{ path: file, action: 'removed' }] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
printConfig(loc: Location): string {
|
printConfig(loc: Location): string {
|
||||||
@@ -126,8 +180,65 @@ class OpencodeTarget implements AgentTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describePaths(loc: Location): string[] {
|
describePaths(loc: Location): string[] {
|
||||||
return [configPath(loc)];
|
return [configPath(loc), instructionsPath(loc)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
|
||||||
|
const file = configPath(loc);
|
||||||
|
const existed = fs.existsSync(file);
|
||||||
|
let text = readConfigText(file);
|
||||||
|
|
||||||
|
// Seed a minimal opencode config when the file is brand-new so
|
||||||
|
// the result is a complete, schema-tagged file (not just a bare
|
||||||
|
// `{ "mcp": {...} }`).
|
||||||
|
if (!text.trim()) {
|
||||||
|
text = '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = parseConfig(text);
|
||||||
|
const before = config.mcp?.codegraph;
|
||||||
|
const after = getOpencodeServerEntry();
|
||||||
|
|
||||||
|
if (jsonDeepEqual(before, after)) {
|
||||||
|
return { path: file, action: 'unchanged' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add $schema if the user's existing file is missing it.
|
||||||
|
if (!config.$schema) {
|
||||||
|
const schemaEdits = modify(text, ['$schema'], 'https://opencode.ai/config.json', {
|
||||||
|
formattingOptions: FORMATTING,
|
||||||
|
});
|
||||||
|
text = applyEdits(text, schemaEdits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surgical edit — preserves comments, formatting, and order of
|
||||||
|
// every key we don't touch.
|
||||||
|
const edits = modify(text, ['mcp', 'codegraph'], after, {
|
||||||
|
formattingOptions: FORMATTING,
|
||||||
|
});
|
||||||
|
const updated = applyEdits(text, edits);
|
||||||
|
atomicWriteFileSync(file, updated);
|
||||||
|
|
||||||
|
return { path: file, action: existed ? 'updated' : 'created' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInstructionsEntry(loc: Location): WriteResult['files'][number] {
|
||||||
|
const file = instructionsPath(loc);
|
||||||
|
const dir = path.dirname(file);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const action = replaceOrAppendMarkedSection(
|
||||||
|
file,
|
||||||
|
INSTRUCTIONS_TEMPLATE,
|
||||||
|
CODEGRAPH_SECTION_START,
|
||||||
|
CODEGRAPH_SECTION_END,
|
||||||
|
);
|
||||||
|
const mapped: 'created' | 'updated' | 'unchanged' =
|
||||||
|
action === 'created' ? 'created'
|
||||||
|
: action === 'unchanged' ? 'unchanged'
|
||||||
|
: 'updated';
|
||||||
|
return { path: file, action: mapped };
|
||||||
|
}
|
||||||
|
|
||||||
export const opencodeTarget: AgentTarget = new OpencodeTarget();
|
export const opencodeTarget: AgentTarget = new OpencodeTarget();
|
||||||
|
|||||||
Reference in New Issue
Block a user