From 19a1aaf0455f0d7eeb7627172cac97ee68fa998b Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 29 May 2026 00:26:21 +0900 Subject: [PATCH 1/2] feat(cli): port subcommands (search/index/find-related/init/savings/mcp) from semble --- src/cli.test.ts | 355 +++++++++++++++++++++++++++++++++++++++ src/cli.ts | 433 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 783 insertions(+), 5 deletions(-) create mode 100644 src/cli.test.ts diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..3416bf4 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,355 @@ +// Port of (none) — unit tests for src/cli.ts +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +import { _agentPath, _readAgentFile, _resolveContent, _runInit, Agent, parseArgs, runCli } from './cli.ts' +import type { CspIndex } from './indexing/index.ts' +import { ContentType, type SearchResult } from './types.ts' + +describe('Agent enum', () => { + test('enum values', () => { + expect(String(Agent.Claude)).toBe('claude') + expect(String(Agent.Copilot)).toBe('copilot') + expect(String(Agent.Cursor)).toBe('cursor') + expect(String(Agent.Gemini)).toBe('gemini') + expect(String(Agent.Kiro)).toBe('kiro') + expect(String(Agent.Opencode)).toBe('opencode') + }) +}) + +describe('_agentPath', () => { + test('claude → .claude/agents/csp-search.md', () => { + expect(_agentPath(Agent.Claude)).toBe('.claude/agents/csp-search.md') + }) + test('copilot → .github/agents/csp-search.md', () => { + expect(_agentPath(Agent.Copilot)).toBe('.github/agents/csp-search.md') + }) + test('cursor → .cursor/agents/csp-search.md', () => { + expect(_agentPath(Agent.Cursor)).toBe('.cursor/agents/csp-search.md') + }) + test('opencode → .opencode/agents/csp-search.md', () => { + expect(_agentPath(Agent.Opencode)).toBe('.opencode/agents/csp-search.md') + }) +}) + +describe('parseArgs', () => { + test('subcommand and positional', () => { + const r = parseArgs(['search', 'foo', '.']) + expect(r.command).toBe('search') + expect(r.positional).toEqual(['foo', '.']) + }) + test('--flag value', () => { + const r = parseArgs(['index', '.', '--out', 'idx']) + expect(r.flags['out']).toBe('idx') + }) + test('--flag=value', () => { + const r = parseArgs(['search', 'q', '--top-k=10']) + expect(r.flags['top-k']).toBe('10') + }) + test('boolean flag', () => { + const r = parseArgs(['savings', '--verbose']) + expect(r.flags['verbose']).toBe(true) + }) + test('multi-value --content', () => { + const r = parseArgs(['search', 'q', '--content', 'code', 'docs']) + expect(r.flags['content']).toEqual(['code', 'docs']) + }) + test('short -k', () => { + const r = parseArgs(['search', 'q', '-k', '20']) + expect(r.flags['k']).toBe('20') + }) +}) + +describe('_resolveContent', () => { + test('default code', () => { + expect(_resolveContent(['code'], false)).toEqual([ContentType.CODE]) + }) + test('all expands', () => { + expect(_resolveContent(['all'], false)).toEqual([ContentType.CODE, ContentType.DOCS, ContentType.CONFIG]) + }) + test('--include-text-files expands like all', () => { + expect(_resolveContent(['code'], true)).toEqual([ContentType.CODE, ContentType.DOCS, ContentType.CONFIG]) + }) + test('multiple types', () => { + expect(_resolveContent(['code', 'docs'], false)).toEqual([ContentType.CODE, ContentType.DOCS]) + }) + test('unknown throws', () => { + expect(() => _resolveContent(['bogus'], false)).toThrow() + }) +}) + +describe('runCli --help', () => { + test('help mentions all subcommands', async () => { + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + try { + const code = await runCli(['--help']) + expect(code).toBe(0) + } + finally { + process.stdout.write = origWrite + } + const out = writes.join('') + expect(out).toContain('search') + expect(out).toContain('index') + expect(out).toContain('find-related') + expect(out).toContain('init') + expect(out).toContain('savings') + expect(out).toContain('mcp') + }) +}) + +describe('csp init', () => { + let tmpDir: string + let origCwd: string + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'csp-cli-test-')) + origCwd = process.cwd() + process.chdir(tmpDir) + }) + + afterEach(async () => { + process.chdir(origCwd) + await rm(tmpDir, { recursive: true, force: true }) + }) + + test('--agent claude writes .claude/agents/csp-search.md', async () => { + await _runInit({ + agent: Agent.Claude, + cwd: tmpDir, + readAgentFile: async () => '# stub agent file\n', + }) + const path = join(tmpDir, '.claude/agents/csp-search.md') + expect(existsSync(path)).toBe(true) + const content = await readFile(path, 'utf8') + expect(content).toBe('# stub agent file\n') + }) + + test('--agent copilot writes .github/agents/csp-search.md', async () => { + await _runInit({ + agent: Agent.Copilot, + cwd: tmpDir, + readAgentFile: async () => '# stub copilot\n', + }) + const path = join(tmpDir, '.github/agents/csp-search.md') + expect(existsSync(path)).toBe(true) + }) + + test('without --force errors if file exists', async () => { + await _runInit({ + agent: Agent.Claude, + cwd: tmpDir, + readAgentFile: async () => 'first\n', + }) + // Second call should exit with code 1; we intercept process.exit. + let exitCode: number | undefined + const origExit = process.exit + process.exit = ((code?: number) => { + exitCode = code + throw new Error('__test_exit__') + }) as typeof process.exit + const origStderr = process.stderr.write.bind(process.stderr) + process.stderr.write = (() => true) as typeof process.stderr.write + try { + await _runInit({ + agent: Agent.Claude, + cwd: tmpDir, + readAgentFile: async () => 'second\n', + }) + } + catch (err) { + // Expected: we threw inside the fake exit. + expect((err as Error).message).toBe('__test_exit__') + } + finally { + process.exit = origExit + process.stderr.write = origStderr + } + expect(exitCode).toBe(1) + // Original content preserved. + const content = await readFile(join(tmpDir, '.claude/agents/csp-search.md'), 'utf8') + expect(content).toBe('first\n') + }) + + test('--force overwrites', async () => { + await _runInit({ + agent: Agent.Claude, + cwd: tmpDir, + readAgentFile: async () => 'first\n', + }) + await _runInit({ + agent: Agent.Claude, + force: true, + cwd: tmpDir, + readAgentFile: async () => 'second\n', + }) + const content = await readFile(join(tmpDir, '.claude/agents/csp-search.md'), 'utf8') + expect(content).toBe('second\n') + }) +}) + +describe('csp search (stub-mocked)', () => { + test('calls index.search with topK', async () => { + let captured: { query?: string, topK?: number } = {} + const fakeIndex: Partial = { + chunks: [], + search: async (query: string, opts?: { topK?: number }): Promise => { + captured = { query, ...(opts ?? {}) } + return [] + }, + } + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + try { + const code = await runCli(['search', 'foo', '.', '-k', '7'], { + fromPath: async () => fakeIndex as CspIndex, + }) + expect(code).toBe(0) + } + finally { + process.stdout.write = origWrite + } + expect(captured).toEqual({ query: 'foo', topK: 7 }) + // Output should be JSON {"error":"No results found."} + const out = writes.join('').trim() + expect(JSON.parse(out)).toEqual({ error: 'No results found.' }) + }) + + test('formats non-empty results as JSON', async () => { + const fakeIndex: Partial = { + chunks: [], + search: async () => [ + { + chunk: { content: 'def foo()', filePath: 'a.py', startLine: 1, endLine: 3, language: 'python' }, + score: 0.9, + }, + ], + } + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + try { + await runCli(['search', 'foo', '.'], { + fromPath: async () => fakeIndex as CspIndex, + }) + } + finally { + process.stdout.write = origWrite + } + const out = JSON.parse(writes.join('').trim()) + expect(out.query).toBe('foo') + expect(out.results).toHaveLength(1) + expect(out.results[0].chunk.file_path).toBe('a.py') + expect(out.results[0].chunk.location).toBe('a.py:1-3') + }) +}) + +describe('csp savings', () => { + test('prints the report', async () => { + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + try { + const code = await runCli(['savings'], { + formatSavings: ({ verbose }) => `SAVINGS verbose=${verbose ? '1' : '0'}`, + }) + expect(code).toBe(0) + } + finally { + process.stdout.write = origWrite + } + expect(writes.join('')).toBe('SAVINGS verbose=0') + }) + + test('--verbose is forwarded', async () => { + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + try { + await runCli(['savings', '--verbose'], { + formatSavings: ({ verbose }) => `SAVINGS verbose=${verbose ? '1' : '0'}`, + }) + } + finally { + process.stdout.write = origWrite + } + expect(writes.join('')).toBe('SAVINGS verbose=1') + }) +}) + +describe('csp mcp', () => { + test('dispatches to serve with path and content', async () => { + let captured: { path?: string | undefined, ref?: string | undefined, content?: ContentType[] } = {} + const code = await runCli(['mcp', '.', '--ref', 'main', '--content', 'all'], { + serveMcp: async (p, o) => { + captured = { path: p, ref: o.ref, content: o.content } + }, + }) + expect(code).toBe(0) + expect(captured.path).toBe('.') + expect(captured.ref).toBe('main') + expect(captured.content).toEqual([ContentType.CODE, ContentType.DOCS, ContentType.CONFIG]) + }) + + test('mcp with no path forwards undefined', async () => { + let captured: { path?: string | undefined } = {} + const code = await runCli(['mcp'], { + serveMcp: async (p) => { + captured = { path: p } + }, + }) + expect(code).toBe(0) + expect(captured.path).toBeUndefined() + }) +}) + +describe('csp find-related validates line', () => { + test('non-integer line errors with code 1', async () => { + const errs: string[] = [] + const origStderr = process.stderr.write.bind(process.stderr) + process.stderr.write = ((chunk: string | Uint8Array) => { + errs.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write + try { + const code = await runCli(['find-related', 'src/auth.ts', '42abc', '.'], { + fromPath: async () => ({ chunks: [] }) as unknown as CspIndex, + }) + expect(code).toBe(1) + } + finally { + process.stderr.write = origStderr + } + expect(errs.join('')).toContain('line must be an integer') + }) +}) + +describe('_readAgentFile', () => { + test('reads src/agents/claude.md', async () => { + const text = await _readAgentFile(Agent.Claude) + expect(text.length).toBeGreaterThan(0) + expect(text).toContain('csp') + }) +}) diff --git a/src/cli.ts b/src/cli.ts index 9fc1d0a..dd193e3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,432 @@ #!/usr/bin/env node -import { version } from './index' +// Port of src/semble/cli.py +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' -function main(): void { - // eslint-disable-next-line no-console - console.log(`csp ${version}`) +// TODO(integration): replace stub when sibling modules land +import { CspIndex } from './indexing/index.ts' +import { serve } from './mcp/server.ts' +import { formatSavingsReport } from './stats.ts' +import { ContentType } from './types.ts' +import { formatResults, isGitUrl, resolveChunk } from './utils.ts' + +export enum Agent { + Claude = 'claude', + Copilot = 'copilot', + Cursor = 'cursor', + Gemini = 'gemini', + Kiro = 'kiro', + Opencode = 'opencode', +} + +const DEFAULT_AGENT = Agent.Claude +const CLI_DISPATCH_ARGS = new Set([ + 'search', + 'find-related', + 'init', + 'savings', + 'index', + 'mcp', + '-h', + '--help', +]) + +const CONTENT_CHOICES = ['code', 'docs', 'config', 'all'] as const + +export function _agentPath(agent: Agent): string { + const baseDir = agent === Agent.Copilot ? '.github' : `.${agent}` + return `${baseDir}/agents/csp-search.md` +} + +export interface ParsedArgs { + command: string | null + positional: string[] + flags: Record +} + +export function parseArgs(argv: string[]): ParsedArgs { + const positional: string[] = [] + const flags: Record = {} + let command: string | null = null + let i = 0 + + if (argv.length > 0 && !argv[0]!.startsWith('-')) { + command = argv[0]! + i = 1 + } + + while (i < argv.length) { + const token = argv[i]! + if (token === '--') { + for (let j = i + 1; j < argv.length; j++) positional.push(argv[j]!) + break + } + if (token.startsWith('--')) { + const eqIdx = token.indexOf('=') + let name: string + let value: string | undefined + if (eqIdx !== -1) { + name = token.slice(2, eqIdx) + value = token.slice(eqIdx + 1) + } + else { + name = token.slice(2) + } + // collect multi-value flag (e.g. --content code docs) + if (name === 'content' && value === undefined) { + const values: string[] = [] + let j = i + 1 + while (j < argv.length && !argv[j]!.startsWith('-')) { + values.push(argv[j]!) + j++ + } + if (values.length > 0) { + flags[name] = values + i = j + continue + } + } + if (value === undefined) { + // boolean or value-from-next + const next = argv[i + 1] + if (next !== undefined && !next.startsWith('-')) { + flags[name] = next + i += 2 + continue + } + flags[name] = true + i += 1 + continue + } + flags[name] = value + i += 1 + continue + } + if (token.startsWith('-') && token.length > 1) { + // short flag + const name = token.slice(1) + const next = argv[i + 1] + if (next !== undefined && !next.startsWith('-')) { + flags[name] = next + i += 2 + continue + } + flags[name] = true + i += 1 + continue + } + positional.push(token) + i += 1 + } + + return { command, positional, flags } +} + +function _getFlag(flags: Record, ...names: string[]): string | boolean | string[] | undefined { + for (const name of names) { + if (name in flags) return flags[name] + } + return undefined +} + +function _getStringFlag(flags: Record, ...names: string[]): string | undefined { + const v = _getFlag(flags, ...names) + if (typeof v === 'string') return v + return undefined +} + +function _getNumberFlag(flags: Record, ...names: string[]): number | undefined { + const s = _getStringFlag(flags, ...names) + if (s === undefined) return undefined + const n = Number(s) + if (Number.isNaN(n)) return undefined + return n +} + +function _getBoolFlag(flags: Record, ...names: string[]): boolean { + const v = _getFlag(flags, ...names) + return v === true +} + +function _getContentFlag(flags: Record): string[] { + const v = flags['content'] + if (Array.isArray(v)) return v + if (typeof v === 'string') return [v] + return ['code'] +} + +export function _resolveContent(content: string[], includeTextFiles: boolean): ContentType[] { + if (includeTextFiles) { + process.emitWarning( + '--include-text-files is deprecated and will be removed in a future version. Use --content all instead.', + 'DeprecationWarning', + ) + } + if (includeTextFiles || content.includes('all')) { + return [ContentType.CODE, ContentType.DOCS, ContentType.CONFIG] + } + const result: ContentType[] = [] + for (const c of content) { + if (c === 'code') result.push(ContentType.CODE) + else if (c === 'docs') result.push(ContentType.DOCS) + else if (c === 'config') result.push(ContentType.CONFIG) + else throw new Error(`Invalid content type: ${c}. Choices: ${CONTENT_CHOICES.join(', ')}`) + } + return result +} + +function _printHelp(): void { + const help = `csp — Instant local code search for agents. + +Usage: + csp [options] + +Commands: + search [path] Search a codebase. + index Index and store a codebase. + find-related [path] Find code similar to a specific location. + init Write a csp sub-agent file for your coding agent. + savings Show token savings and usage stats. + mcp [path] Start the MCP server (optionally pre-index path). + +Common options: + --top-k , -k Number of results (default: 5). + --content Content types: code, docs, config, all (default: code). + --index Path to a pre-built index. + --agent , -a One of: claude, copilot, cursor, gemini, kiro, opencode. + --force Overwrite if file already exists (init). + -o, --out Write the pre-built index to this path (index). + --ref Branch or tag for git URLs (mcp). + --verbose Verbose output (savings). + --include-text-files Deprecated. Use --content all instead. + +Examples: + csp search "authentication flow" ./my-project + csp index ./my-project -o my_index + csp find-related src/auth.ts 42 ./my-project + csp init --agent claude + csp savings --verbose + csp mcp ./my-project +` + process.stdout.write(help) } -main() +interface RunOptions { + readIndex?: (path: string) => Promise + fromPath?: (path: string, opts: { content: ContentType[] }) => Promise + fromGit?: (path: string, opts: { content: ContentType[] }) => Promise + serveMcp?: (path: string | undefined, opts: { ref?: string | undefined, content: ContentType[] }) => Promise + writeFileImpl?: (path: string, content: string) => Promise + readAgentFile?: (agent: Agent) => Promise + formatSavings?: (opts: { verbose: boolean }) => string + cwd?: () => string +} + +export async function _readAgentFile(agent: Agent): Promise { + const url = new URL(`./agents/${agent}.md`, import.meta.url) + return await readFile(fileURLToPath(url), 'utf8') +} + +export async function _runInit(opts: { + agent?: Agent + force?: boolean + cwd?: string + readAgentFile?: (agent: Agent) => Promise + writeFileImpl?: (path: string, content: string) => Promise +}): Promise { + const agent = opts.agent ?? DEFAULT_AGENT + const force = opts.force ?? false + const cwd = opts.cwd ?? process.cwd() + const relDest = _agentPath(agent) + const dest = resolve(cwd, relDest) + + let exists = false + try { + await stat(dest) + exists = true + } + catch { + exists = false + } + if (exists && !force) { + process.stderr.write(`${relDest} already exists. Run with --force to overwrite.\n`) + process.exit(1) + } + + await mkdir(dirname(dest), { recursive: true }) + const readAgent = opts.readAgentFile ?? _readAgentFile + const content = await readAgent(agent) + const write = opts.writeFileImpl ?? ((p: string, c: string) => writeFile(p, c, 'utf8')) + await write(dest, content) + process.stdout.write(`Created ${relDest}\n`) +} + +async function _runIndex(opts: { + path: string + out: string + includeTextFiles: boolean +}): Promise { + const { path, out, includeTextFiles } = opts + const index = isGitUrl(path) + ? await CspIndex.fromGit(path, { includeTextFiles }) + : await CspIndex.fromPath(path, { includeTextFiles }) + await mkdir(out, { recursive: true }) + await index.save(out) +} + +export async function runCli(argv: string[], options: RunOptions = {}): Promise { + // The first arg determines whether this is the MCP entrypoint or a subcommand. + if (argv.length === 0 || (argv[0] !== undefined && !CLI_DISPATCH_ARGS.has(argv[0]) && !argv[0].startsWith('-'))) { + // No subcommand recognized — treat as MCP (mirrors semble's default). + // But for csp the README requires `csp mcp` — so we still treat bare invocation as help. + _printHelp() + return 0 + } + + if (argv[0] === '-h' || argv[0] === '--help') { + _printHelp() + return 0 + } + + const { command, positional, flags } = parseArgs(argv) + + if (command === 'init') { + const agentRaw = _getStringFlag(flags, 'agent', 'a') ?? DEFAULT_AGENT + const agent = _coerceAgent(agentRaw) + const force = _getBoolFlag(flags, 'force') + await _runInit({ + agent, + force, + ...(options.cwd ? { cwd: options.cwd() } : {}), + ...(options.readAgentFile ? { readAgentFile: options.readAgentFile } : {}), + ...(options.writeFileImpl ? { writeFileImpl: options.writeFileImpl } : {}), + }) + return 0 + } + + if (command === 'index') { + const path = positional[0] ?? '.' + const out = _getStringFlag(flags, 'out', 'o') + if (out === undefined) { + process.stderr.write('--out / -o is required for `index`.\n') + return 1 + } + const includeTextFiles = _getBoolFlag(flags, 'include-text-files') + await _runIndex({ path, out, includeTextFiles }) + return 0 + } + + if (command === 'savings') { + const verbose = _getBoolFlag(flags, 'verbose') + const fmt = options.formatSavings ?? formatSavingsReport + process.stdout.write(fmt({ verbose })) + return 0 + } + + if (command === 'mcp') { + const path = positional[0] + const ref = _getStringFlag(flags, 'ref') + const content = _resolveContent(_getContentFlag(flags), _getBoolFlag(flags, 'include-text-files')) + const serveImpl = options.serveMcp ?? ((p, o) => serve(p, o)) + await serveImpl(path, { ref, content }) + return 0 + } + + // search and find-related share index loading + if (command === 'search' || command === 'find-related') { + const indexPath = _getStringFlag(flags, 'index') + let index: CspIndex + if (indexPath !== undefined) { + const loadImpl = options.readIndex ?? ((p: string) => CspIndex.loadFromDisk(p)) + index = await loadImpl(indexPath) + } + else { + const pathArg = command === 'search' ? positional[1] ?? '.' : positional[2] ?? '.' + const content = _resolveContent(_getContentFlag(flags), _getBoolFlag(flags, 'include-text-files')) + const fromPath = options.fromPath ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromPath(p, o)) + const fromGit = options.fromGit ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromGit(p, o)) + index = isGitUrl(pathArg) + ? await fromGit(pathArg, { content }) + : await fromPath(pathArg, { content }) + } + + const topK = _getNumberFlag(flags, 'top-k', 'k') ?? 5 + + if (command === 'search') { + const query = positional[0] + if (query === undefined) { + process.stderr.write('search requires a .\n') + return 1 + } + const results = await index.search(query, { topK }) + const out = (!results || results.length === 0) + ? { error: 'No results found.' } + : formatResults(query, results) + process.stdout.write(`${JSON.stringify(out)}\n`) + return 0 + } + + // find-related + const filePath = positional[0] + const lineRaw = positional[1] + if (filePath === undefined || lineRaw === undefined) { + process.stderr.write('find-related requires .\n') + return 1 + } + if (!/^-?\d+$/.test(lineRaw)) { + process.stderr.write(`line must be an integer, got: ${lineRaw}\n`) + return 1 + } + const line = Number.parseInt(lineRaw, 10) + const chunk = resolveChunk(index.chunks, filePath, line) + if (chunk === undefined || chunk === null) { + process.stderr.write(`No chunk found at ${filePath}:${line}.\n`) + return 1 + } + const related = await index.findRelated(chunk, { topK }) + const out = (!related || related.length === 0) + ? { error: `No related chunks found for ${filePath}:${line}.` } + : formatResults(`Chunks related to ${filePath}:${line}`, related) + process.stdout.write(`${JSON.stringify(out)}\n`) + return 0 + } + + process.stderr.write(`Unknown command: ${command ?? ''}\n`) + _printHelp() + return 1 +} + +function _coerceAgent(raw: string): Agent { + const candidates: Agent[] = [Agent.Claude, Agent.Copilot, Agent.Cursor, Agent.Gemini, Agent.Kiro, Agent.Opencode] + for (const a of candidates) { + if (a === raw) return a + } + throw new Error(`Invalid agent: ${raw}. Choices: ${candidates.join(', ')}`) +} + +async function main(): Promise { + const argv = process.argv.slice(2) + const code = await runCli(argv) + if (code !== 0) process.exit(code) +} + +// Run main only when invoked directly (not when imported as a module / under bun:test) +const invokedDirectly = (() => { + if (typeof process === 'undefined') return false + // process.argv[1] points at the entrypoint script — match against this module's URL + const entry = process.argv[1] + if (entry === undefined) return false + try { + const here = fileURLToPath(import.meta.url) + return entry === here || entry.endsWith('/cli.ts') || entry.endsWith('/cli.mjs') || entry.endsWith('/cli.js') + } + catch { + return false + } +})() + +if (invokedDirectly) { + void main() +} From a911c84ff0a3a926f592cd857878119fdbe7e3cf Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 29 May 2026 00:47:12 +0900 Subject: [PATCH 2/2] review(cli): apply gemini-code-assist feedback - _runInit: throw Error instead of process.exit(1); callers handle exit code - _runIndex: accept content: ContentType[] (was includeTextFiles boolean) - index dispatch: resolve --content / --include-text-files like other commands - runCli: wrap dispatch in try/catch; surface user-friendly errors as exit 1 - unknown subcommand: return exit 1 (was 0); print 'Unknown command' to stderr - tests: use rejects.toThrow for _runInit (no process.exit mocking) - tests: cover unknown subcommand, invalid --agent, invalid --content, runCli-level translation of init rejection, and index --content forwarding Deferred: greedy --content parsing, --agent codex (also missing upstream). --- src/cli.test.ts | 157 ++++++++++++++++++++++++++++------ src/cli.ts | 218 ++++++++++++++++++++++++++---------------------- 2 files changed, 252 insertions(+), 123 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 3416bf4..3ae60dc 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -150,31 +150,13 @@ describe('csp init', () => { cwd: tmpDir, readAgentFile: async () => 'first\n', }) - // Second call should exit with code 1; we intercept process.exit. - let exitCode: number | undefined - const origExit = process.exit - process.exit = ((code?: number) => { - exitCode = code - throw new Error('__test_exit__') - }) as typeof process.exit - const origStderr = process.stderr.write.bind(process.stderr) - process.stderr.write = (() => true) as typeof process.stderr.write - try { - await _runInit({ - agent: Agent.Claude, - cwd: tmpDir, - readAgentFile: async () => 'second\n', - }) - } - catch (err) { - // Expected: we threw inside the fake exit. - expect((err as Error).message).toBe('__test_exit__') - } - finally { - process.exit = origExit - process.stderr.write = origStderr - } - expect(exitCode).toBe(1) + // Second call should reject with an "already exists" error — callers + // (i.e. runCli) translate this into exit code 1 + stderr message. + await expect(_runInit({ + agent: Agent.Claude, + cwd: tmpDir, + readAgentFile: async () => 'second\n', + })).rejects.toThrow('already exists') // Original content preserved. const content = await readFile(join(tmpDir, '.claude/agents/csp-search.md'), 'utf8') expect(content).toBe('first\n') @@ -353,3 +335,128 @@ describe('_readAgentFile', () => { expect(text).toContain('csp') }) }) + +describe('runCli error handling', () => { + test('unknown subcommand returns exit 1', async () => { + const errs: string[] = [] + const outs: string[] = [] + const origErr = process.stderr.write.bind(process.stderr) + const origOut = process.stdout.write.bind(process.stdout) + process.stderr.write = ((chunk: string | Uint8Array) => { + errs.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write + process.stdout.write = ((chunk: string | Uint8Array) => { + outs.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + try { + const code = await runCli(['bogus-cmd']) + expect(code).toBe(1) + } + finally { + process.stderr.write = origErr + process.stdout.write = origOut + } + expect(errs.join('')).toContain('Unknown command: bogus-cmd') + }) + + test('invalid --agent returns exit 1 with stderr message', async () => { + const errs: string[] = [] + const origErr = process.stderr.write.bind(process.stderr) + process.stderr.write = ((chunk: string | Uint8Array) => { + errs.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write + try { + const code = await runCli(['init', '--agent', 'bogus']) + expect(code).toBe(1) + } + finally { + process.stderr.write = origErr + } + expect(errs.join('')).toContain('Invalid agent: bogus') + }) + + test('invalid --content returns exit 1 with stderr message', async () => { + const errs: string[] = [] + const origErr = process.stderr.write.bind(process.stderr) + process.stderr.write = ((chunk: string | Uint8Array) => { + errs.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write + try { + const code = await runCli(['search', 'foo', '--content', 'bogus'], { + fromPath: async () => ({ chunks: [] }) as unknown as CspIndex, + }) + expect(code).toBe(1) + } + finally { + process.stderr.write = origErr + } + expect(errs.join('')).toContain('Invalid content type: bogus') + }) + + test('init rejection is translated to exit 1 by runCli', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'csp-cli-runcli-')) + const errs: string[] = [] + const outs: string[] = [] + const origErr = process.stderr.write.bind(process.stderr) + const origOut = process.stdout.write.bind(process.stdout) + process.stderr.write = ((chunk: string | Uint8Array) => { + errs.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write + process.stdout.write = ((chunk: string | Uint8Array) => { + outs.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + try { + // First run: succeeds. + const code1 = await runCli(['init', '--agent', 'claude'], { + cwd: () => tmp, + readAgentFile: async () => '# stub\n', + }) + expect(code1).toBe(0) + // Second run without --force: should exit 1 with stderr message, not crash. + const code2 = await runCli(['init', '--agent', 'claude'], { + cwd: () => tmp, + readAgentFile: async () => '# stub\n', + }) + expect(code2).toBe(1) + } + finally { + process.stderr.write = origErr + process.stdout.write = origOut + await rm(tmp, { recursive: true, force: true }) + } + expect(errs.join('')).toContain('already exists') + }) +}) + +describe('csp index --content', () => { + test('passes resolved content types to fromPath', async () => { + let captured: { path?: string, content?: ContentType[] } = {} + const fakeIndex: Partial = { + chunks: [], + save: async () => { + // no-op + }, + } + const tmp = await mkdtemp(join(tmpdir(), 'csp-cli-index-')) + try { + const code = await runCli(['index', '.', '-o', join(tmp, 'idx'), '--content', 'all'], { + fromPath: async (p, o) => { + captured = { path: p, content: o.content } + return fakeIndex as CspIndex + }, + }) + expect(code).toBe(0) + } + finally { + await rm(tmp, { recursive: true, force: true }) + } + expect(captured.path).toBe('.') + expect(captured.content).toEqual([ContentType.CODE, ContentType.DOCS, ContentType.CONFIG]) + }) +}) diff --git a/src/cli.ts b/src/cli.ts index dd193e3..5f45522 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -251,8 +251,7 @@ export async function _runInit(opts: { exists = false } if (exists && !force) { - process.stderr.write(`${relDest} already exists. Run with --force to overwrite.\n`) - process.exit(1) + throw new Error(`${relDest} already exists. Run with --force to overwrite.`) } await mkdir(dirname(dest), { recursive: true }) @@ -266,21 +265,24 @@ export async function _runInit(opts: { async function _runIndex(opts: { path: string out: string - includeTextFiles: boolean + content: ContentType[] + fromPath?: (path: string, opts: { content: ContentType[] }) => Promise + fromGit?: (path: string, opts: { content: ContentType[] }) => Promise }): Promise { - const { path, out, includeTextFiles } = opts + const { path, out, content } = opts + const fromPath = opts.fromPath ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromPath(p, o)) + const fromGit = opts.fromGit ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromGit(p, o)) const index = isGitUrl(path) - ? await CspIndex.fromGit(path, { includeTextFiles }) - : await CspIndex.fromPath(path, { includeTextFiles }) + ? await fromGit(path, { content }) + : await fromPath(path, { content }) await mkdir(out, { recursive: true }) await index.save(out) } export async function runCli(argv: string[], options: RunOptions = {}): Promise { - // The first arg determines whether this is the MCP entrypoint or a subcommand. - if (argv.length === 0 || (argv[0] !== undefined && !CLI_DISPATCH_ARGS.has(argv[0]) && !argv[0].startsWith('-'))) { - // No subcommand recognized — treat as MCP (mirrors semble's default). - // But for csp the README requires `csp mcp` — so we still treat bare invocation as help. + // Bare invocation prints help and exits 0; unknown subcommands are handled + // below (after parsing) so they exit 1. + if (argv.length === 0) { _printHelp() return 0 } @@ -290,112 +292,132 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise< return 0 } - const { command, positional, flags } = parseArgs(argv) - - if (command === 'init') { - const agentRaw = _getStringFlag(flags, 'agent', 'a') ?? DEFAULT_AGENT - const agent = _coerceAgent(agentRaw) - const force = _getBoolFlag(flags, 'force') - await _runInit({ - agent, - force, - ...(options.cwd ? { cwd: options.cwd() } : {}), - ...(options.readAgentFile ? { readAgentFile: options.readAgentFile } : {}), - ...(options.writeFileImpl ? { writeFileImpl: options.writeFileImpl } : {}), - }) - return 0 - } + try { + const { command, positional, flags } = parseArgs(argv) - if (command === 'index') { - const path = positional[0] ?? '.' - const out = _getStringFlag(flags, 'out', 'o') - if (out === undefined) { - process.stderr.write('--out / -o is required for `index`.\n') + if (command === null || !CLI_DISPATCH_ARGS.has(command)) { + process.stderr.write(`Unknown command: ${command ?? ''}\n`) + _printHelp() return 1 } - const includeTextFiles = _getBoolFlag(flags, 'include-text-files') - await _runIndex({ path, out, includeTextFiles }) - return 0 - } - if (command === 'savings') { - const verbose = _getBoolFlag(flags, 'verbose') - const fmt = options.formatSavings ?? formatSavingsReport - process.stdout.write(fmt({ verbose })) - return 0 - } + if (command === 'init') { + const agentRaw = _getStringFlag(flags, 'agent', 'a') ?? DEFAULT_AGENT + const agent = _coerceAgent(agentRaw) + const force = _getBoolFlag(flags, 'force') + await _runInit({ + agent, + force, + ...(options.cwd ? { cwd: options.cwd() } : {}), + ...(options.readAgentFile ? { readAgentFile: options.readAgentFile } : {}), + ...(options.writeFileImpl ? { writeFileImpl: options.writeFileImpl } : {}), + }) + return 0 + } - if (command === 'mcp') { - const path = positional[0] - const ref = _getStringFlag(flags, 'ref') - const content = _resolveContent(_getContentFlag(flags), _getBoolFlag(flags, 'include-text-files')) - const serveImpl = options.serveMcp ?? ((p, o) => serve(p, o)) - await serveImpl(path, { ref, content }) - return 0 - } + if (command === 'index') { + const path = positional[0] ?? '.' + const out = _getStringFlag(flags, 'out', 'o') + if (out === undefined) { + process.stderr.write('--out / -o is required for `index`.\n') + return 1 + } + const content = _resolveContent(_getContentFlag(flags), _getBoolFlag(flags, 'include-text-files')) + await _runIndex({ + path, + out, + content, + ...(options.fromPath ? { fromPath: options.fromPath } : {}), + ...(options.fromGit ? { fromGit: options.fromGit } : {}), + }) + return 0 + } - // search and find-related share index loading - if (command === 'search' || command === 'find-related') { - const indexPath = _getStringFlag(flags, 'index') - let index: CspIndex - if (indexPath !== undefined) { - const loadImpl = options.readIndex ?? ((p: string) => CspIndex.loadFromDisk(p)) - index = await loadImpl(indexPath) + if (command === 'savings') { + const verbose = _getBoolFlag(flags, 'verbose') + const fmt = options.formatSavings ?? formatSavingsReport + process.stdout.write(fmt({ verbose })) + return 0 } - else { - const pathArg = command === 'search' ? positional[1] ?? '.' : positional[2] ?? '.' + + if (command === 'mcp') { + const path = positional[0] + const ref = _getStringFlag(flags, 'ref') const content = _resolveContent(_getContentFlag(flags), _getBoolFlag(flags, 'include-text-files')) - const fromPath = options.fromPath ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromPath(p, o)) - const fromGit = options.fromGit ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromGit(p, o)) - index = isGitUrl(pathArg) - ? await fromGit(pathArg, { content }) - : await fromPath(pathArg, { content }) + const serveImpl = options.serveMcp ?? ((p, o) => serve(p, o)) + await serveImpl(path, { ref, content }) + return 0 } - const topK = _getNumberFlag(flags, 'top-k', 'k') ?? 5 + // search and find-related share index loading + if (command === 'search' || command === 'find-related') { + const indexPath = _getStringFlag(flags, 'index') + let index: CspIndex + if (indexPath !== undefined) { + const loadImpl = options.readIndex ?? ((p: string) => CspIndex.loadFromDisk(p)) + index = await loadImpl(indexPath) + } + else { + const pathArg = command === 'search' ? positional[1] ?? '.' : positional[2] ?? '.' + const content = _resolveContent(_getContentFlag(flags), _getBoolFlag(flags, 'include-text-files')) + const fromPath = options.fromPath ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromPath(p, o)) + const fromGit = options.fromGit ?? ((p: string, o: { content: ContentType[] }) => CspIndex.fromGit(p, o)) + index = isGitUrl(pathArg) + ? await fromGit(pathArg, { content }) + : await fromPath(pathArg, { content }) + } - if (command === 'search') { - const query = positional[0] - if (query === undefined) { - process.stderr.write('search requires a .\n') + const topK = _getNumberFlag(flags, 'top-k', 'k') ?? 5 + + if (command === 'search') { + const query = positional[0] + if (query === undefined) { + process.stderr.write('search requires a .\n') + return 1 + } + const results = await index.search(query, { topK }) + const out = (!results || results.length === 0) + ? { error: 'No results found.' } + : formatResults(query, results) + process.stdout.write(`${JSON.stringify(out)}\n`) + return 0 + } + + // find-related + const filePath = positional[0] + const lineRaw = positional[1] + if (filePath === undefined || lineRaw === undefined) { + process.stderr.write('find-related requires .\n') + return 1 + } + if (!/^-?\d+$/.test(lineRaw)) { + process.stderr.write(`line must be an integer, got: ${lineRaw}\n`) + return 1 + } + const line = Number.parseInt(lineRaw, 10) + const chunk = resolveChunk(index.chunks, filePath, line) + if (chunk === undefined || chunk === null) { + process.stderr.write(`No chunk found at ${filePath}:${line}.\n`) return 1 } - const results = await index.search(query, { topK }) - const out = (!results || results.length === 0) - ? { error: 'No results found.' } - : formatResults(query, results) + const related = await index.findRelated(chunk, { topK }) + const out = (!related || related.length === 0) + ? { error: `No related chunks found for ${filePath}:${line}.` } + : formatResults(`Chunks related to ${filePath}:${line}`, related) process.stdout.write(`${JSON.stringify(out)}\n`) return 0 } - // find-related - const filePath = positional[0] - const lineRaw = positional[1] - if (filePath === undefined || lineRaw === undefined) { - process.stderr.write('find-related requires .\n') - return 1 - } - if (!/^-?\d+$/.test(lineRaw)) { - process.stderr.write(`line must be an integer, got: ${lineRaw}\n`) - return 1 - } - const line = Number.parseInt(lineRaw, 10) - const chunk = resolveChunk(index.chunks, filePath, line) - if (chunk === undefined || chunk === null) { - process.stderr.write(`No chunk found at ${filePath}:${line}.\n`) - return 1 - } - const related = await index.findRelated(chunk, { topK }) - const out = (!related || related.length === 0) - ? { error: `No related chunks found for ${filePath}:${line}.` } - : formatResults(`Chunks related to ${filePath}:${line}`, related) - process.stdout.write(`${JSON.stringify(out)}\n`) - return 0 + // Unreachable: CLI_DISPATCH_ARGS gate above filters unknown commands. + process.stderr.write(`Unknown command: ${command}\n`) + _printHelp() + return 1 + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + process.stderr.write(`${message}\n`) + return 1 } - - process.stderr.write(`Unknown command: ${command ?? ''}\n`) - _printHelp() - return 1 } function _coerceAgent(raw: string): Agent {