Skip to content

feat(cli): port subcommands (search/index/find-related/init/savings/mcp) from semble#15

Merged
amondnet merged 2 commits into
mainfrom
feat/unit-15-cli
May 28, 2026
Merged

feat(cli): port subcommands (search/index/find-related/init/savings/mcp) from semble#15
amondnet merged 2 commits into
mainfrom
feat/unit-15-cli

Conversation

@amondnet

@amondnet amondnet commented May 28, 2026

Copy link
Copy Markdown
Contributor

Unit 15 — src/cli.ts

Replaces the placeholder src/cli.ts with a port of src/semble/cli.py, plus a new src/cli.test.ts covering the parser, the init flow, search dispatch, savings, mcp routing, and find-related validation.

Subcommands implemented

  • csp search <query> [path] — flags: --top-k/-k, --index, --content code|docs|config|all, --include-text-files (deprecated)
  • csp index <path> -o <out> — flag: --include-text-files
  • csp find-related <file_path> <line> [path] — flags: --top-k, --index, --content
  • csp init [--agent claude|copilot|cursor|gemini|kiro|opencode] [--force] — writes to .<agent>/agents/csp-search.md (or .github/agents/csp-search.md for copilot)
  • csp savings [--verbose]
  • csp mcp [path] [--ref] [--content ...] — starts MCP server (explicit subcommand, divergence from semble's bare-binary default)

CLI parser

Commander isn't yet in package.json (Unit 0 territory), so this port uses a small purpose-built parseArgs that handles:

  • --flag value, --flag=value, boolean --flag
  • short flags (-k 5, -o idx)
  • multi-value --content code docs
  • -- end-of-options marker

runCli(argv, options) accepts injection points for the CLI's external dependencies (fromPath, fromGit, readIndex, serveMcp, formatSavings, readAgentFile, etc.), making the surface unit-testable without real indexing or model loads.

Agent markdown loading

Agent files live at src/agents/<agent>.md and are loaded at runtime via new URL('./agents/${agent}.md', import.meta.url) + fs.readFile. For the published package, tsdown --unbundle may not copy markdown automatically — if that turns out to be the case, a follow-up should inline the markdown into TS (export const CLAUDE_MD = '...') or configure tsdown to copy assets.

Tests (src/cli.test.ts, 29 tests, all passing)

  • Agent enum values
  • _agentPath for claude/copilot/cursor/opencode
  • parseArgs for sub-command + positional, --flag value, --flag=value, boolean, multi-value --content, short -k
  • _resolveContent for default/all/include-text-files/multiple/unknown
  • runCli --help mentions every subcommand
  • csp init --agent claude writes .claude/agents/csp-search.md to a tmp cwd
  • csp init --agent copilot writes .github/agents/csp-search.md
  • csp init without --force errors if file exists (verifies original content preserved); with --force overwrites
  • csp search foo . calls index.search('foo', { topK }) with the right topK
  • non-empty results formatted via formatResults, JSON-stringified
  • csp savings / --verbose forwards the flag
  • csp mcp dispatches to the injected serveMcp with path/ref/content (locks in the dispatch fix below)
  • csp find-related src/auth.ts 42abc . errors with exit 1 and line must be an integer (locks in the validation fix below)

Code-review findings fixed in this PR

  1. csp mcp was misrouted to help'mcp' was missing from CLI_DISPATCH_ARGS, so the routing predicate fell into the "unknown subcommand → print help" branch. Added 'mcp' to the set. Verified with new test + bun run --bun src/cli.ts mcp now reaches the serve stub.
  2. Dead _runMcp helper — removed; the mcp branch inlines the call to serve directly.
  3. Number.parseInt accepted trailing junk in find-related <line> (42abc silently parsed as 42, diverging from semble's argparse type=int). Added an /^-?\d+$/ validation gate before parseInt.

Deferred findings (follow-up issue worth filing)

  • Greedy --content — multi-value parsing for --content greedily consumes every following non-dash token (matches semble's argparse nargs="+" behavior). csp search foo --content code . swallows the trailing . path. Mitigation: README examples place --content at the end. Long-term fix is to limit to known choices or require explicit =.
  • Subcommand --helpcsp savings --help runs the savings command instead of showing help; per-subcommand help isn't implemented.
  • Windows path detection — the invokedDirectly check uses endsWith('/cli.ts'), which won't match backslash-style argv on Windows. Bun on Windows would mostly normalize, but worth replacing with import.meta.main when available.
  • Long-flag value detection corner case--ref -release (refs starting with a dash) misparses as boolean. Unusual but documented for completeness.

Stubs / sibling integration

The following files are stubs to make the CLI typecheck and let the tests run; they are NOT part of this PR (intentionally left untracked) and belong to other units:

  • src/types.tsContentType, Chunk, SearchResult
  • src/utils.tsisGitUrl, resolveChunk, formatResults
  • src/stats.tsformatSavingsReport
  • src/indexing/index.tsCspIndex class with stubbed static methods
  • src/mcp/server.tsserve
  • src/agents/*.md — initial port of upstream agent markdown with semblecsp renames

If the parallel unit owners create the same files, the stubs here can be discarded — src/cli.ts only depends on the exported names listed in the task brief.

Local typechecking note

tsconfig.json currently lacks allowImportingTsExtensions, which makes tsc --noEmit reject the .ts imports the project uses. I confirmed locally that adding "allowImportingTsExtensions": true resolves it, but per task scope I did not modify tsconfig.json. Bun runs the file fine without that flag.

Verification

bun test src/cli.test.ts   →   29 pass, 0 fail
bun run --bun src/cli.ts --help   →   prints full help
bun run --bun src/cli.ts mcp   →   reaches serve() stub (correct dispatch)

Summary by cubic

Replaces the placeholder CLI with a TypeScript port of the semble CLI, adding search, index, find-related, init, savings, and mcp, plus 29 unit tests. Improves error handling and makes --content consistent across commands.

  • New Features

    • Subcommands: search, index, find-related, init, savings, mcp.
    • Small parseArgs with --flag value, --flag=value, short flags, multi-value --content, and --.
    • Index from path or git, or load via --index; outputs JSON.
    • init writes agent file to .<agent>/agents/csp-search.md (Copilot to .github/agents/csp-search.md); mcp starts the MCP server with --ref and --content.
  • Bug Fixes

    • mcp now routes to the server instead of showing help.
    • find-related requires <line> to be an integer (rejects values like 42abc).
    • index, search, find-related, and mcp all resolve --content (code|docs|config|all); --include-text-files is deprecated (use --content all).
    • runCli now returns exit 1 with a stderr message on errors (e.g., unknown subcommand, invalid --agent/--content); _runInit throws and is translated to exit 1.

Written for commit a911c84. Summary will update on new commits.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request ports the CLI implementation from Python to TypeScript (src/cli.ts) and introduces a comprehensive test suite in src/cli.test.ts. The review feedback suggests improving the CLI's robustness and testability by throwing errors instead of calling process.exit(1) directly in _runInit (which also simplifies the unit tests), wrapping runCli in a try/catch block to prevent unhandled crashes, and updating the index command to properly resolve and pass content types using the --content flag.

Comment thread src/cli.ts
Comment thread src/cli.ts
Comment thread src/cli.ts Outdated
Comment thread src/cli.test.ts Outdated
Comment thread src/cli.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 2 files

Architecture diagram
sequenceDiagram
    participant CLI as CLI Entrypoint
    participant Parser as parseArgs()
    participant AgentFile as Agent File Loader
    participant Index as CspIndex
    participant Stats as formatSavingsReport
    participant MCP as MCP Server
    participant FS as File System
    participant Git as Git Remote

    Note over CLI,Git: Subcommand dispatch flow

    CLI->>Parser: parseArgs(argv)
    Parser-->>CLI: { command, positional, flags }

    alt command = "init"
        CLI->>CLI: _coerceAgent(flags.agent)
        CLI->>AgentFile: _readAgentFile(agent)
        AgentFile->>FS: readFile via import.meta.url
        FS-->>AgentFile: agent markdown content
        AgentFile-->>CLI: markdown string
        CLI->>FS: stat(_agentPath)
        alt file exists AND not --force
            FS-->>CLI: exists
            CLI->>CLI: process.exit(1) with error
        else not exists OR --force
            CLI->>FS: mkdir + writeFile
            FS-->>CLI: written
            CLI->>CLI: process.stdout.write "Created ..."
        end
    end

    alt command = "search"
        CLI->>CLI: _getNumberFlag(topK)
        CLI->>CLI: _resolveContent(flags)
        alt --index provided
            CLI->>Index: fromPath(path, content) or fromGit(url, content)
        else path provided
            CLI->>Index: fromPath(path, content)
        end
        Index-->>CLI: CspIndex instance
        CLI->>Index: search(query, { topK })
        Index-->>CLI: SearchResult[]
        alt empty results
            CLI->>CLI: JSON output {"error": "No results found."}
        else results exist
            CLI->>CLI: formatResults(results) → JSON to stdout
        end
    end

    alt command = "index"
        CLI->>CLI: isGitUrl(path)?
        alt git URL
            CLI->>Git: CspIndex.fromGit(url, { includeTextFiles })
            Git-->>Index: index built from remote
        else local path
            CLI->>FS: CspIndex.fromPath(path, { includeTextFiles })
            FS-->>Index: index built from files
        end
        Index->>FS: index.save(out)
        FS-->>Index: saved
    end

    alt command = "find-related"
        CLI->>CLI: validate line is /^-?\d+$/
        alt invalid line
            CLI->>CLI: process.exit(1) "line must be an integer"
        end
        CLI->>Index: fromPath(path, content) or fromGit(url, content)
        CLI->>Index: search(similarity, { topK })
        Index-->>CLI: results to stdout
    end

    alt command = "savings"
        CLI->>Stats: formatSavings({ verbose })
        Stats-->>CLI: report string
        CLI->>CLI: stdout report
    end

    alt command = "mcp"
        CLI->>MCP: serve(path, { ref, content })
        MCP-->>CLI: server started
    end

    alt command = "--help" or "-h"
        CLI->>CLI: _printHelp() → stdout
    end

    Note over CLI,Git: Key flags parsed via parseArgs
    Note over Parser: --flag value, --flag=value, boolean<br/>short -k, multi-value --content<br/>-- end-of-options
    Note over CLI: Flags: --top-k/-k, --content, --index<br/>--agent/-a, --force, -o/--out, --ref<br/>--verbose, --include-text-files (deprecated)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/cli.ts
Comment thread src/cli.ts
Comment thread src/cli.ts Outdated
Comment thread src/cli.ts Outdated
Comment thread src/cli.ts Outdated
- _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).

@amondnet amondnet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied 8, deferred 2.

Applied (a911c84):

  • gemini #3318894235 / cubic #3318943092 — _runInit throws Error instead of process.exit(1)
  • gemini #3318894249 — _runIndex accepts content: ContentType[]
  • gemini #3318894280 — index dispatch resolves --content / --include-text-files
  • gemini #3318894296 — test simplified to rejects.toThrow
  • gemini #3318894307 / cubic #3318943095 — runCli wraps dispatch in try/catch; user-friendly stderr + exit 1
  • cubic #3318943072 — unknown subcommands now exit 1 (was 0)

New tests: unknown subcommand, invalid --agent, invalid --content, runCli-level translation of init collision, csp index --content content forwarding. bun test src/cli.test.ts34 pass, 0 fail.

Deferred (matches upstream semble; tracked in PR description's deferred-findings list):

  • cubic #3318943061 — greedy --content parsing (matches argparse nargs="+")
  • cubic #3318943068 — --agent codex (upstream semble Agent enum also omits codex; no src/agents/codex.md template exists yet)

@amondnet amondnet self-assigned this May 28, 2026
@amondnet amondnet merged commit 2042846 into main May 28, 2026
1 check passed
@amondnet amondnet deleted the feat/unit-15-cli branch May 28, 2026 16:08
This was referenced Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant