From 3b383ccdb8dc55b0f2433b0c20ee6debf929efd7 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 05:09:17 -0500 Subject: [PATCH 1/4] docs(context-refresh): add phase-3 kickoff checklist and release verification gates --- PUBLISHING.md | 19 +++++++++++++++++++ docs/context-refresh-resume.md | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/PUBLISHING.md b/PUBLISHING.md index 92a6a47..e140581 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -157,6 +157,25 @@ Confirm: - behavior matches docs and exit-code contract. - mixed-stack repos are detected correctly, or can be corrected with explicit `--preset fullstack`. +Release completion gates (required before declaring release complete): + +1. GitHub artifacts: + - release exists for the tag (for example `v0.16.0`) + - `release.yml` run for the tag is `success` +2. npm publication: + - all 12 workspace packages are resolvable at the target version +3. tracker hygiene: + - release-scoped issues/PR follow-ups are closed or explicitly carried forward + +Example npm verification command for a target version: + +```bash +VERSION=0.16.0 +for p in types core adf git classify validate drift blast surface policies ci cli; do + npm view "@stackbilt/${p}@${VERSION}" version +done +``` + ## Release Artifacts After successful publish: diff --git a/docs/context-refresh-resume.md b/docs/context-refresh-resume.md index a2af206..94bafa5 100644 --- a/docs/context-refresh-resume.md +++ b/docs/context-refresh-resume.md @@ -51,6 +51,32 @@ To close `#155`, complete: - TTL tuning guidance - example hook wiring +## Phase 3 Kickoff Checklist (v0.16.0+) + +Track this as the active implementation sequence for `#155`: + +1. `serve` wiring: + - add MCP tool contract for `charter_context` + - support read-only mode (`refresh=false`) and refresh mode (`refresh=true`) +2. runtime behavior: + - return structured JSON from `.ai/context.snapshot.json` when available + - on missing snapshot + `refresh=false`, return explicit actionable error + - on `refresh=true`, invoke existing `context-refresh` pipeline and return refreshed snapshot +3. tests: + - tool reads existing snapshot + - tool refresh path returns updated snapshot + - missing snapshot behavior is deterministic and documented +4. docs: + - `docs/cli-reference.md` tool contract + - `README.md` session-start flow using `--once` and TTL guidance + +Definition of done for Phase 3: + +- `charter serve` exposes `charter_context` with stable JSON output +- end-to-end tests pass for read/refresh/error paths +- docs include a copy/pasteable session-start hook example +- issue `#155` can close with no remaining TODOs + ## Suggested Restart Plan (Next Session) 1. Add `charter_context` tool registration in `packages/cli/src/commands/serve.ts` From 708eeae532ff9b1df19e28db954c967ad14f1729 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 05:17:36 -0500 Subject: [PATCH 2/4] feat(serve): add charter_context MCP tool and codex setup guidance --- README.md | 17 ++- docs/cli-reference.md | 5 +- .../cli/src/__tests__/serve-context.test.ts | 80 +++++++++++++ packages/cli/src/commands/bootstrap.ts | 4 +- packages/cli/src/commands/serve.ts | 109 +++++++++++++++++- packages/cli/src/index.ts | 2 +- 6 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/__tests__/serve-context.test.ts diff --git a/README.md b/README.md index 1459b47..6ac9e9f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Detects your stack, scaffolds `.ai/`, migrates existing CLAUDE.md / `.cursorrule - **Measurable constraints** — per-module metric ceilings (LOC, complexity, bloat) validated at commit time and in CI. - **Codebase analysis** — `charter blast` reverse-dependency graphs, `charter surface` route/schema fingerprints. Deterministic, zero runtime deps. - **Drift + audit** — anti-pattern scans, commit governance, CI-ready exit codes. -- **MCP server** — `charter serve` exposes project context to Claude Code. +- **MCP server** — `charter serve` exposes project context to Claude Code, Codex, and Cursor. Compose with the broader [Stackbilt ecosystem](https://github.com/Stackbilt-dev) — [audit-chain](https://github.com/Stackbilt-dev/audit-chain), [worker-observability](https://github.com/Stackbilt-dev/worker-observability), [llm-providers](https://github.com/Stackbilt-dev/llm-providers), [adf](https://www.npmjs.com/package/@stackbilt/adf) — when you need them. @@ -84,7 +84,7 @@ METRICS [load-bearing]: `charter adf evidence --auto-measure` validates these live. Pre-commit hooks reject code that exceeds ceilings. CI workflows gate merges. Charter enforces its own rules on its own codebase -- every commit. -### MCP server for Claude Code +### MCP server for Claude Code and Codex ```json { @@ -99,6 +99,19 @@ METRICS [load-bearing]: Claude Code can query `getProjectContext`, `getArchitecturalDecisions`, `getProjectState`, and `getRecentChanges` directly. +Codex/Cursor can use the same MCP wiring via `.mcp.json`: + +```json +{ + "mcpServers": { + "charter": { + "command": "npx", + "args": ["@stackbilt/cli", "serve", "--ai-dir", "/absolute/path/to/.ai"] + } + } +} +``` + The `charter_brief` MCP tool composes routes, hotspots, and governance into a single pre-digested brief — call it first in any agent session to skip 15-30 cold-boot discovery calls. For live session continuity snapshots, use `charter context-refresh` to produce `.ai/context.adf` + `.ai/context.snapshot.json` (with optional GitHub source and TTL controls). diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 515e0bb..0d98292 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -341,12 +341,12 @@ npx charter blast src/foo.ts --root ./packages/server # scan a subdirector ### charter serve -Expose ADF project context as an MCP server over stdio, for use with Claude Code. +Expose ADF project context as an MCP server over stdio, for use with Claude Code, Codex, and Cursor. ```bash npx charter serve # stdio MCP server (default) npx charter serve --ai-dir /abs/path/.ai # explicit ADF directory -npx charter serve --name "my-project" # override the server name shown in Claude Code +npx charter serve --name "my-project" # override the server name shown in MCP clients ``` - `--ai-dir ` — path to the `.ai/` ADF directory (default: `.ai`). **Always resolved to an absolute path at startup.** When wiring in `.mcp.json`, use an absolute path or a path relative to the project root — relative paths are resolved against the working directory at spawn time, which may differ from the project root in multi-repo setups. @@ -381,6 +381,7 @@ If startup validation fails (missing `.ai/` directory or `manifest.adf`), `chart | Tool | Description | |------|-------------| | `charter_brief` | **Call first.** Pre-digested repo brief — routes, hotspots, governance. | +| `charter_context` | Session continuity snapshot reader/refresher (`.ai/context.snapshot.json`). Use `refresh=true` to run `context-refresh` before reading. | | `getProjectContext` | ADF bundle resolved for a given task or trigger keywords. | | `getProjectState` | Constraint validation results across all loaded modules. | | `getArchitecturalDecisions` | Load-bearing constraints from `core.adf`. | diff --git a/packages/cli/src/__tests__/serve-context.test.ts b/packages/cli/src/__tests__/serve-context.test.ts new file mode 100644 index 0000000..2a16fea --- /dev/null +++ b/packages/cli/src/__tests__/serve-context.test.ts @@ -0,0 +1,80 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CLIOptions } from '../index'; +import { CLIError } from '../index'; +import { loadCharterContextSnapshot } from '../commands/serve'; + +const baseOptions: CLIOptions = { + configPath: '.charter', + format: 'text', + ciMode: false, + yes: false, +}; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-serve-context-test-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('loadCharterContextSnapshot', () => { + it('returns existing snapshot when refresh is false', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + const aiDir = path.join(tmp, '.ai'); + fs.mkdirSync(aiDir, { recursive: true }); + const snapshotPath = path.join(aiDir, 'context.snapshot.json'); + fs.writeFileSync(snapshotPath, JSON.stringify({ version: 1, generatedAt: '2026-01-01T00:00:00Z' }), 'utf8'); + + const result = await loadCharterContextSnapshot(baseOptions, aiDir, { refresh: false }); + expect(result.refreshed).toBe(false); + expect(result.snapshotPath).toBe('.ai/context.snapshot.json'); + expect((result.snapshot as { version: number }).version).toBe(1); + }); + + it('throws actionable error when snapshot is missing and refresh is false', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + const aiDir = path.join(tmp, '.ai'); + fs.mkdirSync(aiDir, { recursive: true }); + + await expect( + loadCharterContextSnapshot(baseOptions, aiDir, { refresh: false }), + ).rejects.toThrowError(CLIError); + await expect( + loadCharterContextSnapshot(baseOptions, aiDir, { refresh: false }), + ).rejects.toThrow(/refresh=true/); + }); + + it('refreshes snapshot when refresh is true', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + const aiDir = path.join(tmp, '.ai'); + const result = await loadCharterContextSnapshot( + { ...baseOptions, format: 'json' }, + aiDir, + { refresh: true, sources: ['git'] }, + ); + + expect(result.refreshed).toBe(true); + expect(fs.existsSync(path.join(aiDir, 'context.snapshot.json'))).toBe(true); + expect(fs.existsSync(path.join(aiDir, 'context.adf'))).toBe(true); + expect((result.snapshot as { version: number }).version).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index dd213f4..d070e0a 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -330,9 +330,9 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro // Build next steps result.nextSteps.push({ - cmd: 'charter serve # start MCP server for Claude Code / Cursor integration', + cmd: 'charter serve # start MCP server for Claude Code / Codex / Cursor integration', required: false, - reason: 'Enable real-time governance via MCP (add to .claude/settings.json)', + reason: 'Enable real-time governance via MCP (wire in .mcp.json or .claude/settings.json)', }); result.nextSteps.push({ cmd: 'Review .charter/patterns/ and customize for your stack', diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 891c9eb..168c3c2 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -29,6 +29,7 @@ import { } from '@stackbilt/adf'; import { analyze as analyzeBlast, BlastInputSchema } from '@stackbilt/blast'; import { generateBrief } from './context'; +import { contextRefreshCommand } from './context-refresh'; import { analyze as analyzeSurface, SurfaceInputSchema, @@ -44,6 +45,15 @@ import { detectTsconfigAliases } from './blast'; // ============================================================================ const DEFAULT_PORT = 3847; +const CONTEXT_SOURCE_SET = new Set(['git', 'github'] as const); + +type ContextSourceName = 'git' | 'github'; + +interface CharterContextInput { + refresh?: boolean; + sources?: ContextSourceName[]; + ttlMinutes?: number; +} // ============================================================================ // Command Entry @@ -88,7 +98,7 @@ export async function serveCommand(options: CLIOptions, args: string[]): Promise version: '1.0.0', }); - registerTools(server, aiDir); + registerTools(server, aiDir, options); registerResources(server, aiDir); if (options.format !== 'json') { @@ -106,7 +116,40 @@ export async function serveCommand(options: CLIOptions, args: string[]): Promise // Tool Registration // ============================================================================ -function registerTools(server: McpServer, aiDir: string): void { +function registerTools(server: McpServer, aiDir: string, options: CLIOptions): void { + + (server.registerTool as Function)( + 'charter_context', + { + description: + 'Returns the current `.ai/context.snapshot.json` payload as structured JSON. Set refresh=true to run `charter context-refresh` first, then return the refreshed snapshot.', + inputSchema: { + refresh: z.boolean().optional().describe( + 'If true, refresh context before reading by running context-refresh.', + ), + sources: z.array(z.enum(['git', 'github'])).optional().describe( + 'Optional source override used only when refresh=true (for example ["git","github"]).', + ), + ttlMinutes: z.number().optional().describe( + 'Optional TTL override used only when refresh=true.', + ), + }, + }, + async (rawInput: unknown) => { + try { + const input = (rawInput ?? {}) as CharterContextInput; + const result = await loadCharterContextSnapshot(options, aiDir, input); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, + ); (server.registerTool as Function)( 'getProjectContext', @@ -598,3 +641,65 @@ function inferProjectName(aiDir: string): string { // Fall back to directory name return path.basename(process.cwd()); } + +export async function loadCharterContextSnapshot( + options: CLIOptions, + aiDir: string, + input?: CharterContextInput, +): Promise<{ refreshed: boolean; snapshotPath: string; snapshot: unknown }> { + const refresh = input?.refresh ?? false; + const snapshotPathAbs = path.join(aiDir, 'context.snapshot.json'); + const snapshotPathRel = path.relative(process.cwd(), snapshotPathAbs) || '.'; + + if (refresh) { + const args = ['--ai-dir', aiDir]; + if (input?.sources && input.sources.length > 0) { + const invalid = input.sources.filter((entry) => !CONTEXT_SOURCE_SET.has(entry)); + if (invalid.length > 0) { + throw new CLIError(`Invalid sources: ${invalid.join(', ')}. Supported: git, github.`); + } + args.push('--sources', input.sources.join(',')); + } + if (input?.ttlMinutes !== undefined) { + if (!Number.isFinite(input.ttlMinutes) || input.ttlMinutes <= 0) { + throw new CLIError(`Invalid ttlMinutes: ${input.ttlMinutes}. Must be a positive number.`); + } + args.push('--ttl-minutes', String(Math.floor(input.ttlMinutes))); + } + + const originalLog = console.log; + try { + console.log = () => {}; + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + args, + ); + if (exitCode !== EXIT_CODE.SUCCESS) { + throw new CLIError(`context-refresh exited with code ${exitCode}`); + } + } finally { + console.log = originalLog; + } + } + + if (!fs.existsSync(snapshotPathAbs)) { + throw new CLIError( + `Context snapshot not found at ${snapshotPathRel}. Run \`charter context-refresh\` or call charter_context with refresh=true.`, + ); + } + + let snapshot: unknown; + try { + snapshot = JSON.parse(fs.readFileSync(snapshotPathAbs, 'utf-8')); + } catch (err) { + throw new CLIError( + `Failed to parse context snapshot at ${snapshotPathRel}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { + refreshed: refresh, + snapshotPath: snapshotPathRel, + snapshot, + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2e83e32..723c35f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -69,7 +69,7 @@ Usage: Install git pre-commit hook for ADF evidence gate charter adf ADF context format tools (init, fmt, patch, create, bundle, sync, evidence, migrate, metrics) charter serve [--name ] [--ai-dir ] - Expose ADF project context as an MCP server (stdio, for Claude Code) + Expose ADF project context as an MCP server (stdio, for Claude Code/Codex/Cursor) charter login --key Store Stackbilt API key charter login --logout Clear stored credentials charter architect Generate tech stack from project description From 1a040521f336fbcc694611a8a0414479dc59b231 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 05:24:54 -0500 Subject: [PATCH 3/4] feat(cli): auto-wire codex mcp config during bootstrap --- README.md | 2 +- docs/cli-reference.md | 2 +- packages/cli/src/__tests__/bootstrap.test.ts | 51 +++++++++++++ packages/cli/src/commands/bootstrap.ts | 77 ++++++++++++++++++++ packages/cli/src/commands/serve.ts | 2 +- 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6ac9e9f..9e7979c 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ charter adf migrate # Migrate existing configs charter adf sync --check # Verify files match lock charter adf fmt .ai/core.adf --write # Reformat to canonical form charter adf metrics recalibrate # Adjust ceilings to current state -charter serve # MCP server for Claude Code +charter serve # MCP server for Claude Code, Codex, Cursor ``` ### Analyze diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0d98292..94fb8c8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -369,7 +369,7 @@ Use an absolute path for `--ai-dir`. A relative path like `.ai` resolves against #### Startup errors -If startup validation fails (missing `.ai/` directory or `manifest.adf`), `charter serve` emits a structured JSON-RPC error to stdout before exiting so Claude Code can surface a human-readable message: +If startup validation fails (missing `.ai/` directory or `manifest.adf`), `charter serve` emits a structured JSON-RPC error to stdout before exiting so MCP clients can surface a human-readable message: | Condition | Error message | Fix | |-----------|--------------|-----| diff --git a/packages/cli/src/__tests__/bootstrap.test.ts b/packages/cli/src/__tests__/bootstrap.test.ts index 3d92a92..a9a993c 100644 --- a/packages/cli/src/__tests__/bootstrap.test.ts +++ b/packages/cli/src/__tests__/bootstrap.test.ts @@ -165,6 +165,57 @@ STATE: expect(updatedSecurityCheck.status).toBe('PASS'); }); + it('creates .mcp.json with charter MCP server wiring for Codex/Cursor', async () => { + const exitCode = await bootstrapCommand( + { ...baseOptions, yes: true }, + ['--yes', '--preset', 'worker', '--skip-install', '--skip-doctor'], + ); + + expect(exitCode).toBe(0); + expect(fs.existsSync('.mcp.json')).toBe(true); + + const parsed = JSON.parse(fs.readFileSync('.mcp.json', 'utf-8')); + expect(parsed).toHaveProperty('mcpServers.charter'); + expect(parsed.mcpServers.charter.command).toBe('npx'); + expect(parsed.mcpServers.charter.args).toEqual([ + '@stackbilt/cli', + 'serve', + '--ai-dir', + path.resolve('.ai'), + ]); + }); + + it('does not overwrite existing mcpServers.charter without --force', async () => { + fs.writeFileSync( + '.mcp.json', + JSON.stringify( + { + mcpServers: { + charter: { + command: 'charter', + args: ['serve'], + }, + github: { + command: 'npx', + args: ['@modelcontextprotocol/server-github'], + }, + }, + }, + null, + 2, + ) + '\n', + ); + + const before = fs.readFileSync('.mcp.json', 'utf-8'); + const exitCode = await bootstrapCommand( + baseOptions, + ['--preset', 'worker', '--skip-install', '--skip-doctor'], + ); + + expect(exitCode).toBe(0); + expect(fs.readFileSync('.mcp.json', 'utf-8')).toBe(before); + }); + it('treats security deny drift matches as CI policy violations', async () => { await bootstrapCommand( { ...baseOptions, yes: true }, diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index d070e0a..a045195 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -622,6 +622,15 @@ function runSetupPhase( updated.push('package.json (devDependencies)'); } + const mcpConfig = ensureProjectMcpConfig('.ai', force); + if (mcpConfig.created) { + created.push('.mcp.json'); + } else if (mcpConfig.updated) { + updated.push('.mcp.json'); + } else if (mcpConfig.warning) { + warnings.push(mcpConfig.warning); + } + return { step: { name: 'setup', @@ -644,6 +653,74 @@ function runSetupPhase( } } +function ensureProjectMcpConfig( + aiDir: string, + force: boolean, +): { created: boolean; updated: boolean; warning?: string } { + const configPath = path.resolve('.mcp.json'); + const desiredServer = { + command: 'npx', + args: ['@stackbilt/cli', 'serve', '--ai-dir', path.resolve(aiDir)], + }; + + const configExists = fs.existsSync(configPath); + let root: Record = {}; + if (configExists) { + try { + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { + created: false, + updated: false, + warning: 'Skipped MCP config update: .mcp.json must contain a JSON object at the top level.', + }; + } + root = { ...(parsed as Record) }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + created: false, + updated: false, + warning: `Skipped MCP config update: .mcp.json is not valid JSON (${msg}).`, + }; + } + } + + const mcpServersRaw = root.mcpServers; + if (mcpServersRaw !== undefined && (typeof mcpServersRaw !== 'object' || mcpServersRaw === null || Array.isArray(mcpServersRaw))) { + return { + created: false, + updated: false, + warning: 'Skipped MCP config update: .mcp.json#mcpServers must be a JSON object.', + }; + } + + const mcpServers = { ...((mcpServersRaw as Record | undefined) ?? {}) }; + const existingCharter = mcpServers.charter; + const sameServer = JSON.stringify(existingCharter) === JSON.stringify(desiredServer); + if (sameServer) { + return { created: false, updated: false }; + } + + if (existingCharter !== undefined && !force) { + return { + created: false, + updated: false, + warning: 'Skipped MCP config update: .mcp.json already defines mcpServers.charter (use --force to replace it).', + }; + } + + mcpServers.charter = desiredServer; + root.mcpServers = mcpServers; + fs.writeFileSync(configPath, JSON.stringify(root, null, 2) + '\n'); + + if (!configExists) { + return { created: true, updated: false }; + } + + return { created: false, updated: true }; +} + // ============================================================================ // Phase 3: ADF Init // ============================================================================ diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 168c3c2..0be04ea 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -5,7 +5,7 @@ * Supports stdio (default) and SSE transports. * * Usage: - * charter serve # stdio, for Claude Code + * charter serve # stdio, for Claude Code/Codex/Cursor * charter serve --transport sse --port 3847 # SSE, for network access * charter serve --name "my-project" # custom server name */ From 6d2cc7435bbebff3b8fe5cffef7ce12ee0d4ba4b Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 05:45:59 -0500 Subject: [PATCH 4/4] fix(cli): address PR178 review blockers --- .../cli/src/__tests__/serve-context.test.ts | 26 ++++++++- packages/cli/src/commands/bootstrap.ts | 11 ++-- packages/cli/src/commands/context-refresh.ts | 47 +++++++++------- packages/cli/src/commands/serve.ts | 54 +++++++++---------- 4 files changed, 85 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/__tests__/serve-context.test.ts b/packages/cli/src/__tests__/serve-context.test.ts index 2a16fea..10fe3ed 100644 --- a/packages/cli/src/__tests__/serve-context.test.ts +++ b/packages/cli/src/__tests__/serve-context.test.ts @@ -1,11 +1,17 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CLIOptions } from '../index'; +import { EXIT_CODE } from '../index'; import { CLIError } from '../index'; import { loadCharterContextSnapshot } from '../commands/serve'; +const contextRefreshCommandMock = vi.hoisted(() => vi.fn()); +vi.mock('../commands/context-refresh', () => ({ + contextRefreshCommand: contextRefreshCommandMock, +})); + const baseOptions: CLIOptions = { configPath: '.charter', format: 'text', @@ -30,6 +36,10 @@ afterEach(() => { } }); +beforeEach(() => { + contextRefreshCommandMock.mockReset(); +}); + describe('loadCharterContextSnapshot', () => { it('returns existing snapshot when refresh is false', async () => { const tmp = makeTempDir(); @@ -66,6 +76,19 @@ describe('loadCharterContextSnapshot', () => { process.chdir(tmp); const aiDir = path.join(tmp, '.ai'); + contextRefreshCommandMock.mockImplementation(async (_options: CLIOptions, args: string[]) => { + const aiDirArgIndex = args.indexOf('--ai-dir'); + const targetAiDir = aiDirArgIndex !== -1 ? args[aiDirArgIndex + 1] : aiDir; + fs.mkdirSync(targetAiDir, { recursive: true }); + fs.writeFileSync( + path.join(targetAiDir, 'context.snapshot.json'), + JSON.stringify({ version: 1, generatedAt: '2026-01-01T00:00:00Z', sourcesUsed: ['git'] }), + 'utf8', + ); + fs.writeFileSync(path.join(targetAiDir, 'context.adf'), 'ADF: 0.1\n\nSTATE:\n CURRENT: Refreshed\n', 'utf8'); + return EXIT_CODE.SUCCESS; + }); + const result = await loadCharterContextSnapshot( { ...baseOptions, format: 'json' }, aiDir, @@ -73,6 +96,7 @@ describe('loadCharterContextSnapshot', () => { ); expect(result.refreshed).toBe(true); + expect(contextRefreshCommandMock).toHaveBeenCalledTimes(1); expect(fs.existsSync(path.join(aiDir, 'context.snapshot.json'))).toBe(true); expect(fs.existsSync(path.join(aiDir, 'context.adf'))).toBe(true); expect((result.snapshot as { version: number }).version).toBe(1); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index a045195..0ff9302 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -627,7 +627,8 @@ function runSetupPhase( created.push('.mcp.json'); } else if (mcpConfig.updated) { updated.push('.mcp.json'); - } else if (mcpConfig.warning) { + } + if (mcpConfig.warning) { warnings.push(mcpConfig.warning); } @@ -713,12 +714,13 @@ function ensureProjectMcpConfig( mcpServers.charter = desiredServer; root.mcpServers = mcpServers; fs.writeFileSync(configPath, JSON.stringify(root, null, 2) + '\n'); + const absolutePathWarning = 'Generated .mcp.json uses an absolute --ai-dir path. Update it if you share this file across machines.'; if (!configExists) { - return { created: true, updated: false }; + return { created: true, updated: false, warning: absolutePathWarning }; } - return { created: false, updated: true }; + return { created: false, updated: true, warning: absolutePathWarning }; } // ============================================================================ @@ -1267,6 +1269,9 @@ function isAlreadyThinPointer(filePath: string): boolean { * Prompt user for a yes/no answer via readline. */ function promptYesNo(question: string): Promise { + if (!process.stdin.isTTY) { + return Promise.resolve(false); + } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, diff --git a/packages/cli/src/commands/context-refresh.ts b/packages/cli/src/commands/context-refresh.ts index ab551c1..67b1bed 100644 --- a/packages/cli/src/commands/context-refresh.ts +++ b/packages/cli/src/commands/context-refresh.ts @@ -113,6 +113,10 @@ interface RefreshOptionsResolved { config: ContextConfig; } +interface ContextRefreshIO { + log?: (message: string) => void; +} + const SOURCE_SET = new Set(['git', 'github']); const DEFAULT_CONFIG: ContextConfig = { version: 1, @@ -139,7 +143,12 @@ const DEFAULT_CONFIG: ContextConfig = { }, }; -export async function contextRefreshCommand(options: CLIOptions, args: string[]): Promise { +export async function contextRefreshCommand( + options: CLIOptions, + args: string[], + io?: ContextRefreshIO, +): Promise { + const log = io?.log ?? console.log; const resolved = resolveOptions(options, args); const snapshotPath = path.join(resolved.aiDirAbs, 'context.snapshot.json'); const contextAdfPath = path.join(resolved.aiDirAbs, 'context.adf'); @@ -156,7 +165,7 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[]) : null, }; if (options.format === 'json') { - console.log(JSON.stringify({ + log(JSON.stringify({ status: 'skipped', reason: 'fresh_snapshot', generatedAt: existing?.generatedAt ?? null, @@ -168,12 +177,12 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[]) errors: [], }, null, 2)); } else { - console.log(''); - console.log(' charter context-refresh'); - console.log(' Status: skipped (fresh snapshot)'); - console.log(` Snapshot: ${files.snapshotJson}`); - console.log(` TTL (mins): ${resolved.ttlMinutes}`); - console.log(''); + log(''); + log(' charter context-refresh'); + log(' Status: skipped (fresh snapshot)'); + log(` Snapshot: ${files.snapshotJson}`); + log(` TTL (mins): ${resolved.ttlMinutes}`); + log(''); } return EXIT_CODE.SUCCESS; } @@ -204,7 +213,7 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[]) }; if (options.format === 'json') { - console.log(JSON.stringify({ + log(JSON.stringify({ status: 'ok', reason: status, generatedAt: snapshot.generatedAt, @@ -216,22 +225,22 @@ export async function contextRefreshCommand(options: CLIOptions, args: string[]) errors: snapshot.errors, }, null, 2)); } else { - console.log(''); - console.log(' charter context-refresh'); - console.log(` Status: ${status}`); - console.log(` Sources: ${snapshot.sourcesUsed.join(', ') || '(none)'}`); - console.log(` Wrote: ${files.contextAdf}`); - console.log(` Snapshot: ${files.snapshotJson}`); + log(''); + log(' charter context-refresh'); + log(` Status: ${status}`); + log(` Sources: ${snapshot.sourcesUsed.join(', ') || '(none)'}`); + log(` Wrote: ${files.contextAdf}`); + log(` Snapshot: ${files.snapshotJson}`); if (files.outputMarkdown) { - console.log(` Mirrored MD: ${files.outputMarkdown}`); + log(` Mirrored MD: ${files.outputMarkdown}`); } if (snapshot.warnings.length > 0) { - console.log(` Warnings: ${snapshot.warnings.length}`); + log(` Warnings: ${snapshot.warnings.length}`); } if (snapshot.errors.length > 0) { - console.log(` Errors: ${snapshot.errors.length}`); + log(` Errors: ${snapshot.errors.length}`); } - console.log(''); + log(''); } return EXIT_CODE.SUCCESS; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0be04ea..e3d8880 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -49,11 +49,19 @@ const CONTEXT_SOURCE_SET = new Set(['git', 'github'] as const); type ContextSourceName = 'git' | 'github'; -interface CharterContextInput { - refresh?: boolean; - sources?: ContextSourceName[]; - ttlMinutes?: number; -} +const CharterContextInputSchema = z.object({ + refresh: z.boolean().optional().describe( + 'If true, refresh context before reading by running context-refresh.', + ), + sources: z.array(z.enum(['git', 'github'])).optional().describe( + 'Optional source override used only when refresh=true (for example ["git","github"]).', + ), + ttlMinutes: z.number().optional().describe( + 'Optional TTL override used only when refresh=true.', + ), +}); + +type CharterContextInput = z.infer; // ============================================================================ // Command Entry @@ -123,21 +131,11 @@ function registerTools(server: McpServer, aiDir: string, options: CLIOptions): v { description: 'Returns the current `.ai/context.snapshot.json` payload as structured JSON. Set refresh=true to run `charter context-refresh` first, then return the refreshed snapshot.', - inputSchema: { - refresh: z.boolean().optional().describe( - 'If true, refresh context before reading by running context-refresh.', - ), - sources: z.array(z.enum(['git', 'github'])).optional().describe( - 'Optional source override used only when refresh=true (for example ["git","github"]).', - ), - ttlMinutes: z.number().optional().describe( - 'Optional TTL override used only when refresh=true.', - ), - }, + inputSchema: CharterContextInputSchema.shape, }, async (rawInput: unknown) => { try { - const input = (rawInput ?? {}) as CharterContextInput; + const input = CharterContextInputSchema.parse(rawInput ?? {}); const result = await loadCharterContextSnapshot(options, aiDir, input); return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], @@ -649,11 +647,12 @@ export async function loadCharterContextSnapshot( ): Promise<{ refreshed: boolean; snapshotPath: string; snapshot: unknown }> { const refresh = input?.refresh ?? false; const snapshotPathAbs = path.join(aiDir, 'context.snapshot.json'); - const snapshotPathRel = path.relative(process.cwd(), snapshotPathAbs) || '.'; + const snapshotPathRel = path.relative(process.cwd(), snapshotPathAbs); if (refresh) { const args = ['--ai-dir', aiDir]; if (input?.sources && input.sources.length > 0) { + // Defensive guard: this helper is exported and may be called from plain JS. const invalid = input.sources.filter((entry) => !CONTEXT_SOURCE_SET.has(entry)); if (invalid.length > 0) { throw new CLIError(`Invalid sources: ${invalid.join(', ')}. Supported: git, github.`); @@ -667,18 +666,13 @@ export async function loadCharterContextSnapshot( args.push('--ttl-minutes', String(Math.floor(input.ttlMinutes))); } - const originalLog = console.log; - try { - console.log = () => {}; - const exitCode = await contextRefreshCommand( - { ...options, format: 'json' }, - args, - ); - if (exitCode !== EXIT_CODE.SUCCESS) { - throw new CLIError(`context-refresh exited with code ${exitCode}`); - } - } finally { - console.log = originalLog; + const exitCode = await contextRefreshCommand( + { ...options, format: 'json' }, + args, + { log: () => {} }, + ); + if (exitCode !== EXIT_CODE.SUCCESS) { + throw new CLIError(`context-refresh exited with code ${exitCode}`); } }