diff --git a/.git-ai/lancedb.tar.gz b/.git-ai/lancedb.tar.gz index 55cc7c2..90c06a3 100644 --- a/.git-ai/lancedb.tar.gz +++ b/.git-ai/lancedb.tar.gz @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4486536ab53f21b3b7b30f42440bd748455bd2af8346245d26de813562e70aae -size 270647 +oid sha256:74df2c7458d0ab1fff61b2e36bd294c8bba6e20f6d093c4ea3ebe8b0afc37765 +size 298295 diff --git a/AGENTS.md b/AGENTS.md index 2f0b41b..10297ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # PROJECT KNOWLEDGE BASE -**Generated:** 2026-01-31 23:03 -**Commit:** 680e8f2 -**Branch:** copilot/add-index-commit-id-feature +**Generated:** 2026-02-02 01:45 +**Commit:** bd3baf8 +**Branch:** refactor/cli-commands-architecture ## OVERVIEW git-ai CLI + MCP server. TypeScript implementation for AI-powered Git operations with semantic search, DSR (Deterministic Semantic Record), and graph-based code analysis. Indices stored in `.git-ai/`. @@ -11,7 +11,14 @@ git-ai CLI + MCP server. TypeScript implementation for AI-powered Git operations ``` git-ai-cli-v2/ ├── src/ -│ ├── commands/ # CLI subcommands (ai, graph, query, etc.) +│ ├── cli/ # CLI command architecture (NEW: registry + handlers + schemas) +│ │ ├── types.ts # Core types, executeHandler +│ │ ├── registry.ts # Handler registry (24 commands) +│ │ ├── helpers.ts # Shared utilities +│ │ ├── schemas/ # Zod validation schemas +│ │ ├── handlers/ # Business logic handlers +│ │ └── commands/ # Commander.js wrappers +│ ├── commands/ # Command aggregator (ai.ts only) │ ├── core/ # Indexing, DSR, graph, storage, parsers │ └── mcp/ # MCP server implementation ├── test/ # Node test runner tests @@ -22,7 +29,11 @@ git-ai-cli-v2/ ## WHERE TO LOOK | Task | Location | |------|----------| -| CLI commands | `src/commands/*.ts` | +| CLI commands | `src/cli/commands/*.ts` (new architecture) | +| CLI handlers | `src/cli/handlers/*.ts` (business logic) | +| CLI schemas | `src/cli/schemas/*.ts` (Zod validation) | +| Handler registry | `src/cli/registry.ts` (all 24 commands) | +| Command aggregator | `src/commands/ai.ts` (entry point) | | Indexing logic | `src/core/indexer.ts`, `src/core/indexerIncremental.ts` | | DSR (commit records) | `src/core/dsr/`, `src/core/dsr.ts` | | Graph queries | `src/core/cozo.ts`, `src/core/astGraph.ts` | diff --git a/README.md b/README.md index 6f2bc42..d6a42a9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![license](https://img.shields.io/github/license/mars167/git-ai-cli)](./LICENSE) [![npm version](https://img.shields.io/npm/v/@mars167/git-ai)](https://www.npmjs.com/package/@mars167/git-ai) [![npm downloads](https://img.shields.io/npm/dm/@mars167/git-ai)](https://www.npmjs.com/package/@mars167/git-ai) -[![Agent Skill](https://img.shields.io/badge/Agent_Skill-git--ai--mcp-blue)](https://skills.sh) +[![Agent Skill](https://img.shields.io/badge/Agent_Skill-git--ai--code--search-blue)](https://skills.sh) [🇨🇳 简体中文](./README.zh-CN.md) | **English** @@ -22,7 +22,7 @@ **For AI Agents (Claude Code, Cursor, Windsurf, etc.)** ```bash -npx skills add mars167/git-ai-cli/skills/git-ai-mcp +npx skills add mars167/git-ai-cli/skills/git-ai-code-search ``` **For CLI Usage** @@ -286,15 +286,63 @@ That's it! 3 steps to get started, immediately begin deep understanding of your --- +## 🛠️ Troubleshooting + +### Windows Installation Issues + +git-ai uses [CozoDB](https://github.com/cozodb/cozo) for AST graph queries. On Windows, if you encounter installation errors related to `cozo-node`, try these solutions: + +**Option 1: Use Gitee Mirror (Recommended for users in China)** + +```bash +npm install -g @mars167/git-ai --cozo_node_prebuilt_binary_host_mirror=https://gitee.com/cozodb/cozo-lib-nodejs/releases/download/ +``` + +**Option 2: Configure npm proxy** + +If you're behind a corporate firewall or proxy: + +```bash +npm config set proxy http://your-proxy:port +npm config set https-proxy http://your-proxy:port +npm install -g @mars167/git-ai +``` + +**Option 3: Manual binary download** + +1. Download the Windows binary from [cozo-lib-nodejs releases](https://github.com/cozodb/cozo-lib-nodejs/releases) +2. Look for `6-win32-x64.tar.gz` (for 64-bit Windows) +3. Extract to `node_modules/cozo-node/native/6/` + +**Verify installation:** + +```bash +git-ai ai status --path . +``` + +If you see graph-related features working, installation was successful. + +### Other Native Dependencies + +git-ai also uses these native packages that may require similar troubleshooting: +- `onnxruntime-node` - For semantic embeddings +- `tree-sitter` - For code parsing +- `@lancedb/lancedb` - For vector database + +Most issues are resolved by ensuring a stable network connection or using a mirror. + +--- + ## 🤖 AI Agent Integration git-ai provides a standard MCP Server that seamlessly integrates with: - **Claude Desktop**: The most popular local AI programming assistant +- **Cursor**: AI-powered code editor - **Trae**: Powerful AI-driven IDE - **Continue.dev**: VS Code AI plugin -### Claude Desktop Configuration Example +### Single Agent (stdio mode - default) Add to `~/.claude/claude_desktop_config.json`: @@ -309,6 +357,23 @@ Add to `~/.claude/claude_desktop_config.json`: } ``` +### Multiple Agents (HTTP mode) + +When you need multiple AI agents to connect simultaneously (e.g., Claude Code + Cursor): + +```bash +# Start HTTP server (supports multiple clients) +git-ai ai serve --http --port 3000 +``` + +Then configure each agent to connect to `http://localhost:3000/mcp`. + +**HTTP mode features:** +- Multiple concurrent sessions +- Health check endpoint: `http://localhost:3000/health` +- Session management with automatic cleanup +- Optional stateless mode for load-balanced setups: `--stateless` + Then restart Claude Desktop and start conversing: > "Help me analyze this project's architecture, find all payment-related code" @@ -319,8 +384,8 @@ Claude will automatically invoke git-ai tools to provide deep analysis. We provide carefully designed Agent templates to help AI use git-ai better: -- [Skill Template](./templates/agents/common/skills/git-ai-mcp/SKILL.md): Guides Agents on how to use tools -- [Rule Template](./templates/agents/common/rules/git-ai-mcp/RULE.md): Constrains Agent behavior +- [Skill Template](./templates/agents/common/skills/git-ai-code-search/SKILL.md): Guides Agents on how to use tools +- [Rule Template](./templates/agents/common/rules/git-ai-code-search/RULE.md): Constrains Agent behavior Skills/Rules docs (Markdown/YAML) are indexed as part of semantic search, so agents can retrieve MCP guidance via `semantic_search`. diff --git a/README.zh-CN.md b/README.zh-CN.md index b3cdce2..9e7bf01 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,7 +18,7 @@ **AI Agent 技能安装 (Claude Code, Cursor, Windsurf 等)** ```bash -npx skills add mars167/git-ai-cli/skills/git-ai-mcp +npx skills add mars167/git-ai-cli/skills/git-ai-code-search ``` **CLI 工具安装** @@ -283,15 +283,63 @@ git-ai ai graph callers authenticateUser --- +## 🛠️ 故障排除 + +### Windows 安装问题 + +git-ai 使用 [CozoDB](https://github.com/cozodb/cozo) 来实现 AST 图查询功能。在 Windows 上,如果遇到 `cozo-node` 相关的安装错误,可以尝试以下解决方案: + +**方案一:使用 Gitee 镜像(推荐国内用户使用)** + +```bash +npm install -g @mars167/git-ai --cozo_node_prebuilt_binary_host_mirror=https://gitee.com/cozodb/cozo-lib-nodejs/releases/download/ +``` + +**方案二:配置 npm 代理** + +如果你在公司防火墙或代理后面: + +```bash +npm config set proxy http://your-proxy:port +npm config set https-proxy http://your-proxy:port +npm install -g @mars167/git-ai +``` + +**方案三:手动下载二进制文件** + +1. 从 [cozo-lib-nodejs releases](https://github.com/cozodb/cozo-lib-nodejs/releases) 下载 Windows 二进制文件 +2. 找到 `6-win32-x64.tar.gz`(64 位 Windows) +3. 解压到 `node_modules/cozo-node/native/6/` 目录 + +**验证安装:** + +```bash +git-ai ai status --path . +``` + +如果看到 graph 相关功能正常工作,说明安装成功。 + +### 其他原生依赖 + +git-ai 还使用了以下原生包,可能需要类似的故障排除: +- `onnxruntime-node` - 用于语义向量生成 +- `tree-sitter` - 用于代码解析 +- `@lancedb/lancedb` - 用于向量数据库 + +大多数问题可以通过确保稳定的网络连接或使用镜像来解决。 + +--- + ## 🤖 AI Agent 集成 git-ai 提供标准的 MCP Server,可与以下 AI Agent 无缝集成: - **Claude Desktop**:最流行的本地 AI 编程助手 +- **Cursor**:AI 驱动的代码编辑器 - **Trae**:强大的 AI 驱动 IDE - **Continue.dev**:VS Code AI 插件 -### Claude Desktop 配置示例 +### 单客户端模式(stdio,默认) 在 `~/.claude/claude_desktop_config.json` 中添加: @@ -306,6 +354,23 @@ git-ai 提供标准的 MCP Server,可与以下 AI Agent 无缝集成: } ``` +### 多客户端模式(HTTP) + +当你需要多个 AI Agent 同时连接时(如同时使用 Claude Code 和 Cursor): + +```bash +# 启动 HTTP 服务(支持多客户端) +git-ai ai serve --http --port 3000 +``` + +然后配置每个 Agent 连接到 `http://localhost:3000/mcp`。 + +**HTTP 模式特性:** +- 支持多个并发会话 +- 健康检查端点:`http://localhost:3000/health` +- 自动管理会话生命周期 +- 可选无状态模式,用于负载均衡场景:`--stateless` + 然后重启 Claude Desktop,即可开始对话: > "帮我分析这个项目的架构,找出所有与支付相关的代码" @@ -316,8 +381,8 @@ Claude 会自动调用 git-ai 的工具,为你提供深入的分析。 我们提供了精心设计的 Agent 模版,帮助 AI 更好地使用 git-ai: -- [Skill 模版](./templates/agents/common/skills/git-ai-mcp/SKILL.md):指导 Agent 如何使用工具 -- [Rule 模版](./templates/agents/common/rules/git-ai-mcp/RULE.md):约束 Agent 的行为 +- [Skill 模版](./templates/agents/common/skills/git-ai-code-search/SKILL.md):指导 Agent 如何使用工具 +- [Rule 模版](./templates/agents/common/rules/git-ai-code-search/RULE.md):约束 Agent 的行为 Skills/Rules 文档(Markdown/YAML)会被纳入语义索引,便于通过 `semantic_search` 检索 MCP 指南。 diff --git a/docs/CLI_REFACTOR_DESIGN.md b/docs/CLI_REFACTOR_DESIGN.md new file mode 100644 index 0000000..cd3ea12 --- /dev/null +++ b/docs/CLI_REFACTOR_DESIGN.md @@ -0,0 +1,766 @@ +# CLI Command Architecture Refactor - Design Document + +**Date:** 2026-02-01 +**Author:** AI Assistant (Sisyphus) +**Status:** DESIGN PHASE +**Branch:** `refactor/cli-commands-architecture` +**Related:** PR #15 (MCP Server Refactor) + +--- + +## Executive Summary + +Refactor git-ai CLI commands to match the successful MCP server architecture (handler registry + Zod validation + modular design). This achieves architectural consistency, enables code reuse between CLI and MCP, and improves testability. + +--- + +## Current State Analysis + +### Existing CLI Structure + +``` +bin/git-ai.ts (62 lines) + ├── Git passthrough logic + └── Commander program setup + └── aiCommand + +src/commands/ + ├── ai.ts (29 lines) - Main command aggregator + ├── graph.ts (208 lines) - 7 subcommands + ├── index.ts (85 lines) + ├── semantic.ts (129 lines) + ├── query.ts (...) + ├── dsr.ts (...) - 4 subcommands + ├── checkIndex.ts (...) + ├── status.ts (...) + ├── pack.ts (...) + ├── unpack.ts (...) + ├── hooks.ts (...) + ├── serve.ts (...) + └── trae.ts (...) - Agent installation +``` + +### Identified Issues + +| Issue | Impact | Example | +|-------|--------|---------| +| **Inline Action Handlers** | Hard to test, reuse | All commands have logic in `.action()` | +| **Duplicate Boilerplate** | Maintenance burden | Path resolution, logging, error handling repeated 13+ times | +| **Inconsistent Validation** | Type safety gaps | Manual `String()`, `Number()` coercion scattered | +| **No Abstraction Layer** | Tight coupling | CLI directly calls core functions | +| **Varied Error Handling** | Inconsistent UX | Some exit(1), some exit(2), different JSON formats | +| **Option Inconsistency** | Poor UX | `--path` vs `-p`, `--topk` vs `-k` naming varies | + +### Example: Current Pattern (graph.ts) + +```typescript +export const graphCommand = new Command('graph') + .addCommand( + new Command('find') + .argument('', 'Name prefix') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--lang ', 'Language: auto|all|...', 'auto') + .action(async (prefix, options) => { + // 40+ lines of inline logic: + // - Path resolution + // - Index checking + // - Language resolution + // - Query execution + // - Error handling + // - JSON output + // - Logging + }) + ) + // ... 6 more subcommands with similar patterns +``` + +**Problems:** +- Action handler = 40+ lines of inline code +- Cannot test handler independently +- Cannot reuse logic in MCP or other contexts +- Duplicate error handling in every subcommand + +--- + +## Target Architecture + +### New CLI Structure + +``` +src/cli/ +├── registry.ts # Command registry with Zod validation +├── types.ts # Shared CLI types & helpers +├── handlers/ # Business logic (testable, reusable) +│ ├── graphHandlers.ts +│ ├── indexHandlers.ts +│ ├── semanticHandlers.ts +│ ├── dsrHandlers.ts +│ └── ... +├── schemas/ # Zod validation schemas +│ ├── graphSchemas.ts +│ ├── indexSchemas.ts +│ ├── semanticSchemas.ts +│ └── ... +└── commands/ # Commander command definitions (thin) + ├── graphCommands.ts + ├── indexCommand.ts + ├── semanticCommand.ts + └── ... + +src/commands/ +├── ai.ts # Main aggregator (kept for compatibility) +└── (deprecated - migrate to src/cli/) + +bin/git-ai.ts # Entry point (minimal changes) +``` + +### New Pattern: Handler Registry + +```typescript +// src/cli/schemas/graphSchemas.ts +export const FindSymbolsSchema = z.object({ + prefix: z.string().min(1), + path: z.string().default('.'), + lang: z.enum(['auto', 'all', 'java', 'ts', 'python', 'go', 'rust', 'c', 'markdown', 'yaml']).default('auto'), +}); + +// src/cli/handlers/graphHandlers.ts +export async function handleFindSymbols(input: z.infer): Promise { + const log = createLogger({ component: 'cli', cmd: 'ai graph find' }); + const startedAt = Date.now(); + + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const status = await checkIndex(repoRoot); + if (!status.ok) { + return { ok: false, reason: 'index_incompatible', ...status }; + } + + const langs = resolveLangs(status.found.meta ?? null, input.lang); + const allRows: any[] = []; + + for (const lang of langs) { + const result = await runAstGraphQuery(repoRoot, buildFindSymbolsQuery(lang), { prefix: input.prefix, lang }); + const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + allRows.push(...rows); + } + + log.info('ast_graph_find', { ok: true, repoRoot, prefix: input.prefix, rows: allRows.length, duration_ms: Date.now() - startedAt }); + + return { + ok: true, + repoRoot, + lang: input.lang, + result: { headers: ['ref_id', 'file', 'lang', 'name', 'kind', 'signature', 'start_line', 'end_line'], rows: allRows } + }; +} + +// src/cli/registry.ts +export const cliHandlers = { + 'graph:find': { + schema: FindSymbolsSchema, + handler: handleFindSymbols, + }, + // ... all other commands +}; + +// src/cli/commands/graphCommands.ts +export const graphCommand = new Command('graph') + .description('AST graph search powered by CozoDB') + .addCommand( + new Command('find') + .description('Find symbols by name prefix') + .argument('', 'Name prefix (case-insensitive)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--lang ', 'Language: auto|all|...', 'auto') + .action(async (prefix, options) => { + await executeHandler('graph:find', { prefix, ...options }); + }) + ); +``` + +### Benefits of New Pattern + +| Benefit | Explanation | +|---------|-------------| +| **Testable** | Handlers are pure functions accepting validated input | +| **Reusable** | Same handler can be called from CLI, MCP, or tests | +| **Type-Safe** | Zod validates all inputs before handler execution | +| **Consistent** | All commands follow identical pattern | +| **Maintainable** | Business logic isolated from CLI framework | +| **Extensible** | Easy to add new commands via registry | + +--- + +## CLI ↔ MCP Relationship + +### Current State + +Both CLI and MCP duplicate similar logic: + +``` +CLI: src/commands/graph.ts (208 lines) + └─> core/astGraphQuery.ts + +MCP: src/mcp/handlers/astGraphHandlers.ts (142 lines) + └─> core/astGraphQuery.ts +``` + +**Problem:** Two implementations of the same business logic. + +### Target State: Shared Handlers + +``` +src/handlers/ # NEW: Shared business logic +├── graphHandlers.ts # Used by BOTH CLI and MCP +├── indexHandlers.ts +├── semanticHandlers.ts +└── ... + +src/cli/ +├── commands/graphCommands.ts # CLI: Commander wrappers +└── registry.ts # CLI: Zod validation + handler dispatch + +src/mcp/ +├── tools/graphTools.ts # MCP: Tool metadata +├── handlers/graphHandlers.ts # MCP: Adapter to shared handlers +└── registry.ts # MCP: Zod validation + handler dispatch +``` + +**Strategy:** + +1. **Phase 1:** Refactor CLI to new architecture (this PR) +2. **Phase 2:** Extract shared handlers to `src/handlers/` (future PR) +3. **Phase 3:** Refactor MCP to use shared handlers (future PR) + +**Why Phased Approach:** +- Reduces risk per PR +- Easier to review and test +- Can merge CLI improvements independently +- Allows validation of pattern before full MCP migration + +--- + +## Implementation Plan + +### Phase 1: Foundation (✅ This PR) + +#### Step 1: Create Directory Structure + +```bash +mkdir -p src/cli/{handlers,schemas,commands} +``` + +#### Step 2: Create Core Infrastructure + +**Files to Create:** +1. `src/cli/types.ts` - Shared types, result interfaces, helpers +2. `src/cli/registry.ts` - Handler registry with Zod validation +3. `src/cli/helpers.ts` - Common utilities (path resolution, error formatting) + +**Example: `src/cli/types.ts`** +```typescript +export interface CLIResult { + ok: boolean; + [key: string]: any; +} + +export interface CLIError { + ok: false; + reason: string; + message?: string; + [key: string]: any; +} + +export async function executeHandler( + commandKey: string, + rawInput: unknown +): Promise { + const handler = cliHandlers[commandKey]; + if (!handler) { + console.error(JSON.stringify({ ok: false, reason: 'unknown_command', command: commandKey }, null, 2)); + process.exit(1); + } + + try { + const validInput = handler.schema.parse(rawInput); + const result = await handler.handler(validInput); + + if (result.ok) { + console.log(JSON.stringify(result, null, 2)); + } else { + process.stderr.write(JSON.stringify(result, null, 2) + '\n'); + process.exit(2); + } + } catch (e) { + if (e instanceof z.ZodError) { + console.error(JSON.stringify({ ok: false, reason: 'validation_error', errors: e.errors }, null, 2)); + process.exit(1); + } + + const log = createLogger({ component: 'cli', cmd: commandKey }); + log.error(commandKey, { ok: false, err: e instanceof Error ? { message: e.message, stack: e.stack } : { message: String(e) } }); + console.error(JSON.stringify({ ok: false, reason: 'internal_error', message: e instanceof Error ? e.message : String(e) }, null, 2)); + process.exit(1); + } +} +``` + +#### Step 3: Migrate Commands (One by One) + +**Priority Order:** +1. `graph` (7 subcommands) - Most complex, best example +2. `semantic` - Single command, medium complexity +3. `index` - Core functionality +4. `dsr` (4 subcommands) - Multiple subcommands +5. `query`, `checkIndex`, `status`, `pack`, `unpack` - Simpler commands +6. `hooks`, `serve`, `trae` - CLI-only commands + +**Migration Template:** + +For each command: +1. Create `src/cli/schemas/{command}Schemas.ts` +2. Create `src/cli/handlers/{command}Handlers.ts` +3. Create `src/cli/commands/{command}Commands.ts` +4. Register in `src/cli/registry.ts` +5. Test with `npm run build && node dist/bin/git-ai.js ai {command} --help` +6. Verify JSON output matches original +7. Run full test suite: `npm test` + +#### Step 4: Update Entry Points + +**Update `src/commands/ai.ts`:** +```typescript +import { Command } from 'commander'; +import { graphCommand } from '../cli/commands/graphCommands'; +import { semanticCommand } from '../cli/commands/semanticCommand'; +// ... import new commands + +export const aiCommand = new Command('ai') + .description('AI features (indexing, search, hooks, MCP)') + .addCommand(graphCommand) // New architecture + .addCommand(semanticCommand) // New architecture + .addCommand(indexCommand) // New architecture + // ... all other commands +``` + +**Keep `bin/git-ai.ts` unchanged** (git passthrough logic stays) + +#### Step 5: Cleanup & Documentation + +1. Delete old `src/commands/*.ts` files (except `ai.ts`) +2. Update `src/commands/AGENTS.md` to point to new structure +3. Create `src/cli/AGENTS.md` with new architecture docs +4. Update root `AGENTS.md` with CLI architecture section + +--- + +### Phase 2: Validation & Testing (Future PR) + +#### Tasks: +1. Add CLI command tests (`test/cli/*.test.ts`) +2. Add handler unit tests (`test/handlers/*.test.ts`) +3. Add integration tests for CLI ↔ MCP parity +4. Add schema validation tests + +#### Test Strategy: +```typescript +// test/handlers/graphHandlers.test.ts +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { handleFindSymbols } from '../../src/cli/handlers/graphHandlers'; + +describe('handleFindSymbols', () => { + it('should find symbols by prefix', async () => { + const result = await handleFindSymbols({ + prefix: 'handle', + path: '/path/to/repo', + lang: 'auto', + }); + + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.result.rows)); + }); + + it('should handle index not found', async () => { + const result = await handleFindSymbols({ + prefix: 'test', + path: '/nonexistent', + lang: 'auto', + }); + + assert.strictEqual(result.ok, false); + assert.strictEqual(result.reason, 'index_incompatible'); + }); +}); +``` + +--- + +### Phase 3: Shared Handler Extraction (Future PR) + +#### Goal: +Extract common logic between CLI and MCP to `src/handlers/` + +#### Strategy: +1. Create `src/handlers/{domain}Handlers.ts` with pure business logic +2. Update `src/cli/handlers/{domain}Handlers.ts` to call shared handlers +3. Update `src/mcp/handlers/{domain}Handlers.ts` to call shared handlers + +#### Example: + +**Before:** +``` +src/cli/handlers/graphHandlers.ts (200 lines) - CLI-specific logic +src/mcp/handlers/astGraphHandlers.ts (142 lines) - MCP-specific logic +``` + +**After:** +``` +src/handlers/graphHandlers.ts (150 lines) - Shared business logic + +src/cli/handlers/graphHandlers.ts (50 lines) - CLI adapter + └─> src/handlers/graphHandlers.ts + +src/mcp/handlers/astGraphHandlers.ts (50 lines) - MCP adapter + └─> src/handlers/graphHandlers.ts +``` + +--- + +## Breaking Changes Assessment + +### None Expected ✅ + +All changes are internal refactoring. External interfaces remain identical: + +| Interface | Before | After | +|-----------|--------|-------| +| CLI commands | `git-ai ai graph find ` | ✅ Identical | +| CLI options | `--path`, `--lang`, etc. | ✅ Identical | +| JSON output | `{ ok, repoRoot, result }` | ✅ Identical | +| Exit codes | 0 (success), 1 (error), 2 (index issue) | ✅ Identical | +| MCP tools | All 21 tools | ✅ Unchanged (different PR) | + +### Backward Compatibility + +- ✅ All existing scripts using `git-ai` CLI continue working +- ✅ All MCP tools continue working (unchanged) +- ✅ JSON output format preserved +- ✅ Exit codes preserved +- ✅ Help text preserved + +--- + +## Success Criteria + +### Must Have (Blocking) + +- [ ] All 13 CLI commands migrated to new architecture +- [ ] `npm run build` succeeds with zero errors +- [ ] `npm test` passes all existing tests +- [ ] Manual smoke test: Each command produces identical JSON output +- [ ] Exit codes match original behavior +- [ ] No breaking changes to public API + +### Should Have (Non-Blocking) + +- [ ] All handlers have JSDoc comments +- [ ] All schemas have descriptions +- [ ] `src/cli/AGENTS.md` documents new architecture +- [ ] Updated root `AGENTS.md` with CLI section + +### Nice to Have (Future) + +- [ ] CLI command tests added +- [ ] Handler unit tests added +- [ ] Shared handlers extracted to `src/handlers/` + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Breaking CLI behavior | Low | High | Extensive manual testing, JSON output validation | +| Zod validation too strict | Medium | Medium | Use `.passthrough()` initially, tighten later | +| Migration mistakes | Medium | Medium | Migrate one command at a time, test after each | +| Performance regression | Low | Low | Handler logic unchanged, only routing differs | +| Build size increase | Low | Low | Zod already a dependency (used in MCP) | + +--- + +## Timeline Estimate + +| Phase | Tasks | Estimated Time | +|-------|-------|----------------| +| Foundation | Create types, registry, helpers | 30 min | +| Migrate `graph` | Schemas + handlers + commands | 45 min | +| Migrate `semantic`, `index`, `dsr` | 3 commands | 1 hour | +| Migrate remaining 9 commands | Simple commands | 1 hour | +| Testing & validation | Manual testing all commands | 30 min | +| Cleanup & docs | Delete old files, update docs | 20 min | +| **Total** | | **~4 hours** | + +--- + +## Rollback Plan + +If issues arise: + +1. **Immediate:** `git checkout refactor/mcp-server-design` (previous working state) +2. **Partial:** Keep completed migrations, revert problematic commands +3. **Full:** Abandon branch, keep existing `src/commands/*.ts` structure + +--- + +## Post-Merge TODO + +### Immediate (This PR) +- [ ] Update README examples (if needed) +- [ ] Update `DEVELOPMENT.md` with new CLI architecture +- [ ] Tag release: `v2.4.0` (minor version bump for internal improvements) + +### Future PRs +- [ ] Add CLI command tests +- [ ] Extract shared handlers to `src/handlers/` +- [ ] Refactor MCP to use shared handlers +- [ ] Add integration tests for CLI ↔ MCP parity +- [ ] Consider TypeScript builder pattern for complex commands + +--- + +## References + +- **Related PR:** #15 (MCP Server Refactor) +- **cli-developer skill:** `~/.agents/skills/cli-developer` +- **Commander.js docs:** https://github.com/tj/commander.js +- **Zod docs:** https://zod.dev + +--- + +## Questions & Decisions + +### Q1: Should we use Zod or Commander built-in validation? + +**Decision:** Use Zod. + +**Reasoning:** +- Already a dependency (used in MCP) +- Type-safe, reusable schemas +- Better error messages +- Consistent with MCP architecture +- Future-proof for shared handlers + +### Q2: Should we extract shared handlers in this PR or later? + +**Decision:** Later (separate PR). + +**Reasoning:** +- Reduces scope and risk +- Easier to review CLI refactor in isolation +- Can validate pattern before MCP migration +- Allows testing CLI improvements independently + +### Q3: Should we change command naming (e.g., `git-ai graph find` instead of `git-ai ai graph find`)? + +**Decision:** No, keep existing naming. + +**Reasoning:** +- Breaking change for existing users +- Scripts depend on current paths +- Can be addressed in future major version (v3.0.0) +- Internal refactor only, no UX changes + +### Q4: Should we standardize option names (e.g., always use `-p` for path)? + +**Decision:** Yes, but via deprecation warnings in future PR. + +**Reasoning:** +- Consistency improves UX +- But breaking change requires deprecation period +- Can add both old and new options with warnings +- Address in v2.5.0 with deprecation notices + +--- + +## Sign-Off + +**Design Approved By:** +- User (selected "Option C: Complete Restructure") + +**Implementation Start:** 2026-02-01 +**Target Completion:** 2026-02-01 (same day) + +--- + +## Appendix A: Command Inventory + +| Command | Subcommands | Lines | Complexity | Priority | +|---------|-------------|-------|------------|----------| +| `graph` | 7 (query, find, children, refs, callers, callees, chain) | 208 | High | 1 | +| `dsr` | 4 (context, generate, rebuild-index, symbol-evolution) | ~150 | Medium | 4 | +| `semantic` | 0 | 129 | Medium | 2 | +| `index` | 0 | 85 | Medium | 3 | +| `query` | 0 | ~80 | Low | 5 | +| `checkIndex` | 0 | ~50 | Low | 6 | +| `status` | 0 | ~60 | Low | 7 | +| `pack` | 0 | ~70 | Low | 8 | +| `unpack` | 0 | ~70 | Low | 9 | +| `hooks` | 0 | ~100 | Low | 10 | +| `serve` | 0 | ~40 | Low | 11 | +| `trae` | 0 | ~80 | Low | 12 | + +**Total:** 13 commands, 11 subcommands (24 total command handlers) + +--- + +## Appendix B: Example Migration (graph:find) + +### Before (src/commands/graph.ts - lines 30-56) + +```typescript +new Command('find') + .description('Find symbols by name prefix') + .argument('', 'Name prefix (case-insensitive)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .action(async (prefix, options) => { + const log = createLogger({ component: 'cli', cmd: 'ai graph find' }); + const startedAt = Date.now(); + const repoRoot = await resolveGitRoot(path.resolve(options.path)); + const status = await checkIndex(repoRoot); + if (!status.ok) { + process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); + process.exit(2); + return; + } + const langSel = String(options.lang ?? 'auto'); + const langs = resolveLangs(status.found.meta ?? null, langSel as any); + const allRows: any[] = []; + for (const lang of langs) { + const result = await runAstGraphQuery(repoRoot, buildFindSymbolsQuery(lang), { prefix: String(prefix), lang }); + const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + for (const r of rows) allRows.push(r); + } + const result = { headers: ['ref_id', 'file', 'lang', 'name', 'kind', 'signature', 'start_line', 'end_line'], rows: allRows }; + log.info('ast_graph_find', { ok: true, repoRoot, prefix: String(prefix), lang: langSel, langs, rows: allRows.length, duration_ms: Date.now() - startedAt }); + console.log(JSON.stringify({ repoRoot, lang: langSel, result }, null, 2)); + }) +``` + +### After (Split into 3 files) + +**1. src/cli/schemas/graphSchemas.ts** +```typescript +import { z } from 'zod'; + +export const FindSymbolsSchema = z.object({ + prefix: z.string().min(1).describe('Symbol name prefix to search for'), + path: z.string().default('.').describe('Path inside the repository'), + lang: z.enum(['auto', 'all', 'java', 'ts', 'python', 'go', 'rust', 'c', 'markdown', 'yaml']) + .default('auto') + .describe('Programming language filter'), +}); + +export type FindSymbolsInput = z.infer; +``` + +**2. src/cli/handlers/graphHandlers.ts** +```typescript +import { type FindSymbolsInput } from '../schemas/graphSchemas'; +import { type CLIResult } from '../types'; + +export async function handleFindSymbols(input: FindSymbolsInput): Promise { + const log = createLogger({ component: 'cli', cmd: 'ai graph find' }); + const startedAt = Date.now(); + + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const status = await checkIndex(repoRoot); + + if (!status.ok) { + return { ok: false, reason: 'index_incompatible', ...status }; + } + + const langs = resolveLangs(status.found.meta ?? null, input.lang); + const allRows: any[] = []; + + for (const lang of langs) { + const result = await runAstGraphQuery( + repoRoot, + buildFindSymbolsQuery(lang), + { prefix: input.prefix, lang } + ); + const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + allRows.push(...rows); + } + + log.info('ast_graph_find', { + ok: true, + repoRoot, + prefix: input.prefix, + lang: input.lang, + langs, + rows: allRows.length, + duration_ms: Date.now() - startedAt + }); + + return { + ok: true, + repoRoot, + lang: input.lang, + result: { + headers: ['ref_id', 'file', 'lang', 'name', 'kind', 'signature', 'start_line', 'end_line'], + rows: allRows + } + }; +} +``` + +**3. src/cli/commands/graphCommands.ts** +```typescript +import { Command } from 'commander'; +import { executeHandler } from '../types'; + +export const graphCommand = new Command('graph') + .description('AST graph search powered by CozoDB') + .addCommand( + new Command('find') + .description('Find symbols by name prefix') + .argument('', 'Name prefix (case-insensitive)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .action(async (prefix, options) => { + await executeHandler('graph:find', { prefix, ...options }); + }) + ) + // ... 6 more subcommands with same pattern +``` + +**4. src/cli/registry.ts** +```typescript +import { FindSymbolsSchema } from './schemas/graphSchemas'; +import { handleFindSymbols } from './handlers/graphHandlers'; + +export const cliHandlers = { + 'graph:find': { + schema: FindSymbolsSchema, + handler: handleFindSymbols, + }, + // ... all other commands +}; +``` + +### Improvements + +| Aspect | Before | After | +|--------|--------|-------| +| **Lines per command** | 40+ lines inline | 10 lines (command def) + 30 lines (handler) | +| **Testability** | Cannot test without mocking Commander | Pure function, easy to test | +| **Reusability** | CLI-only | Can be called from MCP, tests, scripts | +| **Type Safety** | Manual coercion (`String()`, `Number()`) | Zod validates all inputs | +| **Error Handling** | Inline try/catch | Centralized in `executeHandler` | +| **Maintainability** | Logic scattered in `.action()` | Clear separation: schema → handler → output | + +--- + +**End of Design Document** diff --git a/docs/zh-CN/mcp.md b/docs/zh-CN/mcp.md index 30237e3..c17f69d 100644 --- a/docs/zh-CN/mcp.md +++ b/docs/zh-CN/mcp.md @@ -1,16 +1,64 @@ # MCP Server 接入 -`git-ai` 提供了一个基于 MCP (Model Context Protocol) 的 stdio Server,供 Agent (如 Claude Desktop, Trae 等) 调用,赋予 Agent "理解代码库"的能力。 +`git-ai` 提供了一个基于 MCP (Model Context Protocol) 的 Server,供 Agent (如 Claude Desktop, Cursor, Trae 等) 调用,赋予 Agent "理解代码库"的能力。 ## 启动 +### Stdio 模式(默认,单客户端) + 在任意目录执行: ```bash git-ai ai serve ``` -该进程是 stdio 模式(会等待客户端连接)。你可以把它配置到支持 MCP 的客户端里。 +该进程是 stdio 模式(会等待客户端连接)。你可以把它配置到支持 MCP 的客户端里。适用于单个 Agent 连接。 + +### HTTP 模式(多客户端) + +如果你需要多个 Agent 同时连接(如同时使用 Claude Code 和 Cursor),使用 HTTP 模式: + +```bash +git-ai ai serve --http --port 3000 +``` + +HTTP 模式特性: +- **多客户端支持**:每个连接获得独立的 session +- **健康检查端点**:`http://localhost:3000/health` 返回服务状态 +- **MCP 端点**:`http://localhost:3000/mcp` 用于 MCP 协议通信 +- **Session 管理**:自动管理客户端 session 生命周期 + +#### 选项说明 + +| 选项 | 说明 | 默认值 | +|------|------|--------| +| `--http` | 启用 HTTP 传输(支持多客户端) | 否(使用 stdio) | +| `--port ` | HTTP 服务端口 | 3000 | +| `--stateless` | 无状态模式(不追踪 session,用于负载均衡) | 否 | +| `--disable-mcp-log` | 禁用 MCP 访问日志 | 否 | + +#### HTTP 模式示例 + +```bash +# 默认端口 3000 +git-ai ai serve --http + +# 自定义端口 +git-ai ai serve --http --port 8080 + +# 无状态模式(用于负载均衡场景) +git-ai ai serve --http --port 3000 --stateless + +# 禁用访问日志 +git-ai ai serve --http --disable-mcp-log +``` + +#### 健康检查 + +```bash +curl http://localhost:3000/health +# {"status":"ok","sessions":2} +``` ## 工具列表 @@ -134,8 +182,8 @@ semantic_search({ path: "/ABS/PATH/TO/REPO", query: "where is auth handled", top ### Markdown 模版(便于直接阅读/复制) -- **Skill**: [`templates/agents/common/skills/git-ai-mcp/SKILL.md`](../../templates/agents/common/skills/git-ai-mcp/SKILL.md) -- **Rule**: [`templates/agents/common/rules/git-ai-mcp/RULE.md`](../../templates/agents/common/rules/git-ai-mcp/RULE.md) +- **Skill**: [`templates/agents/common/skills/git-ai-code-search/SKILL.md`](../../templates/agents/common/skills/git-ai-code-search/SKILL.md) +- **Rule**: [`templates/agents/common/rules/git-ai-code-search/RULE.md`](../../templates/agents/common/rules/git-ai-code-search/RULE.md) ### 安装到 Trae diff --git a/docs/zh-CN/technical-details.md b/docs/zh-CN/technical-details.md index 011c1a4..f77bd73 100644 --- a/docs/zh-CN/technical-details.md +++ b/docs/zh-CN/technical-details.md @@ -392,7 +392,7 @@ git lfs pull Skill 模版定义了 Agent 如何使用 git-ai 工具的最佳实践。 -**位置**:`templates/agents/common/skills/git-ai-mcp/SKILL.md` +**位置**:`templates/agents/common/skills/git-ai-code-search/SKILL.md` **核心内容**: - 推荐工作流程(7 步) @@ -404,7 +404,7 @@ Skill 模版定义了 Agent 如何使用 git-ai 工具的最佳实践。 Rule 模版定义了 Agent 使用 git-ai 工具时的行为约束。 -**位置**:`templates/agents/common/rules/git-ai-mcp/RULE.md` +**位置**:`templates/agents/common/rules/git-ai-code-search/RULE.md` **核心内容**: - 必须遵守的规则(must_follow) diff --git a/package-lock.json b/package-lock.json index 0e80c79..f9505a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,18 @@ { - "name": "git-ai", + "name": "@mars167/git-ai", "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "git-ai", + "name": "@mars167/git-ai", "version": "2.3.0", "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], "dependencies": { "@lancedb/lancedb": "0.22.3", "@modelcontextprotocol/sdk": "^1.25.2", @@ -15,6 +20,7 @@ "@types/node": "^25.0.9", "apache-arrow": "18.1.0", "commander": "^14.0.2", + "cozo-lib-wasm": "^0.7.6", "fs-extra": "^11.3.3", "glob": "^13.0.0", "onnxruntime-node": "^1.19.2", @@ -37,6 +43,10 @@ "engines": { "node": ">=18" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mars167" + }, "optionalDependencies": { "@lancedb/lancedb-darwin-arm64": "0.22.3", "@lancedb/lancedb-darwin-x64": "0.22.3", @@ -46,7 +56,6 @@ "@lancedb/lancedb-linux-x64-musl": "0.22.3", "@lancedb/lancedb-win32-arm64-msvc": "0.22.3", "@lancedb/lancedb-win32-x64-msvc": "0.22.3", - "cozo-lib-wasm": "^0.7.6", "cozo-node": "^0.7.6" } }, @@ -977,8 +986,7 @@ "version": "0.7.6", "resolved": "https://registry.npmmirror.com/cozo-lib-wasm/-/cozo-lib-wasm-0.7.6.tgz", "integrity": "sha512-JxM0JHF2EVY7/S+100FKHB1h+fBgcmtqbs/9gSka0TgD9YutDYFCgIE5I80fRmbjh0h2e+THEe2HooRIbML7dw==", - "license": "MPL-2.0", - "optional": true + "license": "MPL-2.0" }, "node_modules/cozo-node": { "version": "0.7.6", diff --git a/package.json b/package.json index 5752a7c..6ddcbca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mars167/git-ai", - "version": "2.3.0", + "version": "2.4.0", "main": "dist/index.js", "bin": { "git-ai": "dist/bin/git-ai.js" @@ -80,7 +80,8 @@ "tree-sitter-typescript": "^0.21.1", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "zod": "^4.3.5" + "zod": "^4.3.5", + "cozo-node": "^0.7.6" }, "optionalDependencies": { "@lancedb/lancedb-darwin-arm64": "0.22.3", @@ -90,8 +91,6 @@ "@lancedb/lancedb-linux-x64-gnu": "0.22.3", "@lancedb/lancedb-linux-x64-musl": "0.22.3", "@lancedb/lancedb-win32-arm64-msvc": "0.22.3", - "@lancedb/lancedb-win32-x64-msvc": "0.22.3", - "cozo-lib-wasm": "^0.7.6", - "cozo-node": "^0.7.6" + "@lancedb/lancedb-win32-x64-msvc": "0.22.3" } } diff --git a/skills/git-ai-code-search/SKILL.md b/skills/git-ai-code-search/SKILL.md new file mode 100644 index 0000000..52127c7 --- /dev/null +++ b/skills/git-ai-code-search/SKILL.md @@ -0,0 +1,49 @@ +--- +name: git-ai-code-search +description: | + Semantic code search and codebase understanding using git-ai MCP tools. Use when: (1) Searching for symbols, functions, or semantic concepts, (2) Understanding project architecture, (3) Analyzing call graphs and code relationships, (4) Tracking symbol history via DSR. Triggers: "find X", "search for X", "who calls X", "where is X", "history of X", "understand this codebase". +--- + +# git-ai Code Search + +Semantic code search with AST analysis and change tracking. + +## Quick Start + +**For Agents** - 3-step pattern: +``` +1. check_index({ path }) → verify index exists +2. semantic_search({ path, query }) → find relevant code +3. read_file({ path, file }) → read the actual code +``` + +**For Users** - build index first: +```bash +cd your-repo +git-ai ai index # build index +git-ai ai semantic "authentication logic" # search +``` + +## Core Tools + +| Need | Tool | Example | +|------|------|---------| +| Search by meaning | `semantic_search` | `{ path, query: "error handling", topk: 10 }` | +| Search by name | `search_symbols` | `{ path, query: "handleAuth", mode: "substring" }` | +| Who calls X | `ast_graph_callers` | `{ path, name: "processOrder" }` | +| What X calls | `ast_graph_callees` | `{ path, name: "processOrder" }` | +| Call chain | `ast_graph_chain` | `{ path, name: "main", direction: "downstream" }` | +| Symbol history | `dsr_symbol_evolution` | `{ path, symbol: "UserService" }` | +| Project overview | `repo_map` | `{ path, max_files: 20 }` | + +## Rules + +1. **Always pass `path`** - Every tool requires explicit repository path +2. **Check index first** - Run `check_index` before search tools +3. **Read before modify** - Use `read_file` to understand code before changes +4. **Use DSR for history** - Never parse git log manually + +## References + +- [Tool Documentation](references/tools.md) +- [Behavioral Constraints](references/constraints.md) diff --git a/skills/git-ai-mcp/references/constraints.md b/skills/git-ai-code-search/references/constraints.md similarity index 100% rename from skills/git-ai-mcp/references/constraints.md rename to skills/git-ai-code-search/references/constraints.md diff --git a/skills/git-ai-mcp/references/tools.md b/skills/git-ai-code-search/references/tools.md similarity index 100% rename from skills/git-ai-mcp/references/tools.md rename to skills/git-ai-code-search/references/tools.md diff --git a/skills/git-ai-mcp/SKILL.md b/skills/git-ai-mcp/SKILL.md deleted file mode 100644 index 96efa26..0000000 --- a/skills/git-ai-mcp/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: git-ai-mcp -description: | - Efficient codebase understanding and navigation using git-ai MCP tools. Use when working with code repositories that have git-ai indexed, including: (1) Understanding project structure and architecture, (2) Searching for symbols, functions, or semantic concepts, (3) Analyzing code relationships and call graphs, (4) Tracking symbol evolution and change history via DSR, (5) Reading and navigating code files. Triggers: "understand this project", "find function X", "who calls X", "what does X call", "history of X", "where is X implemented". ---- - -# git-ai MCP Skill - -Guide for using git-ai MCP tools to understand and navigate codebases efficiently. - -## Overview - -git-ai provides semantic code understanding through: - -- **Hyper RAG**: Vector + Graph + DSR retrieval -- **AST Analysis**: Symbol relationships and call graphs -- **DSR**: Deterministic Semantic Records for change tracking - -## Workflow - -Understanding a codebase involves these steps: - -1. Get global view (run `repo_map`) -2. Check index status (run `check_index`, rebuild if needed) -3. Locate code (run `search_symbols` or `semantic_search`) -4. Analyze relationships (run `ast_graph_callers/callees/chain`) -5. Trace history (run `dsr_symbol_evolution`) -6. Read code (run `read_file`) - -## Tool Selection - -| Task | Tool | Key Parameters | -|------|------|----------------| -| Project overview | `repo_map` | `path`, `max_files: 20` | -| Find by name | `search_symbols` | `path`, `query`, `mode: substring` | -| Find by meaning | `semantic_search` | `path`, `query`, `topk: 10` | -| Who calls X | `ast_graph_callers` | `path`, `name` | -| What X calls | `ast_graph_callees` | `path`, `name` | -| Call chain | `ast_graph_chain` | `path`, `name`, `direction`, `max_depth` | -| Symbol history | `dsr_symbol_evolution` | `path`, `symbol`, `limit` | -| Read code | `read_file` | `path`, `file`, `start_line`, `end_line` | -| Index health | `check_index` | `path` | -| Rebuild index | `rebuild_index` | `path` | - -## Critical Rules - -**MUST follow:** - -1. **Always pass `path` explicitly** - Never rely on implicit working directory -2. **Check index before search** - Run `check_index` before using search/graph tools -3. **Read before modify** - Use `read_file` to understand code before making changes -4. **Use DSR for history** - Never manually parse git log; use `dsr_symbol_evolution` - -**NEVER do:** - -- Assume symbol locations without searching -- Modify files without reading them first -- Search when index is missing or incompatible -- Ignore DSR risk levels (high risk = extra review needed) - -## Examples - -**Find authentication code:** -```js -semantic_search({ path: "/repo", query: "user authentication logic", topk: 10 }) -``` - -**Find who calls a function:** -```js -ast_graph_callers({ path: "/repo", name: "handleRequest", limit: 50 }) -``` - -**Trace call chain upstream:** -```js -ast_graph_chain({ path: "/repo", name: "processOrder", direction: "upstream", max_depth: 3 }) -``` - -**View symbol history:** -```js -dsr_symbol_evolution({ path: "/repo", symbol: "authenticateUser", limit: 50 }) -``` - -## References - -- **Tool details**: See [references/tools.md](references/tools.md) for complete tool documentation -- **Constraints**: See [references/constraints.md](references/constraints.md) for behavioral rules diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md new file mode 100644 index 0000000..4125be1 --- /dev/null +++ b/src/cli/AGENTS.md @@ -0,0 +1,192 @@ +# src/cli + +**CLI command architecture matching MCP server patterns.** + +## OVERVIEW +Refactored CLI layer with handler registry, Zod validation, and modular design. Mirrors MCP server architecture for consistency. + +## STRUCTURE +``` +cli/ +├── types.ts # Core types: CLIResult, CLIError, CLIHandler, executeHandler +├── registry.ts # Handler registry with all 24 commands +├── helpers.ts # Shared utilities: resolveRepoContext, validateIndex, formatError +├── schemas/ # Zod schemas for all commands +│ ├── graphSchemas.ts +│ ├── indexSchemas.ts +│ ├── semanticSchemas.ts +│ ├── querySchemas.ts +│ ├── dsrSchemas.ts +│ ├── statusSchemas.ts +│ ├── archiveSchemas.ts +│ ├── hooksSchemas.ts +│ └── serveSchemas.ts +├── handlers/ # Business logic handlers +│ ├── graphHandlers.ts +│ ├── indexHandlers.ts +│ ├── semanticHandlers.ts +│ ├── queryHandlers.ts +│ ├── dsrHandlers.ts +│ ├── statusHandlers.ts +│ ├── archiveHandlers.ts +│ ├── hooksHandlers.ts +│ └── serveHandlers.ts +└── commands/ # Commander.js wrappers + ├── graphCommands.ts + ├── indexCommand.ts + ├── semanticCommand.ts + ├── queryCommand.ts + ├── dsrCommands.ts + ├── statusCommands.ts + ├── archiveCommands.ts + ├── hooksCommands.ts + └── serveCommands.ts +``` + +## WHERE TO LOOK +| Task | Location | +|------|----------| +| Add new command | Create schema → handler → command, register in registry.ts | +| Modify validation | `schemas/*Schemas.ts` | +| Change business logic | `handlers/*Handlers.ts` | +| Update CLI interface | `commands/*Commands.ts` or `commands/*Command.ts` | +| Shared utilities | `helpers.ts` | +| Handler execution | `types.ts` (executeHandler) | + +## KEY PATTERNS + +### Handler Pattern +```typescript +// Handler signature +export async function handleFindSymbols(input: FindSymbolsInput): Promise { + // 1. Resolve repository context + const ctx = await resolveRepoContext(input.path); + + // 2. Validate index + const error = validateIndex(ctx); + if (error) return error; + + // 3. Business logic + const result = await astGraphFind({ ...input, ...ctx }); + + // 4. Return success + return success({ repoRoot: ctx.repoRoot, result }); +} +``` + +### Command Pattern +```typescript +// Commander wrapper +export const findCommand = new Command('find') + .description('Find symbols by name prefix') + .argument('', 'Name prefix (case-insensitive)') + .option('-p, --path ', 'Path inside the repository', '.') + .action(async (prefix, options) => { + await executeHandler('graph:find', { prefix, ...options }); + }); +``` + +### Registry Pattern +```typescript +// Central registry (registry.ts) +const handlers: Record> = { + 'graph:find': handleFindSymbols, + 'semantic': handleSemanticSearch, + 'index': handleIndexRepo, + // ... all 24 commands +}; +``` + +## CONVENTIONS + +### Input Validation +- **All inputs validated via Zod schemas** in `schemas/` directory +- Schema naming: `{Command}{Subcommand}Input` (e.g., `GraphFindInput`) +- Schemas exported individually and as union types + +### Handler Return Types +- **Success**: `{ ok: true, data: T }` via `success(data)` helper +- **Error**: `{ ok: false, error: string, details?: unknown }` via `error(msg, details)` helper +- Handlers use `resolveRepoContext()` and `validateIndex()` from helpers.ts + +### Error Handling +- Index issues: Exit code 2 (stderr JSON output) +- Other errors: Exit code 1 (stderr JSON output) +- Success: Exit code 0 (stdout JSON output) + +### File Organization +- Each major command gets its own schema/handler/command file +- Subcommands grouped in single files (e.g., `graphCommands.ts` has 7 subcommands) +- Single-command files use singular name (e.g., `indexCommand.ts`) +- Multi-subcommand files use plural name (e.g., `graphCommands.ts`) + +## COMMANDS REGISTRY + +### Graph Commands (7 subcommands) +- `graph:query` - Run CozoScript query +- `graph:find` - Find symbols by prefix +- `graph:children` - List children in AST +- `graph:refs` - Find reference locations +- `graph:callers` - Find callers +- `graph:callees` - Find callees +- `graph:chain` - Compute call chain + +### Core Commands (3 commands) +- `index` - Build repository index +- `semantic` - Semantic search +- `query` - Symbol search + +### DSR Commands (4 subcommands) +- `dsr:context` - Get DSR directory state +- `dsr:generate` - Generate DSR for commit +- `dsr:rebuild-index` - Rebuild DSR index +- `dsr:query` - Semantic queries over Git DAG + +### Status Commands (2 commands) +- `checkIndex` - Check index status (deprecated) +- `status` - Show repository status + +### Archive Commands (2 commands) +- `pack` - Pack index into archive +- `unpack` - Unpack archive + +### Hooks Commands (3 subcommands) +- `hooks:install` - Install git hooks +- `hooks:uninstall` - Uninstall git hooks +- `hooks:status` - Show hooks status + +### Serve Commands (2 commands) +- `serve` - Start MCP server +- `agent` - Install agent templates + +## ANTI-PATTERNS (THIS MODULE) + +- Never bypass Zod validation - all inputs go through schemas +- Never output non-JSON to stdout from handlers +- Never throw errors from handlers - return `CLIError` +- Never duplicate repo context logic - use `resolveRepoContext()` +- Never skip index validation - use `validateIndex()` +- No empty error messages - always provide context + +## UNIQUE STYLES + +- **MCP-style architecture**: Registry + handlers + schemas pattern +- **Type-safe everywhere**: Zod runtime validation + TypeScript compile-time types +- **Consistent error handling**: All handlers use success/error helpers +- **Shared utilities**: helpers.ts eliminates duplicated validation logic +- **Backward compatible**: JSON output format unchanged from original commands + +## TESTING NOTES + +- All handlers tested via CLI commands +- Integration tests verify JSON output matches original behavior +- Test both success and error paths +- Verify exit codes: 0 (success), 1 (error), 2 (index issue) + +## MIGRATION NOTES + +- Original commands in `src/commands/*.ts` have been deleted +- Only `src/commands/ai.ts` remains as the command aggregator +- All business logic moved to `src/cli/handlers/` +- Commander definitions moved to `src/cli/commands/` +- ~300 lines of duplicated code eliminated via helpers.ts diff --git a/src/cli/commands/archiveCommands.ts b/src/cli/commands/archiveCommands.ts new file mode 100644 index 0000000..954c759 --- /dev/null +++ b/src/cli/commands/archiveCommands.ts @@ -0,0 +1,17 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types'; + +export const packCommand = new Command('pack') + .description('Pack .git-ai/lancedb into .git-ai/lancedb.tar.gz') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--lfs', 'Run git lfs track for .git-ai/lancedb.tar.gz', false) + .action(async (options) => { + await executeHandler('pack', options); + }); + +export const unpackCommand = new Command('unpack') + .description('Unpack .git-ai/lancedb.tar.gz into .git-ai/lancedb') + .option('-p, --path ', 'Path inside the repository', '.') + .action(async (options) => { + await executeHandler('unpack', options); + }); diff --git a/src/cli/commands/dsrCommands.ts b/src/cli/commands/dsrCommands.ts new file mode 100644 index 0000000..c5b3f9c --- /dev/null +++ b/src/cli/commands/dsrCommands.ts @@ -0,0 +1,51 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types'; + +export const dsrCommand = new Command('dsr') + .description('Deterministic Semantic Record (per-commit, immutable, Git-addressable)') + .addCommand( + new Command('context') + .description('Discover repository root, HEAD, branch, and DSR directory state') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--json', 'Output machine-readable JSON', false) + .action(async (options) => { + await executeHandler('dsr:context', options); + }) + ) + .addCommand( + new Command('generate') + .description('Generate DSR for exactly one commit') + .argument('', 'Commit hash (any rev that resolves to a commit)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--json', 'Output machine-readable JSON', false) + .action(async (commit, options) => { + await executeHandler('dsr:generate', { commit, ...options }); + }) + ) + .addCommand( + new Command('rebuild-index') + .description('Rebuild performance-oriented DSR index from DSR files') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--json', 'Output machine-readable JSON', false) + .action(async (options) => { + await executeHandler('dsr:rebuild-index', options); + }) + ) + .addCommand( + new Command('query') + .description('Read-only semantic queries over Git DAG + DSR') + .addCommand( + new Command('symbol-evolution') + .description('List commits where a symbol changed (requires DSR per traversed commit)') + .argument('', 'Symbol name') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--all', 'Traverse all refs (default: from HEAD)', false) + .option('--start ', 'Start commit (default: HEAD)') + .option('--limit ', 'Max commits to traverse', (v) => Number(v), 200) + .option('--contains', 'Match by substring instead of exact match', false) + .option('--json', 'Output machine-readable JSON', false) + .action(async (symbol, options) => { + await executeHandler('dsr:symbol-evolution', { symbol, ...options }); + }) + ) + ); diff --git a/src/cli/commands/graphCommands.ts b/src/cli/commands/graphCommands.ts new file mode 100644 index 0000000..9cb7595 --- /dev/null +++ b/src/cli/commands/graphCommands.ts @@ -0,0 +1,82 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types'; + +export const graphCommand = new Command('graph') + .description('AST graph search powered by CozoDB') + .addCommand( + new Command('query') + .description('Run a CozoScript query against the AST graph database') + .argument('', 'CozoScript query') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--params ', 'JSON params object', '{}') + .action(async (scriptParts, options) => { + await executeHandler('graph:query', { scriptParts, ...options }); + }) + ) + .addCommand( + new Command('find') + .description('Find symbols by name prefix') + .argument('', 'Name prefix (case-insensitive)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .action(async (prefix, options) => { + await executeHandler('graph:find', { prefix, ...options }); + }) + ) + .addCommand( + new Command('children') + .description('List direct children in the AST containment graph') + .argument('', 'Parent id (ref_id or file_id)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--as-file', 'Treat as a repository-relative file path and hash it to file_id', false) + .action(async (id, options) => { + await executeHandler('graph:children', { id, ...options }); + }) + ) + .addCommand( + new Command('refs') + .description('Find reference locations by name (calls/new/type)') + .argument('', 'Symbol name') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--limit ', 'Limit results', '200') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .action(async (name, options) => { + await executeHandler('graph:refs', { name, ...options }); + }) + ) + .addCommand( + new Command('callers') + .description('Find callers by callee name') + .argument('', 'Callee name') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--limit ', 'Limit results', '200') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .action(async (name, options) => { + await executeHandler('graph:callers', { name, ...options }); + }) + ) + .addCommand( + new Command('callees') + .description('Find callees by caller name (resolved by exact callee name match in graph)') + .argument('', 'Caller name') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--limit ', 'Limit results', '200') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .action(async (name, options) => { + await executeHandler('graph:callees', { name, ...options }); + }) + ) + .addCommand( + new Command('chain') + .description('Compute call chain by symbol name (heuristic, name-based)') + .argument('', 'Start symbol name') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--direction ', 'Direction: downstream|upstream', 'downstream') + .option('--depth ', 'Max depth', '3') + .option('--limit ', 'Limit results', '500') + .option('--min-name-len ', 'Filter out edges with very short names (default: 1)', '1') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .action(async (name, options) => { + await executeHandler('graph:chain', { name, ...options }); + }) + ); diff --git a/src/cli/commands/hooksCommands.ts b/src/cli/commands/hooksCommands.ts new file mode 100644 index 0000000..87c3bf1 --- /dev/null +++ b/src/cli/commands/hooksCommands.ts @@ -0,0 +1,29 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types'; + +export const hooksCommand = new Command('hooks') + .description('Manage git hooks integration') + .addCommand( + new Command('install') + .description('Install git hooks (write .githooks/* and set core.hooksPath=.githooks)') + .option('-p, --path ', 'Path inside the repository', '.') + .action(async (options) => { + await executeHandler('hooks:install', options); + }) + ) + .addCommand( + new Command('uninstall') + .description('Uninstall git hooks (unset core.hooksPath)') + .option('-p, --path ', 'Path inside the repository', '.') + .action(async (options) => { + await executeHandler('hooks:uninstall', options); + }) + ) + .addCommand( + new Command('status') + .description('Show current hooks configuration') + .option('-p, --path ', 'Path inside the repository', '.') + .action(async (options) => { + await executeHandler('hooks:status', options); + }) + ); diff --git a/src/cli/commands/indexCommand.ts b/src/cli/commands/indexCommand.ts new file mode 100644 index 0000000..61a122d --- /dev/null +++ b/src/cli/commands/indexCommand.ts @@ -0,0 +1,13 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types.js'; + +export const indexCommand = new Command('index') + .description('Build LanceDB+SQ8 index for the current repository (HEAD working tree)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('-d, --dim ', 'Embedding dimension', '256') + .option('--overwrite', 'Overwrite existing tables', false) + .option('--incremental', 'Incremental indexing (only changed files)', false) + .option('--staged', 'Read changed file contents from Git index (staged)', false) + .action(async (options) => { + await executeHandler('index', options); + }); diff --git a/src/cli/commands/queryCommand.ts b/src/cli/commands/queryCommand.ts new file mode 100644 index 0000000..f03bbad --- /dev/null +++ b/src/cli/commands/queryCommand.ts @@ -0,0 +1,19 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types.js'; + +export const queryCommand = new Command('query') + .description('Query refs table by symbol match (substring/prefix/wildcard/regex/fuzzy)') + .argument('', 'Symbol substring') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--limit ', 'Limit results', '50') + .option('--mode ', 'Mode: substring|prefix|wildcard|regex|fuzzy (default: auto)') + .option('--case-insensitive', 'Case-insensitive matching', false) + .option('--max-candidates ', 'Max candidates to fetch before filtering', '1000') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .option('--with-repo-map', 'Attach a lightweight repo map (ranked files + top symbols + wiki links)', false) + .option('--repo-map-files ', 'Max repo map files', '20') + .option('--repo-map-symbols ', 'Max repo map symbols per file', '5') + .option('--wiki ', 'Wiki directory (default: docs/wiki or wiki)', '') + .action(async (keyword, options) => { + await executeHandler('query', { keyword, ...options }); + }); diff --git a/src/cli/commands/semanticCommand.ts b/src/cli/commands/semanticCommand.ts new file mode 100644 index 0000000..6701c5b --- /dev/null +++ b/src/cli/commands/semanticCommand.ts @@ -0,0 +1,16 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types.js'; + +export const semanticCommand = new Command('semantic') + .description('Semantic search using SQ8 vectors (brute-force over chunks)') + .argument('', 'Query text') + .option('-p, --path ', 'Path inside the repository', '.') + .option('-k, --topk ', 'Top K results', '10') + .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') + .option('--with-repo-map', 'Attach a lightweight repo map (ranked files + top symbols + wiki links)', false) + .option('--repo-map-files ', 'Max repo map files', '20') + .option('--repo-map-symbols ', 'Max repo map symbols per file', '5') + .option('--wiki ', 'Wiki directory (default: docs/wiki or wiki)', '') + .action(async (text, options) => { + await executeHandler('semantic', { text, ...options }); + }); diff --git a/src/cli/commands/serveCommands.ts b/src/cli/commands/serveCommands.ts new file mode 100644 index 0000000..b2ec6b5 --- /dev/null +++ b/src/cli/commands/serveCommands.ts @@ -0,0 +1,27 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types'; + +export const serveCommand = new Command('serve') + .description('Start MCP server. Default: stdio (single client). Use --http for multiple clients.') + .option('--disable-mcp-log', 'Disable MCP access logging') + .option('--http', 'Use HTTP transport instead of stdio (supports multiple clients)') + .option('--port ', 'HTTP server port (default: 3000)', '3000') + .option('--stateless', 'Stateless mode (no session tracking, for load-balanced setups)') + .action(async (options) => { + await executeHandler('serve', options); + }); + +export const agentCommand = new Command('agent') + .description('Install Agent skills/rules templates into a target directory') + .alias('trae') + .addCommand( + new Command('install') + .description('Install skills/rules templates (default: /.agents)') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--to ', 'Destination directory (overrides default)', '') + .option('--agent ', 'Template layout: agents|trae', 'agents') + .option('--overwrite', 'Overwrite existing files', false) + .action(async (options) => { + await executeHandler('agent', options); + }) + ); diff --git a/src/cli/commands/statusCommands.ts b/src/cli/commands/statusCommands.ts new file mode 100644 index 0000000..b6ca99d --- /dev/null +++ b/src/cli/commands/statusCommands.ts @@ -0,0 +1,17 @@ +import { Command } from 'commander'; +import { executeHandler } from '../types'; + +export const checkIndexCommand = new Command('check-index') + .description('Deprecated: use `git-ai ai status --json`') + .option('-p, --path ', 'Path inside the repository', '.') + .action(async (options) => { + await executeHandler('checkIndex', options); + }); + +export const statusCommand = new Command('status') + .description('Show repository index status') + .option('-p, --path ', 'Path inside the repository', '.') + .option('--json', 'Output machine-readable JSON', false) + .action(async (options) => { + await executeHandler('status', options); + }); diff --git a/src/cli/handlers/archiveHandlers.ts b/src/cli/handlers/archiveHandlers.ts new file mode 100644 index 0000000..86bf8c4 --- /dev/null +++ b/src/cli/handlers/archiveHandlers.ts @@ -0,0 +1,61 @@ +import path from 'path'; +import { resolveGitRoot } from '../../core/git'; +import { packLanceDb } from '../../core/archive'; +import { unpackLanceDb } from '../../core/archive'; +import { ensureLfsTracking } from '../../core/lfs'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; + +export async function handlePackIndex(input: { + path: string; + lfs: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'pack' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const packed = await packLanceDb(repoRoot); + const lfs = input.lfs ? ensureLfsTracking(repoRoot, '.git-ai/lancedb.tar.gz') : { tracked: false }; + + log.info('pack_index', { + ok: true, + repoRoot, + packed: packed.packed, + lfs: Boolean(lfs.tracked), + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot, ...packed, lfs }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('pack_index', { ok: false, err: message }); + return error('pack_index_failed', { message }); + } +} + +export async function handleUnpackIndex(input: { + path: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'unpack' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const unpacked = await unpackLanceDb(repoRoot); + + log.info('unpack_index', { + ok: true, + repoRoot, + unpacked: unpacked.unpacked, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot, ...unpacked }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('unpack_index', { ok: false, err: message }); + return error('unpack_index_failed', { message }); + } +} diff --git a/src/cli/handlers/dsrHandlers.ts b/src/cli/handlers/dsrHandlers.ts new file mode 100644 index 0000000..41ad96c --- /dev/null +++ b/src/cli/handlers/dsrHandlers.ts @@ -0,0 +1,149 @@ +import path from 'path'; +import { detectRepoGitContext } from '../../core/dsr/gitContext'; +import { generateDsrForCommit } from '../../core/dsr/generate'; +import { materializeDsrIndex } from '../../core/dsr/indexMaterialize'; +import { symbolEvolution } from '../../core/dsr/query'; +import { getDsrDirectoryState } from '../../core/dsr/state'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; + +export async function handleDsrContext(input: { + path: string; + json: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'dsr:context' }); + const startedAt = Date.now(); + + try { + const start = path.resolve(input.path); + const ctx = await detectRepoGitContext(start); + const state = await getDsrDirectoryState(ctx.repo_root); + + const out = { + commit_hash: ctx.head_commit, + repo_root: ctx.repo_root, + branch: ctx.branch, + detached: ctx.detached, + dsr_directory_state: state, + }; + + log.info('dsr_context', { + ok: true, + repoRoot: ctx.repo_root, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot: ctx.repo_root, ...out }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('dsr:context', { ok: false, err: message }); + return error('dsr_context_failed', { message }); + } +} + +export async function handleDsrGenerate(input: { + commit: string; + path: string; + json: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'dsr:generate' }); + const startedAt = Date.now(); + + try { + const start = path.resolve(input.path); + const ctx = await detectRepoGitContext(start); + const res = await generateDsrForCommit(ctx.repo_root, String(input.commit)); + + const out = { + commit_hash: res.dsr.commit_hash, + file_path: res.file_path, + existed: res.existed, + counts: { + affected_symbols: res.dsr.affected_symbols.length, + ast_operations: res.dsr.ast_operations.length, + }, + semantic_change_type: res.dsr.semantic_change_type, + risk_level: res.dsr.risk_level, + }; + + log.info('dsr_generate', { + ok: true, + repoRoot: ctx.repo_root, + commit_hash: out.commit_hash, + existed: res.existed, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot: ctx.repo_root, ...out }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('dsr:generate', { ok: false, err: message }); + return error('dsr_generate_failed', { message }); + } +} + +export async function handleDsrRebuildIndex(input: { + path: string; + json: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'dsr:rebuild-index' }); + const startedAt = Date.now(); + + try { + const start = path.resolve(input.path); + const ctx = await detectRepoGitContext(start); + const res = await materializeDsrIndex(ctx.repo_root); + + log.info('dsr_rebuild_index', { + ok: res.enabled, + repoRoot: ctx.repo_root, + enabled: res.enabled, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot: ctx.repo_root, ...res }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('dsr:rebuild-index', { ok: false, err: message }); + return error('dsr_rebuild_index_failed', { message }); + } +} + +export async function handleDsrSymbolEvolution(input: { + symbol: string; + path: string; + all: boolean; + start?: string; + limit: number; + contains: boolean; + json: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'dsr:symbol-evolution' }); + const startedAt = Date.now(); + + try { + const startDir = path.resolve(input.path); + const ctx = await detectRepoGitContext(startDir); + const res = await symbolEvolution(ctx.repo_root, String(input.symbol), { + all: Boolean(input.all), + start: input.start ? String(input.start) : undefined, + limit: Number(input.limit), + contains: Boolean(input.contains), + }); + + log.info('dsr_symbol_evolution', { + ok: res.ok, + repoRoot: ctx.repo_root, + symbol: input.symbol, + hits: res.hits?.length ?? 0, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot: ctx.repo_root, symbol: input.symbol, ...res }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('dsr:symbol-evolution', { ok: false, err: message }); + return error('dsr_symbol_evolution_failed', { message }); + } +} diff --git a/src/cli/handlers/graphHandlers.ts b/src/cli/handlers/graphHandlers.ts new file mode 100644 index 0000000..c0eb948 --- /dev/null +++ b/src/cli/handlers/graphHandlers.ts @@ -0,0 +1,455 @@ +import path from 'path'; +import { resolveGitRoot } from '../../core/git'; +import { sha256Hex } from '../../core/crypto'; +import { + buildCallChainDownstreamByNameQuery, + buildCallChainUpstreamByNameQuery, + buildCalleesByNameQuery, + buildCallersByNameQuery, + buildChildrenQuery, + buildFindReferencesQuery, + buildFindSymbolsQuery, + runAstGraphQuery, +} from '../../core/astGraphQuery'; +import { toPosixPath } from '../../core/paths'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; +import { resolveRepoContext, validateIndex, resolveLanguages, type RepoContext } from '../helpers'; + +function isCLIError(value: unknown): value is CLIError { + return typeof value === 'object' && value !== null && 'ok' in value && (value as any).ok === false; +} + +export async function handleGraphQuery(input: { + scriptParts: string[]; + path: string; + params: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'graph:query' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const query = input.scriptParts.join(' '); + const params = JSON.parse(input.params); + const result = await runAstGraphQuery(repoRoot, query, params); + + log.info('ast_graph_query', { + ok: true, + repoRoot, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot, result }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('graph:query', { ok: false, err: message }); + return error('query_execution_failed', { message }); + } +} + +export async function handleFindSymbols(input: { + prefix: string; + path: string; + lang: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'graph:find' }); + const startedAt = Date.now(); + + const ctxOrError = await resolveRepoContext(input.path); + + if (isCLIError(ctxOrError)) { + return ctxOrError; + } + + const ctx = ctxOrError as RepoContext; + + if (!ctx.indexStatus.ok) { + return error('index_incompatible', { + message: 'Index is missing or incompatible. Run: git-ai ai index --overwrite', + ...ctx.indexStatus, + }); + } + + const validationError = validateIndex(ctx); + if (validationError) { + return validationError; + } + + try { + const langs = resolveLanguages(ctx.meta, input.lang); + const allRows: any[] = []; + + for (const lang of langs) { + const result = await runAstGraphQuery( + ctx.repoRoot, + buildFindSymbolsQuery(lang), + { prefix: input.prefix, lang } + ); + const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + for (const r of rows) allRows.push(r); + } + + const result = { + headers: ['ref_id', 'file', 'lang', 'name', 'kind', 'signature', 'start_line', 'end_line'], + rows: allRows, + }; + + log.info('ast_graph_find', { + ok: true, + repoRoot: ctx.repoRoot, + prefix: input.prefix, + lang: input.lang, + langs, + rows: allRows.length, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot: ctx.repoRoot, lang: input.lang, result }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('graph:find', { ok: false, err: message }); + return error('find_symbols_failed', { message }); + } +} + +export async function handleGraphChildren(input: { + id: string; + path: string; + asFile: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'graph:children' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const parentId = input.asFile ? sha256Hex(`file:${toPosixPath(input.id)}`) : input.id; + const result = await runAstGraphQuery(repoRoot, buildChildrenQuery(), { parent_id: parentId }); + + log.info('ast_graph_children', { + ok: true, + repoRoot, + parent_id: parentId, + rows: Array.isArray((result as any)?.rows) ? (result as any).rows.length : 0, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot, parent_id: parentId, result }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('graph:children', { ok: false, err: message }); + return error('graph_children_failed', { message }); + } +} + +export async function handleGraphRefs(input: { + name: string; + path: string; + limit: number; + lang: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'graph:refs' }); + const startedAt = Date.now(); + + const ctxOrError = await resolveRepoContext(input.path); + + if (isCLIError(ctxOrError)) { + return ctxOrError; + } + + const ctx = ctxOrError as RepoContext; + + if (!ctx.indexStatus.ok) { + return error('index_incompatible', { + message: 'Index is missing or incompatible. Run: git-ai ai index --overwrite', + ...ctx.indexStatus, + }); + } + + const validationError = validateIndex(ctx); + if (validationError) { + return validationError; + } + + try { + const langs = resolveLanguages(ctx.meta, input.lang); + const allRows: any[] = []; + + for (const lang of langs) { + const result = await runAstGraphQuery( + ctx.repoRoot, + buildFindReferencesQuery(lang), + { name: input.name, lang } + ); + const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + for (const r of rows) allRows.push(r); + } + + const rows = allRows.slice(0, input.limit); + + log.info('ast_graph_refs', { + ok: true, + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + langs, + rows: rows.length, + duration_ms: Date.now() - startedAt, + }); + + return success({ + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + result: { + headers: ['file', 'line', 'col', 'ref_kind', 'from_id', 'from_kind', 'from_name', 'from_lang'], + rows, + }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('graph:refs', { ok: false, err: message }); + return error('graph_refs_failed', { message }); + } +} + +export async function handleGraphCallers(input: { + name: string; + path: string; + limit: number; + lang: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'graph:callers' }); + const startedAt = Date.now(); + + const ctxOrError = await resolveRepoContext(input.path); + + if (isCLIError(ctxOrError)) { + return ctxOrError; + } + + const ctx = ctxOrError as RepoContext; + + if (!ctx.indexStatus.ok) { + return error('index_incompatible', { + message: 'Index is missing or incompatible. Run: git-ai ai index --overwrite', + ...ctx.indexStatus, + }); + } + + const validationError = validateIndex(ctx); + if (validationError) { + return validationError; + } + + try { + const langs = resolveLanguages(ctx.meta, input.lang); + const allRows: any[] = []; + + for (const lang of langs) { + const result = await runAstGraphQuery( + ctx.repoRoot, + buildCallersByNameQuery(lang), + { name: input.name, lang } + ); + const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + for (const r of rows) allRows.push(r); + } + + const rows = allRows.slice(0, input.limit); + + log.info('ast_graph_callers', { + ok: true, + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + langs, + rows: rows.length, + duration_ms: Date.now() - startedAt, + }); + + return success({ + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + result: { + headers: ['caller_id', 'caller_kind', 'caller_name', 'file', 'line', 'col', 'caller_lang'], + rows, + }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('graph:callers', { ok: false, err: message }); + return error('graph_callers_failed', { message }); + } +} + +export async function handleGraphCallees(input: { + name: string; + path: string; + limit: number; + lang: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'graph:callees' }); + const startedAt = Date.now(); + + const ctxOrError = await resolveRepoContext(input.path); + + if (isCLIError(ctxOrError)) { + return ctxOrError; + } + + const ctx = ctxOrError as RepoContext; + + if (!ctx.indexStatus.ok) { + return error('index_incompatible', { + message: 'Index is missing or incompatible. Run: git-ai ai index --overwrite', + ...ctx.indexStatus, + }); + } + + const validationError = validateIndex(ctx); + if (validationError) { + return validationError; + } + + try { + const langs = resolveLanguages(ctx.meta, input.lang); + const allRows: any[] = []; + + for (const lang of langs) { + const result = await runAstGraphQuery( + ctx.repoRoot, + buildCalleesByNameQuery(lang), + { name: input.name, lang } + ); + const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + for (const r of rows) allRows.push(r); + } + + const rows = allRows.slice(0, input.limit); + + log.info('ast_graph_callees', { + ok: true, + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + langs, + rows: rows.length, + duration_ms: Date.now() - startedAt, + }); + + return success({ + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + result: { + headers: [ + 'caller_id', + 'caller_lang', + 'callee_id', + 'callee_file', + 'callee_name', + 'callee_kind', + 'file', + 'line', + 'col', + ], + rows, + }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('graph:callees', { ok: false, err: message }); + return error('graph_callees_failed', { message }); + } +} + +export async function handleGraphChain(input: { + name: string; + path: string; + direction: 'downstream' | 'upstream'; + depth: number; + limit: number; + minNameLen: number; + lang: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'graph:chain' }); + const startedAt = Date.now(); + + const ctxOrError = await resolveRepoContext(input.path); + + if (isCLIError(ctxOrError)) { + return ctxOrError; + } + + const ctx = ctxOrError as RepoContext; + + if (!ctx.indexStatus.ok) { + return error('index_incompatible', { + message: 'Index is missing or incompatible. Run: git-ai ai index --overwrite', + ...ctx.indexStatus, + }); + } + + const validationError = validateIndex(ctx); + if (validationError) { + return validationError; + } + + try { + const langs = resolveLanguages(ctx.meta, input.lang); + const query = + input.direction === 'upstream' + ? buildCallChainUpstreamByNameQuery() + : buildCallChainDownstreamByNameQuery(); + const allRows: any[] = []; + + for (const lang of langs) { + const result = await runAstGraphQuery(ctx.repoRoot, query, { + name: input.name, + max_depth: input.depth, + lang, + }); + const rawRows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; + for (const r of rawRows) allRows.push(r); + } + + const filtered = + input.minNameLen > 1 + ? allRows.filter((r: any[]) => String(r?.[3] ?? '').length >= input.minNameLen && String(r?.[4] ?? '').length >= input.minNameLen) + : allRows; + const rows = filtered.slice(0, input.limit); + + log.info('ast_graph_chain', { + ok: true, + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + langs, + direction: input.direction, + max_depth: input.depth, + rows: rows.length, + min_name_len: input.minNameLen, + duration_ms: Date.now() - startedAt, + }); + + return success({ + repoRoot: ctx.repoRoot, + name: input.name, + lang: input.lang, + direction: input.direction, + max_depth: input.depth, + min_name_len: input.minNameLen, + result: { + headers: ['caller_id', 'callee_id', 'depth', 'caller_name', 'callee_name', 'lang'], + rows, + }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('graph:chain', { ok: false, err: message }); + return error('graph_chain_failed', { message }); + } +} diff --git a/src/commands/hooks.ts b/src/cli/handlers/hooksHandlers.ts similarity index 51% rename from src/commands/hooks.ts rename to src/cli/handlers/hooksHandlers.ts index 8d82da4..309eed0 100644 --- a/src/commands/hooks.ts +++ b/src/cli/handlers/hooksHandlers.ts @@ -1,8 +1,10 @@ -import { Command } from 'commander'; import path from 'path'; import { spawnSync } from 'child_process'; import fs from 'fs-extra'; -import { resolveGitRoot } from '../core/git'; +import { resolveGitRoot } from '../../core/git'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; function runGit(cwd: string, args: string[]) { const res = spawnSync('git', args, { cwd, stdio: 'inherit' }); @@ -69,54 +71,100 @@ async function installHookTemplates(repoRoot: string): Promise<{ installed: stri return { installed }; } -const install = new Command('install') - .description('Install git hooks (write .githooks/* and set core.hooksPath=.githooks)') - .option('-p, --path ', 'Path inside the repository', '.') - .action(async (options) => { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); +export async function handleInstallHooks(input: { + path: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'hooks:install' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); if (!isGitRepo(repoRoot)) { - console.log(JSON.stringify({ ok: false, error: 'not_a_git_repo', repoRoot }, null, 2)); - process.exitCode = 1; - return; + log.error('hooks:install', { ok: false, error: 'not_a_git_repo', repoRoot }); + return error('not_a_git_repo', { repoRoot, message: 'Not a git repository' }); } const templates = await installHookTemplates(repoRoot); const exec = await ensureHooksExecutable(repoRoot); runGit(repoRoot, ['config', 'core.hooksPath', '.githooks']); const hooksPath = getGitConfig(repoRoot, 'core.hooksPath'); - console.log(JSON.stringify({ ok: true, repoRoot, hooksPath, templates, executable: exec }, null, 2)); - }); - -const uninstall = new Command('uninstall') - .description('Uninstall git hooks (unset core.hooksPath)') - .option('-p, --path ', 'Path inside the repository', '.') - .action(async (options) => { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); + + log.info('hooks_install', { + ok: true, + repoRoot, + hooksPath, + templates, + duration_ms: Date.now() - startedAt, + }); + + return success({ ok: true, repoRoot, hooksPath, templates, executable: exec }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('hooks:install', { ok: false, err: message }); + return error('hooks_install_failed', { message }); + } +} + +export async function handleUninstallHooks(input: { + path: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'hooks:uninstall' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); if (!isGitRepo(repoRoot)) { - console.log(JSON.stringify({ ok: false, error: 'not_a_git_repo', repoRoot }, null, 2)); - process.exitCode = 1; - return; + log.error('hooks:uninstall', { ok: false, error: 'not_a_git_repo', repoRoot }); + return error('not_a_git_repo', { repoRoot, message: 'Not a git repository' }); } spawnSync('git', ['config', '--unset', 'core.hooksPath'], { cwd: repoRoot, stdio: 'ignore' }); const hooksPath = getGitConfig(repoRoot, 'core.hooksPath'); - console.log(JSON.stringify({ ok: true, repoRoot, hooksPath }, null, 2)); - }); - -const status = new Command('status') - .description('Show current hooks configuration') - .option('-p, --path ', 'Path inside the repository', '.') - .action(async (options) => { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); + + log.info('hooks_uninstall', { + ok: true, + repoRoot, + hooksPath, + duration_ms: Date.now() - startedAt, + }); + + return success({ ok: true, repoRoot, hooksPath }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('hooks:uninstall', { ok: false, err: message }); + return error('hooks_uninstall_failed', { message }); + } +} + +export async function handleHooksStatus(input: { + path: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'hooks:status' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); if (!isGitRepo(repoRoot)) { - console.log(JSON.stringify({ ok: false, error: 'not_a_git_repo', repoRoot }, null, 2)); - process.exitCode = 1; - return; + log.error('hooks:status', { ok: false, error: 'not_a_git_repo', repoRoot }); + return error('not_a_git_repo', { repoRoot, message: 'Not a git repository' }); } const hooksPath = getGitConfig(repoRoot, 'core.hooksPath'); - console.log(JSON.stringify({ ok: true, repoRoot, hooksPath, expected: '.githooks', installed: hooksPath === '.githooks' }, null, 2)); - }); - -export const hooksCommand = new Command('hooks') - .description('Manage git hooks integration') - .addCommand(install) - .addCommand(uninstall) - .addCommand(status); + + log.info('hooks_status', { + ok: true, + repoRoot, + hooksPath, + duration_ms: Date.now() - startedAt, + }); + + return success({ + ok: true, + repoRoot, + hooksPath, + expected: '.githooks', + installed: hooksPath === '.githooks', + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('hooks:status', { ok: false, err: message }); + return error('hooks_status_failed', { message }); + } +} diff --git a/src/cli/handlers/indexHandlers.ts b/src/cli/handlers/indexHandlers.ts new file mode 100644 index 0000000..83b77fc --- /dev/null +++ b/src/cli/handlers/indexHandlers.ts @@ -0,0 +1,92 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { inferScanRoot, resolveGitRoot } from '../../core/git'; +import { IndexerV2 } from '../../core/indexer'; +import { IncrementalIndexerV2 } from '../../core/indexerIncremental'; +import { getStagedNameStatus, getWorktreeNameStatus } from '../../core/gitDiff'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; + +export async function handleIndexRepo(input: { + path: string; + dim: number; + overwrite: boolean; + incremental: boolean; + staged: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'index' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const scanRoot = inferScanRoot(repoRoot); + const metaPath = path.join(repoRoot, '.git-ai', 'meta.json'); + const meta = await fs.readJSON(metaPath).catch(() => null); + const dim = typeof meta?.dim === 'number' ? meta.dim : input.dim; + const isTTY = Boolean(process.stderr.isTTY) && !process.env.CI; + let renderedInTTY = false; + let finishedInTTY = false; + const renderProgress = (p: { totalFiles: number; processedFiles: number; currentFile?: string }): void => { + const total = Math.max(0, p.totalFiles); + const done = Math.max(0, Math.min(total, p.processedFiles)); + if (!isTTY) { + if (done === 0) { + process.stderr.write(`Indexing ${total} files...\n`); + } else if (done === total || done % 200 === 0) { + process.stderr.write((`[${done}/${total}] ${p.currentFile ?? ''}`.trim()) + '\n'); + } + return; + } + const columns = Math.max(40, Number(process.stderr.columns ?? 100)); + const prefix = `${done}/${total}`; + const percent = total > 0 ? Math.floor((done / total) * 100) : 100; + const reserved = prefix.length + 1 + 6 + 1 + 1; + const barWidth = Math.max(10, Math.min(30, columns - reserved - 20)); + const filled = total > 0 ? Math.round((done / total) * barWidth) : barWidth; + const bar = `${'='.repeat(filled)}${'-'.repeat(Math.max(0, barWidth - filled))}`; + const fileSuffix = p.currentFile ? ` ${p.currentFile}` : ''; + const line = `[${bar}] ${String(percent).padStart(3)}% ${prefix}${fileSuffix}`; + const clipped = line.length >= columns ? line.slice(0, columns - 1) : line; + process.stderr.write(`\r${clipped.padEnd(columns - 1, ' ')}`); + renderedInTTY = true; + if (done === total && !finishedInTTY) { + process.stderr.write('\n'); + finishedInTTY = true; + } + }; + + if (input.incremental) { + const changes = input.staged ? await getStagedNameStatus(repoRoot) : await getWorktreeNameStatus(repoRoot); + const indexer = new IncrementalIndexerV2({ + repoRoot, + scanRoot, + dim, + source: input.staged ? 'staged' : 'worktree', + changes, + onProgress: renderProgress, + }); + await indexer.run(); + } else { + const indexer = new IndexerV2({ repoRoot, scanRoot, dim, overwrite: input.overwrite, onProgress: renderProgress }); + await indexer.run(); + } + if (renderedInTTY && !finishedInTTY) process.stderr.write('\n'); + log.info('index_repo', { + ok: true, + repoRoot, + scanRoot, + dim, + overwrite: input.overwrite, + duration_ms: Date.now() - startedAt, + }); + return success({ ok: true, repoRoot, scanRoot, dim, overwrite: input.overwrite, incremental: input.incremental, staged: input.staged }); + } catch (e) { + log.error('index_repo', { + ok: false, + duration_ms: Date.now() - startedAt, + err: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { message: String(e) }, + }); + return error('index_failed', { message: e instanceof Error ? e.message : String(e) }); + } +} diff --git a/src/cli/handlers/queryHandlers.ts b/src/cli/handlers/queryHandlers.ts new file mode 100644 index 0000000..9d4b3c6 --- /dev/null +++ b/src/cli/handlers/queryHandlers.ts @@ -0,0 +1,175 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { inferWorkspaceRoot, resolveGitRoot } from '../../core/git'; +import { defaultDbDir, openTablesByLang, type IndexLang } from '../../core/lancedb'; +import { queryManifestWorkspace } from '../../core/workspace'; +import { buildCoarseWhere, filterAndRankSymbolRows, inferSymbolSearchMode, pickCoarseToken, type SymbolSearchMode } from '../../core/symbolSearch'; +import { createLogger } from '../../core/log'; +import { checkIndex, resolveLangs } from '../../core/indexCheck'; +import { generateRepoMap, type FileRank } from '../../core/repoMap'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; +import { resolveRepoContext, validateIndex, resolveLanguages, type RepoContext } from '../helpers'; + +function isCLIError(value: unknown): value is CLIError { + return typeof value === 'object' && value !== null && 'ok' in value && (value as any).ok === false; +} + +async function buildRepoMapAttachment( + repoRoot: string, + options: { wiki: string; repoMapFiles: number; repoMapSymbols: number } +): Promise<{ enabled: boolean; wikiDir: string; files: FileRank[] } | { enabled: boolean; skippedReason: string }> { + try { + const wikiDir = resolveWikiDir(repoRoot, options.wiki); + const files = await generateRepoMap({ + repoRoot, + maxFiles: options.repoMapFiles, + maxSymbolsPerFile: options.repoMapSymbols, + wikiDir, + }); + return { enabled: true, wikiDir, files }; + } catch (e: any) { + return { enabled: false, skippedReason: String(e?.message ?? e) }; + } +} + +function resolveWikiDir(repoRoot: string, wikiOpt: string): string { + const w = String(wikiOpt ?? '').trim(); + if (w) return path.resolve(repoRoot, w); + const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return ''; +} + +function inferLangFromFile(file: string): IndexLang { + const f = String(file); + if (f.endsWith('.md') || f.endsWith('.mdx')) return 'markdown'; + if (f.endsWith('.yml') || f.endsWith('.yaml')) return 'yaml'; + if (f.endsWith('.java')) return 'java'; + if (f.endsWith('.c') || f.endsWith('.h')) return 'c'; + if (f.endsWith('.go')) return 'go'; + if (f.endsWith('.py')) return 'python'; + if (f.endsWith('.rs')) return 'rust'; + return 'ts'; +} + +function filterWorkspaceRowsByLang(rows: any[], langSel: string): any[] { + const sel = String(langSel ?? 'auto'); + if (sel === 'auto' || sel === 'all') return rows; + const target = sel as IndexLang; + return rows.filter(r => inferLangFromFile(String((r as any).file ?? '')) === target); +} + +export async function handleSearchSymbols(input: { + keyword: string; + path: string; + limit: number; + mode?: SymbolSearchMode; + caseInsensitive: boolean; + maxCandidates: number; + lang: string; + withRepoMap: boolean; + repoMapFiles: number; + repoMapSymbols: number; + wiki: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'query' }); + const startedAt = Date.now(); + + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const mode = inferSymbolSearchMode(input.keyword, input.mode); + + if (inferWorkspaceRoot(repoRoot)) { + const coarse = (mode === 'substring' || mode === 'prefix') ? input.keyword : pickCoarseToken(input.keyword); + const res = await queryManifestWorkspace({ manifestRepoRoot: repoRoot, keyword: coarse, limit: input.maxCandidates }); + const filteredByLang = filterWorkspaceRowsByLang(res.rows, input.lang); + const rows = filterAndRankSymbolRows(filteredByLang, { + query: input.keyword, + mode, + caseInsensitive: input.caseInsensitive, + limit: input.limit, + }); + log.info('query_symbols', { + ok: true, + repoRoot, + workspace: true, + mode, + case_insensitive: input.caseInsensitive, + limit: input.limit, + max_candidates: input.maxCandidates, + candidates: res.rows.length, + rows: rows.length, + duration_ms: Date.now() - startedAt, + }); + const repoMap = input.withRepoMap + ? { enabled: false, skippedReason: 'workspace_mode_not_supported' } + : undefined; + return success({ ...res, rows, ...(repoMap ? { repo_map: repoMap } : {}) }); + } + + const ctxOrError = await resolveRepoContext(input.path); + + if (isCLIError(ctxOrError)) { + return ctxOrError; + } + + const ctx = ctxOrError as RepoContext; + + const validationError = validateIndex(ctx); + if (validationError) { + return validationError; + } + + const langs = resolveLanguages(ctx.meta, input.lang); + if (langs.length === 0) { + return error('lang_not_available', { + lang: input.lang, + available: ctx.meta?.languages ?? [], + }); + } + + try { + const dbDir = defaultDbDir(ctx.repoRoot); + const dim = typeof ctx.meta?.dim === 'number' ? ctx.meta.dim : 256; + const { byLang } = await openTablesByLang({ dbDir, dim, mode: 'open_only', languages: langs as IndexLang[] }); + const where = buildCoarseWhere({ query: input.keyword, mode, caseInsensitive: input.caseInsensitive }); + const candidates: any[] = []; + for (const lang of langs) { + const t = byLang[lang as IndexLang]; + if (!t) continue; + const rows = where + ? await t.refs.query().where(where).limit(input.maxCandidates).toArray() + : await t.refs.query().limit(input.maxCandidates).toArray(); + for (const r of rows as any[]) candidates.push({ ...r, lang }); + } + const rows = filterAndRankSymbolRows(candidates, { query: input.keyword, mode, caseInsensitive: input.caseInsensitive, limit: input.limit }); + log.info('query_symbols', { + ok: true, + repoRoot: ctx.repoRoot, + workspace: false, + lang: input.lang, + langs, + mode, + case_insensitive: input.caseInsensitive, + limit: input.limit, + max_candidates: input.maxCandidates, + candidates: candidates.length, + rows: rows.length, + duration_ms: Date.now() - startedAt, + }); + const repoMap = input.withRepoMap ? await buildRepoMapAttachment(ctx.repoRoot, input) : undefined; + return success({ + repoRoot: ctx.repoRoot, + count: rows.length, + lang: input.lang, + rows, + ...(repoMap ? { repo_map: repoMap } : {}), + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('query_symbols', { ok: false, duration_ms: Date.now() - startedAt, err: message }); + return error('query_symbols_failed', { message }); + } +} diff --git a/src/cli/handlers/semanticHandlers.ts b/src/cli/handlers/semanticHandlers.ts new file mode 100644 index 0000000..269c2e5 --- /dev/null +++ b/src/cli/handlers/semanticHandlers.ts @@ -0,0 +1,152 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { resolveGitRoot } from '../../core/git'; +import { defaultDbDir, openTablesByLang, type IndexLang } from '../../core/lancedb'; +import { buildQueryVector, scoreAgainst } from '../../core/search'; +import { checkIndex, resolveLangs } from '../../core/indexCheck'; +import { generateRepoMap, type FileRank } from '../../core/repoMap'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; +import { resolveRepoContext, validateIndex, resolveLanguages, type RepoContext } from '../helpers'; + +function isCLIError(value: unknown): value is CLIError { + return typeof value === 'object' && value !== null && 'ok' in value && (value as any).ok === false; +} + +async function buildRepoMapAttachment( + repoRoot: string, + options: { wiki: string; repoMapFiles: number; repoMapSymbols: number } +): Promise<{ enabled: boolean; wikiDir: string; files: FileRank[] } | { enabled: boolean; skippedReason: string }> { + try { + const wikiDir = resolveWikiDir(repoRoot, options.wiki); + const files = await generateRepoMap({ + repoRoot, + maxFiles: options.repoMapFiles, + maxSymbolsPerFile: options.repoMapSymbols, + wikiDir, + }); + return { enabled: true, wikiDir, files }; + } catch (e: any) { + return { enabled: false, skippedReason: String(e?.message ?? e) }; + } +} + +function resolveWikiDir(repoRoot: string, wikiOpt: string): string { + const w = String(wikiOpt ?? '').trim(); + if (w) return path.resolve(repoRoot, w); + const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return ''; +} + +export async function handleSemanticSearch(input: { + text: string; + path: string; + topk: number; + lang: string; + withRepoMap: boolean; + repoMapFiles: number; + repoMapSymbols: number; + wiki: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'semantic' }); + const startedAt = Date.now(); + + const ctxOrError = await resolveRepoContext(input.path); + + if (isCLIError(ctxOrError)) { + return ctxOrError; + } + + const ctx = ctxOrError as RepoContext; + + const validationError = validateIndex(ctx); + if (validationError) { + return validationError; + } + + try { + const dbDir = defaultDbDir(ctx.repoRoot); + const dim = typeof ctx.meta?.dim === 'number' ? ctx.meta.dim : 256; + const q = buildQueryVector(input.text, dim); + const langs = resolveLanguages(ctx.meta, input.lang); + + if (langs.length === 0) { + return error('lang_not_available', { + lang: input.lang, + available: ctx.meta?.languages ?? [], + }); + } + + const { byLang } = await openTablesByLang({ dbDir, dim, mode: 'open_only', languages: langs as IndexLang[] }); + + const allScored: any[] = []; + let totalChunks = 0; + for (const lang of langs) { + const t = byLang[lang as IndexLang]; + if (!t) continue; + const chunkRows = await t.chunks.query().select(['content_hash', 'text', 'dim', 'scale', 'qvec_b64']).limit(1_000_000).toArray(); + totalChunks += (chunkRows as any[]).length; + for (const r of chunkRows as any[]) { + allScored.push({ + lang, + content_hash: String(r.content_hash), + score: scoreAgainst(q, { dim: Number(r.dim), scale: Number(r.scale), qvec: new Int8Array(Buffer.from(String(r.qvec_b64), 'base64')) }), + text: String(r.text), + }); + } + } + const scored = allScored.sort((a, b) => b.score - a.score).slice(0, input.topk); + + const neededByLang: Partial>> = {}; + for (const s of scored) { + if (!neededByLang[s.lang]) neededByLang[s.lang] = new Set(); + neededByLang[s.lang]!.add(String(s.content_hash)); + } + + const refsByLangAndId = new Map>(); + for (const lang of langs) { + const t = byLang[lang as IndexLang]; + if (!t) continue; + const needed = neededByLang[lang]; + if (!needed || needed.size === 0) continue; + const refsRows = await t.refs.query().select(['content_hash', 'file', 'symbol', 'kind', 'signature', 'start_line', 'end_line']) + .limit(1_000_000) + .toArray(); + const byId = new Map(); + for (const r of refsRows as any[]) { + const id = String(r.content_hash); + if (!needed.has(id)) continue; + if (!byId.has(id)) byId.set(id, []); + byId.get(id)!.push(r); + } + refsByLangAndId.set(lang, byId); + } + + const hits = scored.map(s => ({ + ...s, + refs: (refsByLangAndId.get(s.lang)?.get(s.content_hash) || []).slice(0, 5), + })); + + log.info('semantic_search', { + ok: true, + repoRoot: ctx.repoRoot, + topk: input.topk, + lang: input.lang, + langs, + chunks: totalChunks, + hits: hits.length, + duration_ms: Date.now() - startedAt, + }); + + const repoMap = input.withRepoMap ? await buildRepoMapAttachment(ctx.repoRoot, input) : undefined; + return success({ repoRoot: ctx.repoRoot, topk: input.topk, lang: input.lang, hits, ...(repoMap ? { repo_map: repoMap } : {}) }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('semantic_search', { ok: false, duration_ms: Date.now() - startedAt, err: message }); + return error('semantic_search_failed', { message }); + } +} diff --git a/src/cli/handlers/serveHandlers.ts b/src/cli/handlers/serveHandlers.ts new file mode 100644 index 0000000..f0c0b9b --- /dev/null +++ b/src/cli/handlers/serveHandlers.ts @@ -0,0 +1,112 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { resolveGitRoot } from '../../core/git'; +import { GitAIV2MCPServer } from '../../mcp/server'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; + +export async function handleServe(input: { + disableMcpLog: boolean; + http: boolean; + port: number; + stateless: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'serve' }); + log.info('serve_start', { + disableAccessLog: input.disableMcpLog, + transport: input.http ? 'http' : 'stdio', + port: input.http ? input.port : undefined, + stateless: input.http ? input.stateless : undefined, + }); + + const server = new GitAIV2MCPServer(process.cwd(), { + disableAccessLog: !!input.disableMcpLog, + transport: input.http ? 'http' : 'stdio', + port: input.port, + stateless: input.stateless, + }); + await server.start(); + throw new Error('Server should never return'); +} + +async function findPackageRoot(startDir: string): Promise { + let cur = path.resolve(startDir); + for (let i = 0; i < 12; i++) { + const pj = path.join(cur, 'package.json'); + if (await fs.pathExists(pj)) return cur; + const parent = path.dirname(cur); + if (parent === cur) break; + cur = parent; + } + return path.resolve(startDir); +} + +async function listDirNames(p: string): Promise { + if (!await fs.pathExists(p)) return []; + const entries = await fs.readdir(p); + const out: string[] = []; + for (const n of entries) { + const full = path.join(p, n); + try { + const st = await fs.stat(full); + if (st.isDirectory()) out.push(n); + } catch { + } + } + return out.sort(); +} + +export async function handleAgentInstall(input: { + path: string; + to?: string; + agent: string; + overwrite: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'agent:install' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const agent = String(input.agent).trim().toLowerCase(); + const defaultDirName = agent === 'trae' ? '.trae' : '.agents'; + const destDir = String(input.to ?? '').trim() ? path.resolve(String(input.to)) : path.join(repoRoot, defaultDirName); + const overwrite = Boolean(input.overwrite); + + const packageRoot = await findPackageRoot(__dirname); + const srcTemplateDir = path.join(packageRoot, 'templates', 'agents', 'common'); + const srcSkillsDir = path.join(srcTemplateDir, 'skills'); + const srcRulesDir = path.join(srcTemplateDir, 'rules'); + if (!await fs.pathExists(srcSkillsDir) || !await fs.pathExists(srcRulesDir)) { + log.error('agent_install', { ok: false, error: 'template_missing', srcTemplateDir }); + return error('template_missing', { repoRoot, message: 'Template directory missing' }); + } + + const dstSkillsDir = path.join(destDir, 'skills'); + const dstRulesDir = path.join(destDir, 'rules'); + await fs.ensureDir(destDir); + await fs.copy(srcSkillsDir, dstSkillsDir, { overwrite }); + await fs.copy(srcRulesDir, dstRulesDir, { overwrite }); + + const installed = { + skills: await listDirNames(dstSkillsDir), + rules: await listDirNames(dstRulesDir), + }; + + log.info('agent_install', { + ok: true, + repoRoot, + agent, + destDir, + overwrite, + installed, + duration_ms: Date.now() - startedAt, + }); + + return success({ ok: true, repoRoot, agent, destDir, overwrite, installed }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('agent_install', { ok: false, err: message }); + return error('agent_install_failed', { message }); + } +} diff --git a/src/commands/status.ts b/src/cli/handlers/statusHandlers.ts similarity index 57% rename from src/commands/status.ts rename to src/cli/handlers/statusHandlers.ts index e96699f..9b4713d 100644 --- a/src/commands/status.ts +++ b/src/cli/handlers/statusHandlers.ts @@ -1,20 +1,45 @@ -import { Command } from 'commander'; import path from 'path'; -import { resolveGitRoot } from '../core/git'; -import { checkIndex } from '../core/indexCheck'; -import { ALL_INDEX_LANGS } from '../core/lancedb'; +import { resolveGitRoot } from '../../core/git'; +import { checkIndex } from '../../core/indexCheck'; +import { ALL_INDEX_LANGS } from '../../core/lancedb'; +import { createLogger } from '../../core/log'; +import type { CLIResult, CLIError } from '../types'; +import { success, error } from '../types'; -export const statusCommand = new Command('status') - .description('Show repository index status') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--json', 'Output machine-readable JSON', false) - .action(async (options) => { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); +export async function handleCheckIndex(input: { + path: string; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'checkIndex' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); + const res = await checkIndex(repoRoot); + + log.info('check_index', { + ok: res.ok, + repoRoot, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot, ...res }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('checkIndex', { ok: false, err: message }); + return error('check_index_failed', { message }); + } +} + +export async function handleStatus(input: { + path: string; + json: boolean; +}): Promise { + const log = createLogger({ component: 'cli', cmd: 'status' }); + const startedAt = Date.now(); + + try { + const repoRoot = await resolveGitRoot(path.resolve(input.path)); const res = await checkIndex(repoRoot); - if (options.json) { - console.log(JSON.stringify({ repoRoot, ...res }, null, 2)); - process.exit(res.ok ? 0 : 2); - } const meta = res.found.meta ?? null; const lines: string[] = []; @@ -31,7 +56,6 @@ export const statusCommand = new Command('status') if (meta.dbDir) lines.push(`db: ${meta.dbDir}`); if (meta.scanRoot) lines.push(`scanRoot: ${meta.scanRoot}`); - // Display commit information if (meta.commit_hash) { const shortHash = meta.commit_hash.slice(0, 7); const currentHash = res.found.currentCommitHash; @@ -61,6 +85,17 @@ export const statusCommand = new Command('status') lines.push(`hint: Index may be out of date. Run: git-ai ai index --incremental`); } } - console.log(lines.join('\n')); - process.exit(res.ok ? 0 : 2); - }); + + log.info('status', { + ok: res.ok, + repoRoot, + duration_ms: Date.now() - startedAt, + }); + + return success({ repoRoot, ...res, textOutput: lines.join('\n') }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log.error('status', { ok: false, err: message }); + return error('status_failed', { message }); + } +} diff --git a/src/cli/helpers.ts b/src/cli/helpers.ts new file mode 100644 index 0000000..4af6eac --- /dev/null +++ b/src/cli/helpers.ts @@ -0,0 +1,114 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { resolveGitRoot } from '../core/git'; +import { checkIndex, resolveLangs, type IndexMetaV21, type IndexCheckResult } from '../core/indexCheck'; +import type { CLIError } from './types'; + +export interface RepoContext { + repoRoot: string; + meta: IndexMetaV21 | null; + indexStatus: IndexCheckResult; +} + +/** + * Resolve repository context from a path + * + * This combines common operations: + * 1. Resolve git root + * 2. Check index status + * 3. Load metadata + * + * @param startPath - Path inside the repository (default: '.') + * @returns Repository context or error + */ +export async function resolveRepoContext(startPath: string = '.'): Promise { + try { + const repoRoot = await resolveGitRoot(path.resolve(startPath)); + const status = await checkIndex(repoRoot); + + // Load metadata + const metaPath = path.join(repoRoot, '.git-ai', 'meta.json'); + const meta = await fs.readJSON(metaPath).catch(() => null); + + return { + repoRoot, + meta, + indexStatus: status, + }; + } catch (e) { + return { + ok: false, + reason: 'repo_resolution_failed', + message: e instanceof Error ? e.message : String(e), + }; + } +} + +/** + * Validate that index exists and is compatible + * + * @param ctx - Repository context + * @returns null if valid, error object if invalid + */ +export function validateIndex(ctx: RepoContext): CLIError | null { + if (!ctx.indexStatus.ok) { + const error: CLIError = { + ok: false, + reason: 'index_incompatible', + message: 'Index is missing or incompatible. Run: git-ai ai index --overwrite', + }; + return Object.assign(error, ctx.indexStatus); + } + return null; +} + +/** + * Resolve language selection from user input + * + * @param meta - Repository metadata + * @param langInput - User's language selection ('auto', 'all', or specific language) + * @returns Array of resolved languages + */ +export function resolveLanguages( + meta: RepoContext['meta'], + langInput: string = 'auto' +): string[] { + return resolveLangs(meta, langInput as any); +} + +/** + * Format an error for CLI output + * + * @param error - Error object + * @param code - Optional error code for categorization + * @returns Formatted error object + */ +export function formatError(error: Error | unknown, code?: string): CLIError { + const err = error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }; + + return { + ok: false, + reason: code || 'error', + ...err, + }; +} + +/** + * Common options shared across multiple commands + */ +export interface CommonOptions { + path?: string; + lang?: string; +} + +/** + * Parse and validate common options + */ +export function parseCommonOptions(options: Record): CommonOptions { + return { + path: typeof options.path === 'string' ? options.path : '.', + lang: typeof options.lang === 'string' ? options.lang : 'auto', + }; +} diff --git a/src/cli/registry.ts b/src/cli/registry.ts new file mode 100644 index 0000000..8b8f564 --- /dev/null +++ b/src/cli/registry.ts @@ -0,0 +1,155 @@ +import type { HandlerRegistration } from './types'; +import { + GraphQuerySchema, + FindSymbolsSchema, + GraphChildrenSchema, + GraphRefsSchema, + GraphCallersSchema, + GraphCalleesSchema, + GraphChainSchema, +} from './schemas/graphSchemas'; +import { + handleGraphQuery, + handleFindSymbols, + handleGraphChildren, + handleGraphRefs, + handleGraphCallers, + handleGraphCallees, + handleGraphChain, +} from './handlers/graphHandlers'; +import { SemanticSearchSchema } from './schemas/semanticSchemas'; +import { IndexRepoSchema } from './schemas/indexSchemas'; +import { SearchSymbolsSchema } from './schemas/querySchemas'; +import { handleSemanticSearch } from './handlers/semanticHandlers'; +import { handleIndexRepo } from './handlers/indexHandlers'; +import { handleSearchSymbols } from './handlers/queryHandlers'; +import { + DsrContextSchema, + DsrGenerateSchema, + DsrRebuildIndexSchema, + DsrSymbolEvolutionSchema, +} from './schemas/dsrSchemas'; +import { + handleDsrContext, + handleDsrGenerate, + handleDsrRebuildIndex, + handleDsrSymbolEvolution, +} from './handlers/dsrHandlers'; +import { CheckIndexSchema, StatusSchema } from './schemas/statusSchemas'; +import { handleCheckIndex, handleStatus } from './handlers/statusHandlers'; +import { PackIndexSchema, UnpackIndexSchema } from './schemas/archiveSchemas'; +import { handlePackIndex, handleUnpackIndex } from './handlers/archiveHandlers'; +import { InstallHooksSchema, UninstallHooksSchema, HooksStatusSchema } from './schemas/hooksSchemas'; +import { handleInstallHooks, handleUninstallHooks, handleHooksStatus } from './handlers/hooksHandlers'; +import { ServeSchema, AgentInstallSchema } from './schemas/serveSchemas'; +import { handleServe, handleAgentInstall } from './handlers/serveHandlers'; + +/** + * Registry of all CLI command handlers + * + * Maps command keys to their schema + handler implementations. + * + * Command keys follow the pattern: + * - Top-level commands: 'index', 'semantic', 'status' + * - Subcommands: 'graph:find', 'graph:query', 'dsr:generate' + * + * This will be populated as commands are migrated from src/commands/*.ts + */ +export const cliHandlers: Record> = { + // Top-level commands + 'semantic': { + schema: SemanticSearchSchema, + handler: handleSemanticSearch, + }, + 'index': { + schema: IndexRepoSchema, + handler: handleIndexRepo, + }, + 'query': { + schema: SearchSymbolsSchema, + handler: handleSearchSymbols, + }, + 'status': { + schema: StatusSchema, + handler: handleStatus, + }, + 'checkIndex': { + schema: CheckIndexSchema, + handler: handleCheckIndex, + }, + 'pack': { + schema: PackIndexSchema, + handler: handlePackIndex, + }, + 'unpack': { + schema: UnpackIndexSchema, + handler: handleUnpackIndex, + }, + 'serve': { + schema: ServeSchema, + handler: handleServe, + }, + 'agent': { + schema: AgentInstallSchema, + handler: handleAgentInstall, + }, + // DSR subcommands + 'dsr:context': { + schema: DsrContextSchema, + handler: handleDsrContext, + }, + 'dsr:generate': { + schema: DsrGenerateSchema, + handler: handleDsrGenerate, + }, + 'dsr:rebuild-index': { + schema: DsrRebuildIndexSchema, + handler: handleDsrRebuildIndex, + }, + 'dsr:symbol-evolution': { + schema: DsrSymbolEvolutionSchema, + handler: handleDsrSymbolEvolution, + }, + // Hooks subcommands + 'hooks:install': { + schema: InstallHooksSchema, + handler: handleInstallHooks, + }, + 'hooks:uninstall': { + schema: UninstallHooksSchema, + handler: handleUninstallHooks, + }, + 'hooks:status': { + schema: HooksStatusSchema, + handler: handleHooksStatus, + }, + // Graph subcommands + 'graph:query': { + schema: GraphQuerySchema, + handler: handleGraphQuery, + }, + 'graph:find': { + schema: FindSymbolsSchema, + handler: handleFindSymbols, + }, + 'graph:children': { + schema: GraphChildrenSchema, + handler: handleGraphChildren, + }, + 'graph:refs': { + schema: GraphRefsSchema, + handler: handleGraphRefs, + }, + 'graph:callers': { + schema: GraphCallersSchema, + handler: handleGraphCallers, + }, + 'graph:callees': { + schema: GraphCalleesSchema, + handler: handleGraphCallees, + }, + 'graph:chain': { + schema: GraphChainSchema, + handler: handleGraphChain, + }, +}; diff --git a/src/cli/schemas/archiveSchemas.ts b/src/cli/schemas/archiveSchemas.ts new file mode 100644 index 0000000..014fba8 --- /dev/null +++ b/src/cli/schemas/archiveSchemas.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const PackIndexSchema = z.object({ + path: z.string().default('.'), + lfs: z.boolean().default(false), +}); + +export const UnpackIndexSchema = z.object({ + path: z.string().default('.'), +}); diff --git a/src/cli/schemas/dsrSchemas.ts b/src/cli/schemas/dsrSchemas.ts new file mode 100644 index 0000000..59b589b --- /dev/null +++ b/src/cli/schemas/dsrSchemas.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const DsrContextSchema = z.object({ + path: z.string().default('.'), + json: z.boolean().default(false), +}); + +export const DsrGenerateSchema = z.object({ + commit: z.string().min(1, 'Commit hash is required'), + path: z.string().default('.'), + json: z.boolean().default(false), +}); + +export const DsrRebuildIndexSchema = z.object({ + path: z.string().default('.'), + json: z.boolean().default(false), +}); + +export const DsrSymbolEvolutionSchema = z.object({ + symbol: z.string().min(1, 'Symbol name is required'), + path: z.string().default('.'), + all: z.boolean().default(false), + start: z.string().optional(), + limit: z.coerce.number().int().positive().default(200), + contains: z.boolean().default(false), + json: z.boolean().default(false), +}); diff --git a/src/cli/schemas/graphSchemas.ts b/src/cli/schemas/graphSchemas.ts new file mode 100644 index 0000000..433e2af --- /dev/null +++ b/src/cli/schemas/graphSchemas.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +const languageEnum = z.enum(['auto', 'all', 'java', 'ts', 'python', 'go', 'rust', 'c', 'markdown', 'yaml']); + +export const GraphQuerySchema = z.object({ + scriptParts: z.array(z.string()).min(1, 'Query script is required'), + path: z.string().default('.'), + params: z.string().default('{}'), +}); + +export const FindSymbolsSchema = z.object({ + prefix: z.string().min(1, 'Prefix is required'), + path: z.string().default('.'), + lang: languageEnum.default('auto'), +}); + +export const GraphChildrenSchema = z.object({ + id: z.string().min(1, 'Parent id is required'), + path: z.string().default('.'), + asFile: z.boolean().default(false), +}); + +export const GraphRefsSchema = z.object({ + name: z.string().min(1, 'Symbol name is required'), + path: z.string().default('.'), + limit: z.coerce.number().int().positive().default(200), + lang: languageEnum.default('auto'), +}); + +export const GraphCallersSchema = z.object({ + name: z.string().min(1, 'Callee name is required'), + path: z.string().default('.'), + limit: z.coerce.number().int().positive().default(200), + lang: languageEnum.default('auto'), +}); + +export const GraphCalleesSchema = z.object({ + name: z.string().min(1, 'Caller name is required'), + path: z.string().default('.'), + limit: z.coerce.number().int().positive().default(200), + lang: languageEnum.default('auto'), +}); + +export const GraphChainSchema = z.object({ + name: z.string().min(1, 'Start symbol name is required'), + path: z.string().default('.'), + direction: z.enum(['downstream', 'upstream']).default('downstream'), + depth: z.coerce.number().int().positive().default(3), + limit: z.coerce.number().int().positive().default(500), + minNameLen: z.coerce.number().int().min(1).default(1), + lang: languageEnum.default('auto'), +}); diff --git a/src/cli/schemas/hooksSchemas.ts b/src/cli/schemas/hooksSchemas.ts new file mode 100644 index 0000000..5ae4311 --- /dev/null +++ b/src/cli/schemas/hooksSchemas.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const InstallHooksSchema = z.object({ + path: z.string().default('.'), +}); + +export const UninstallHooksSchema = z.object({ + path: z.string().default('.'), +}); + +export const HooksStatusSchema = z.object({ + path: z.string().default('.'), +}); diff --git a/src/cli/schemas/indexSchemas.ts b/src/cli/schemas/indexSchemas.ts new file mode 100644 index 0000000..11a0324 --- /dev/null +++ b/src/cli/schemas/indexSchemas.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const IndexRepoSchema = z.object({ + path: z.string().default('.'), + dim: z.coerce.number().int().positive().default(256), + overwrite: z.boolean().default(false), + incremental: z.boolean().default(false), + staged: z.boolean().default(false), +}); diff --git a/src/cli/schemas/querySchemas.ts b/src/cli/schemas/querySchemas.ts new file mode 100644 index 0000000..f6becd7 --- /dev/null +++ b/src/cli/schemas/querySchemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const languageEnum = z.enum(['auto', 'all', 'java', 'ts', 'python', 'go', 'rust', 'c', 'markdown', 'yaml']); +const searchModeEnum = z.enum(['substring', 'prefix', 'wildcard', 'regex', 'fuzzy']); + +export const SearchSymbolsSchema = z.object({ + keyword: z.string().min(1, 'Keyword is required'), + path: z.string().default('.'), + limit: z.coerce.number().int().positive().default(50), + mode: searchModeEnum.optional(), + caseInsensitive: z.boolean().default(false), + maxCandidates: z.coerce.number().int().positive().default(1000), + lang: languageEnum.default('auto'), + withRepoMap: z.boolean().default(false), + repoMapFiles: z.coerce.number().int().positive().default(20), + repoMapSymbols: z.coerce.number().int().positive().default(5), + wiki: z.string().default(''), +}); diff --git a/src/cli/schemas/semanticSchemas.ts b/src/cli/schemas/semanticSchemas.ts new file mode 100644 index 0000000..6e9363d --- /dev/null +++ b/src/cli/schemas/semanticSchemas.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +const languageEnum = z.enum(['auto', 'all', 'java', 'ts', 'python', 'go', 'rust', 'c', 'markdown', 'yaml']); + +export const SemanticSearchSchema = z.object({ + text: z.string().min(1, 'Query text is required'), + path: z.string().default('.'), + topk: z.coerce.number().int().positive().default(10), + lang: languageEnum.default('auto'), + withRepoMap: z.boolean().default(false), + repoMapFiles: z.coerce.number().int().positive().default(20), + repoMapSymbols: z.coerce.number().int().positive().default(5), + wiki: z.string().default(''), +}); diff --git a/src/cli/schemas/serveSchemas.ts b/src/cli/schemas/serveSchemas.ts new file mode 100644 index 0000000..a815387 --- /dev/null +++ b/src/cli/schemas/serveSchemas.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ServeSchema = z.object({ + disableMcpLog: z.boolean().default(false), + http: z.boolean().default(false), + port: z.coerce.number().default(3000), + stateless: z.boolean().default(false), +}); + +export const AgentInstallSchema = z.object({ + path: z.string().default('.'), + to: z.string().optional(), + agent: z.string().default('agents'), + overwrite: z.boolean().default(false), +}); diff --git a/src/cli/schemas/statusSchemas.ts b/src/cli/schemas/statusSchemas.ts new file mode 100644 index 0000000..8124a33 --- /dev/null +++ b/src/cli/schemas/statusSchemas.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const CheckIndexSchema = z.object({ + path: z.string().default('.'), +}); + +export const StatusSchema = z.object({ + path: z.string().default('.'), + json: z.boolean().default(false), +}); diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..f41a1be --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,145 @@ +import { z, ZodSchema } from 'zod'; +import { createLogger } from '../core/log'; + +/** + * Standard CLI result interface for successful operations + */ +export interface CLIResult { + ok: true; + [key: string]: unknown; +} + +/** + * Standard CLI error interface + */ +export interface CLIError { + ok: false; + reason: string; + message?: string; + [key: string]: unknown; +} + +/** + * CLI handler function signature + * @template TInput - Validated input type (from Zod schema) + */ +export type CLIHandler = (input: TInput) => Promise; + +/** + * Handler registration with schema and handler function + */ +export interface HandlerRegistration { + schema: ZodSchema; + handler: CLIHandler; +} + +/** + * Execute a CLI handler with validation and error handling + * + * @param commandKey - Unique command identifier (e.g., 'graph:find', 'semantic') + * @param rawInput - Raw input from Commander.js (arguments + options) + * + * @example + * ```typescript + * .action(async (prefix, options) => { + * await executeHandler('graph:find', { prefix, ...options }); + * }) + * ``` + */ +export async function executeHandler( + commandKey: string, + rawInput: unknown +): Promise { + const { cliHandlers } = await import('./registry.js'); + + const handler = cliHandlers[commandKey]; + if (!handler) { + console.error(JSON.stringify( + { ok: false, reason: 'unknown_command', command: commandKey }, + null, + 2 + )); + process.exit(1); + return; + } + + const log = createLogger({ component: 'cli', cmd: commandKey }); + + try { + // Validate input with Zod schema + const validInput = handler.schema.parse(rawInput); + + // Execute handler + const result = await handler.handler(validInput); + + if (result.ok) { + // Success: output to stdout + console.log(JSON.stringify(result, null, 2)); + process.exit(0); + } else { + // Business logic error: output to stderr, exit with code 2 + process.stderr.write(JSON.stringify(result, null, 2) + '\n'); + process.exit(2); + } + } catch (e) { + if (e instanceof z.ZodError) { + const errors = e.issues.map((err: z.ZodIssue) => ({ + path: err.path.join('.'), + message: err.message, + code: err.code, + })); + + console.error(JSON.stringify( + { + ok: false, + reason: 'validation_error', + message: 'Invalid command arguments', + errors, + }, + null, + 2 + )); + process.exit(1); + return; + } + + // Unexpected error + const errorDetails = e instanceof Error + ? { name: e.name, message: e.message, stack: e.stack } + : { message: String(e) }; + + log.error(commandKey, { ok: false, err: errorDetails }); + + console.error(JSON.stringify( + { + ok: false, + reason: 'internal_error', + message: e instanceof Error ? e.message : String(e), + }, + null, + 2 + )); + process.exit(1); + } +} + +/** + * Format a result for CLI output (utility for handlers that want custom formatting) + */ +export function formatCLIResult(result: CLIResult | CLIError): string { + return JSON.stringify(result, null, 2); +} + +/** + * Create a success result + */ +export function success(data: Record): CLIResult { + return { ok: true, ...data }; +} + +/** + * Create an error result + */ +export function error(reason: string, details?: Record): CLIError { + return { ok: false, reason, ...details }; +} diff --git a/src/commands/ai.ts b/src/commands/ai.ts index bacc963..6d9081f 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -1,16 +1,13 @@ import { Command } from 'commander'; -import { indexCommand } from './index'; -import { queryCommand } from './query'; -import { semanticCommand } from './semantic'; -import { serveCommand } from './serve'; -import { packCommand } from './pack'; -import { unpackCommand } from './unpack'; -import { hooksCommand } from './hooks'; -import { graphCommand } from './graph'; -import { checkIndexCommand } from './checkIndex'; -import { statusCommand } from './status'; -import { agentCommand } from './trae'; -import { dsrCommand } from './dsr'; +import { indexCommand } from '../cli/commands/indexCommand.js'; +import { queryCommand } from '../cli/commands/queryCommand.js'; +import { semanticCommand } from '../cli/commands/semanticCommand.js'; +import { serveCommand, agentCommand } from '../cli/commands/serveCommands.js'; +import { packCommand, unpackCommand } from '../cli/commands/archiveCommands.js'; +import { hooksCommand } from '../cli/commands/hooksCommands.js'; +import { graphCommand } from '../cli/commands/graphCommands.js'; +import { checkIndexCommand, statusCommand } from '../cli/commands/statusCommands.js'; +import { dsrCommand } from '../cli/commands/dsrCommands.js'; export const aiCommand = new Command('ai') .description('AI features (indexing, search, hooks, MCP)') diff --git a/src/commands/checkIndex.ts b/src/commands/checkIndex.ts deleted file mode 100644 index 92ebd53..0000000 --- a/src/commands/checkIndex.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import { resolveGitRoot } from '../core/git'; -import { checkIndex } from '../core/indexCheck'; - -export const checkIndexCommand = new Command('check-index') - .description('Deprecated: use `git-ai ai status --json`') - .option('-p, --path ', 'Path inside the repository', '.') - .action(async (options) => { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const res = await checkIndex(repoRoot); - console.log(JSON.stringify({ repoRoot, ...res }, null, 2)); - process.exit(res.ok ? 0 : 2); - }); diff --git a/src/commands/dsr.ts b/src/commands/dsr.ts deleted file mode 100644 index ac3056a..0000000 --- a/src/commands/dsr.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import { detectRepoGitContext } from '../core/dsr/gitContext'; -import { generateDsrForCommit } from '../core/dsr/generate'; -import { materializeDsrIndex } from '../core/dsr/indexMaterialize'; -import { symbolEvolution } from '../core/dsr/query'; -import { getDsrDirectoryState } from '../core/dsr/state'; - -export const dsrCommand = new Command('dsr') - .description('Deterministic Semantic Record (per-commit, immutable, Git-addressable)'); - -dsrCommand - .command('context') - .description('Discover repository root, HEAD, branch, and DSR directory state') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--json', 'Output machine-readable JSON', false) - .action(async (options) => { - const start = path.resolve(options.path); - const ctx = await detectRepoGitContext(start); - const state = await getDsrDirectoryState(ctx.repo_root); - const out = { - commit_hash: ctx.head_commit, - repo_root: ctx.repo_root, - branch: ctx.branch, - detached: ctx.detached, - dsr_directory_state: state, - }; - if (options.json) { - console.log(JSON.stringify(out, null, 2)); - process.exit(0); - } - const lines: string[] = []; - lines.push(`repo: ${out.repo_root}`); - lines.push(`head: ${out.commit_hash}`); - lines.push(`branch: ${out.detached ? '(detached)' : out.branch}`); - lines.push(`dsrCacheRoot: ${out.dsr_directory_state.cache_root} (${out.dsr_directory_state.cache_root_exists ? 'exists' : 'missing'})`); - lines.push(`dsrDir: ${out.dsr_directory_state.dsr_dir} (${out.dsr_directory_state.dsr_dir_exists ? 'exists' : 'missing'})`); - lines.push(`dsrFiles: ${String(out.dsr_directory_state.dsr_file_count)}`); - console.log(lines.join('\n')); - process.exit(0); - }); - -dsrCommand - .command('generate') - .description('Generate DSR for exactly one commit') - .argument('', 'Commit hash (any rev that resolves to a commit)') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--json', 'Output machine-readable JSON', false) - .action(async (commit: string, options) => { - const start = path.resolve(options.path); - const ctx = await detectRepoGitContext(start); - const res = await generateDsrForCommit(ctx.repo_root, String(commit)); - const out = { - commit_hash: res.dsr.commit_hash, - file_path: res.file_path, - existed: res.existed, - counts: { - affected_symbols: res.dsr.affected_symbols.length, - ast_operations: res.dsr.ast_operations.length, - }, - semantic_change_type: res.dsr.semantic_change_type, - risk_level: res.dsr.risk_level, - }; - if (options.json) { - console.log(JSON.stringify(out, null, 2)); - process.exit(0); - } - const lines: string[] = []; - lines.push(`commit: ${out.commit_hash}`); - lines.push(`dsr: ${out.file_path}`); - lines.push(`status: ${out.existed ? 'exists' : 'generated'}`); - lines.push(`ops: ${String(out.counts.ast_operations)}`); - lines.push(`affected_symbols: ${String(out.counts.affected_symbols)}`); - lines.push(`semantic_change_type: ${out.semantic_change_type}`); - lines.push(`risk_level: ${out.risk_level ?? 'unknown'}`); - console.log(lines.join('\n')); - process.exit(0); - }); - -dsrCommand - .command('rebuild-index') - .description('Rebuild performance-oriented DSR index from DSR files') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--json', 'Output machine-readable JSON', false) - .action(async (options) => { - const start = path.resolve(options.path); - const ctx = await detectRepoGitContext(start); - const res = await materializeDsrIndex(ctx.repo_root); - if (options.json) { - console.log(JSON.stringify({ repo_root: ctx.repo_root, ...res }, null, 2)); - process.exit(res.enabled ? 0 : 2); - } - if (!res.enabled) { - console.error(res.skippedReason ?? 'rebuild-index skipped'); - process.exit(2); - } - const lines: string[] = []; - lines.push(`repo: ${ctx.repo_root}`); - lines.push(`engine: ${res.engine}`); - if (res.dbPath) lines.push(`db: ${res.dbPath}`); - if (res.exportPath) lines.push(`export: ${res.exportPath}`); - if (res.counts) { - lines.push(`commits: ${String(res.counts.commits)}`); - lines.push(`affected_symbols: ${String(res.counts.affected_symbols)}`); - lines.push(`ast_operations: ${String(res.counts.ast_operations)}`); - } - console.log(lines.join('\n')); - process.exit(0); - }); - -const queryCommand = new Command('query').description('Read-only semantic queries over Git DAG + DSR'); - -queryCommand - .command('symbol-evolution') - .description('List commits where a symbol changed (requires DSR per traversed commit)') - .argument('', 'Symbol name') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--all', 'Traverse all refs (default: from HEAD)', false) - .option('--start ', 'Start commit (default: HEAD)') - .option('--limit ', 'Max commits to traverse', (v) => Number(v), 200) - .option('--contains', 'Match by substring instead of exact match', false) - .option('--json', 'Output machine-readable JSON', false) - .action(async (symbol: string, options) => { - const startDir = path.resolve(options.path); - const ctx = await detectRepoGitContext(startDir); - const res = await symbolEvolution(ctx.repo_root, String(symbol), { - all: Boolean(options.all), - start: options.start ? String(options.start) : undefined, - limit: Number(options.limit), - contains: Boolean(options.contains), - }); - if (options.json) { - console.log(JSON.stringify({ repo_root: ctx.repo_root, symbol, ...res }, null, 2)); - process.exit(res.ok ? 0 : 2); - } - if (!res.ok) { - console.error(`missing DSR for commit: ${res.missing_dsrs?.[0] ?? 'unknown'}`); - process.exit(2); - } - const hits = res.hits ?? []; - const lines: string[] = []; - lines.push(`repo: ${ctx.repo_root}`); - lines.push(`symbol: ${symbol}`); - lines.push(`hits: ${String(hits.length)}`); - for (const h of hits.slice(0, 50)) { - const opKinds = Array.from(new Set(h.operations.map((o) => o.op))).sort().join(','); - lines.push(`${h.commit_hash} ${h.semantic_change_type} ${h.risk_level ?? ''} ops=${String(h.operations.length)} kinds=${opKinds} ${h.summary ?? ''}`.trim()); - } - if (hits.length > 50) lines.push(`... (${hits.length - 50} more)`); - console.log(lines.join('\n')); - process.exit(0); - }); - -dsrCommand.addCommand(queryCommand); diff --git a/src/commands/graph.ts b/src/commands/graph.ts deleted file mode 100644 index fbecb83..0000000 --- a/src/commands/graph.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import { resolveGitRoot } from '../core/git'; -import { sha256Hex } from '../core/crypto'; -import { buildCallChainDownstreamByNameQuery, buildCallChainUpstreamByNameQuery, buildCalleesByNameQuery, buildCallersByNameQuery, buildChildrenQuery, buildFindReferencesQuery, buildFindSymbolsQuery, runAstGraphQuery } from '../core/astGraphQuery'; -import { toPosixPath } from '../core/paths'; -import { createLogger } from '../core/log'; -import { checkIndex, resolveLangs } from '../core/indexCheck'; - -export const graphCommand = new Command('graph') - .description('AST graph search powered by CozoDB') - .addCommand( - new Command('query') - .description('Run a CozoScript query against the AST graph database') - .argument('', 'CozoScript query') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--params ', 'JSON params object', '{}') - .action(async (scriptParts, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai graph query' }); - const startedAt = Date.now(); - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const query = Array.isArray(scriptParts) ? scriptParts.join(' ') : String(scriptParts ?? ''); - const params = JSON.parse(String(options.params ?? '{}')); - const result = await runAstGraphQuery(repoRoot, query, params); - log.info('ast_graph_query', { ok: true, repoRoot, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, result }, null, 2)); - }) - ) - .addCommand( - new Command('find') - .description('Find symbols by name prefix') - .argument('', 'Name prefix (case-insensitive)') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') - .action(async (prefix, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai graph find' }); - const startedAt = Date.now(); - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const status = await checkIndex(repoRoot); - if (!status.ok) { - process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); - process.exit(2); - return; - } - const langSel = String(options.lang ?? 'auto'); - const langs = resolveLangs(status.found.meta ?? null, langSel as any); - const allRows: any[] = []; - for (const lang of langs) { - const result = await runAstGraphQuery(repoRoot, buildFindSymbolsQuery(lang), { prefix: String(prefix), lang }); - const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; - for (const r of rows) allRows.push(r); - } - const result = { headers: ['ref_id', 'file', 'lang', 'name', 'kind', 'signature', 'start_line', 'end_line'], rows: allRows }; - log.info('ast_graph_find', { ok: true, repoRoot, prefix: String(prefix), lang: langSel, langs, rows: allRows.length, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, lang: langSel, result }, null, 2)); - }) - ) - .addCommand( - new Command('children') - .description('List direct children in the AST containment graph') - .argument('', 'Parent id (ref_id or file_id)') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--as-file', 'Treat as a repository-relative file path and hash it to file_id', false) - .action(async (id, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai graph children' }); - const startedAt = Date.now(); - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const parentId = options.asFile ? sha256Hex(`file:${toPosixPath(String(id))}`) : String(id); - const result = await runAstGraphQuery(repoRoot, buildChildrenQuery(), { parent_id: parentId }); - log.info('ast_graph_children', { ok: true, repoRoot, parent_id: parentId, rows: Array.isArray((result as any)?.rows) ? (result as any).rows.length : 0, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, parent_id: parentId, result }, null, 2)); - }) - ) - .addCommand( - new Command('refs') - .description('Find reference locations by name (calls/new/type)') - .argument('', 'Symbol name') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--limit ', 'Limit results', '200') - .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') - .action(async (name, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai graph refs' }); - const startedAt = Date.now(); - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const status = await checkIndex(repoRoot); - if (!status.ok) { - process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); - process.exit(2); - return; - } - const limit = Number(options.limit ?? 200); - const langSel = String(options.lang ?? 'auto'); - const langs = resolveLangs(status.found.meta ?? null, langSel as any); - const allRows: any[] = []; - for (const lang of langs) { - const result = await runAstGraphQuery(repoRoot, buildFindReferencesQuery(lang), { name: String(name), lang }); - const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; - for (const r of rows) allRows.push(r); - } - const rows = allRows.slice(0, limit); - log.info('ast_graph_refs', { ok: true, repoRoot, name: String(name), lang: langSel, langs, rows: rows.length, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, name: String(name), lang: langSel, result: { headers: ['file', 'line', 'col', 'ref_kind', 'from_id', 'from_kind', 'from_name', 'from_lang'], rows } }, null, 2)); - }) - ) - .addCommand( - new Command('callers') - .description('Find callers by callee name') - .argument('', 'Callee name') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--limit ', 'Limit results', '200') - .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') - .action(async (name, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai graph callers' }); - const startedAt = Date.now(); - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const status = await checkIndex(repoRoot); - if (!status.ok) { - process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); - process.exit(2); - return; - } - const limit = Number(options.limit ?? 200); - const langSel = String(options.lang ?? 'auto'); - const langs = resolveLangs(status.found.meta ?? null, langSel as any); - const allRows: any[] = []; - for (const lang of langs) { - const result = await runAstGraphQuery(repoRoot, buildCallersByNameQuery(lang), { name: String(name), lang }); - const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; - for (const r of rows) allRows.push(r); - } - const rows = allRows.slice(0, limit); - log.info('ast_graph_callers', { ok: true, repoRoot, name: String(name), lang: langSel, langs, rows: rows.length, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, name: String(name), lang: langSel, result: { headers: ['caller_id', 'caller_kind', 'caller_name', 'file', 'line', 'col', 'caller_lang'], rows } }, null, 2)); - }) - ) - .addCommand( - new Command('callees') - .description('Find callees by caller name (resolved by exact callee name match in graph)') - .argument('', 'Caller name') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--limit ', 'Limit results', '200') - .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') - .action(async (name, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai graph callees' }); - const startedAt = Date.now(); - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const status = await checkIndex(repoRoot); - if (!status.ok) { - process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); - process.exit(2); - return; - } - const limit = Number(options.limit ?? 200); - const langSel = String(options.lang ?? 'auto'); - const langs = resolveLangs(status.found.meta ?? null, langSel as any); - const allRows: any[] = []; - for (const lang of langs) { - const result = await runAstGraphQuery(repoRoot, buildCalleesByNameQuery(lang), { name: String(name), lang }); - const rows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; - for (const r of rows) allRows.push(r); - } - const rows = allRows.slice(0, limit); - log.info('ast_graph_callees', { ok: true, repoRoot, name: String(name), lang: langSel, langs, rows: rows.length, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, name: String(name), lang: langSel, result: { headers: ['caller_id', 'caller_lang', 'callee_id', 'callee_file', 'callee_name', 'callee_kind', 'file', 'line', 'col'], rows } }, null, 2)); - }) - ) - .addCommand( - new Command('chain') - .description('Compute call chain by symbol name (heuristic, name-based)') - .argument('', 'Start symbol name') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--direction ', 'Direction: downstream|upstream', 'downstream') - .option('--depth ', 'Max depth', '3') - .option('--limit ', 'Limit results', '500') - .option('--min-name-len ', 'Filter out edges with very short names (default: 1)', '1') - .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') - .action(async (name, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai graph chain' }); - const startedAt = Date.now(); - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const status = await checkIndex(repoRoot); - if (!status.ok) { - process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); - process.exit(2); - return; - } - const direction = String(options.direction ?? 'downstream'); - const maxDepth = Number(options.depth ?? 3); - const limit = Number(options.limit ?? 500); - const minNameLen = Math.max(1, Number(options.minNameLen ?? 1)); - const langSel = String(options.lang ?? 'auto'); - const langs = resolveLangs(status.found.meta ?? null, langSel as any); - const query = direction === 'upstream' ? buildCallChainUpstreamByNameQuery() : buildCallChainDownstreamByNameQuery(); - const allRows: any[] = []; - for (const lang of langs) { - const result = await runAstGraphQuery(repoRoot, query, { name: String(name), max_depth: maxDepth, lang }); - const rawRows = Array.isArray((result as any)?.rows) ? (result as any).rows : []; - for (const r of rawRows) allRows.push(r); - } - const filtered = minNameLen > 1 - ? allRows.filter((r: any[]) => String(r?.[3] ?? '').length >= minNameLen && String(r?.[4] ?? '').length >= minNameLen) - : allRows; - const rows = filtered.slice(0, limit); - log.info('ast_graph_chain', { ok: true, repoRoot, name: String(name), lang: langSel, langs, direction, max_depth: maxDepth, rows: rows.length, min_name_len: minNameLen, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, name: String(name), lang: langSel, direction, max_depth: maxDepth, min_name_len: minNameLen, result: { headers: ['caller_id', 'callee_id', 'depth', 'caller_name', 'callee_name', 'lang'], rows } }, null, 2)); - }) - ); diff --git a/src/commands/index.ts b/src/commands/index.ts deleted file mode 100644 index 84864a7..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import fs from 'fs-extra'; -import { inferScanRoot, resolveGitRoot } from '../core/git'; -import { IndexerV2 } from '../core/indexer'; -import { createLogger } from '../core/log'; -import { getStagedNameStatus, getWorktreeNameStatus } from '../core/gitDiff'; -import { IncrementalIndexerV2 } from '../core/indexerIncremental'; - -export const indexCommand = new Command('index') - .description('Build LanceDB+SQ8 index for the current repository (HEAD working tree)') - .option('-p, --path ', 'Path inside the repository', '.') - .option('-d, --dim ', 'Embedding dimension', '256') - .option('--overwrite', 'Overwrite existing tables', false) - .option('--incremental', 'Incremental indexing (only changed files)', false) - .option('--staged', 'Read changed file contents from Git index (staged)', false) - .action(async (options) => { - const log = createLogger({ component: 'cli', cmd: 'ai index' }); - const startedAt = Date.now(); - try { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const scanRoot = inferScanRoot(repoRoot); - const requestedDim = Number(options.dim); - const overwrite = Boolean(options.overwrite); - const incremental = Boolean((options as any).incremental ?? false); - const staged = Boolean((options as any).staged ?? false); - const metaPath = path.join(repoRoot, '.git-ai', 'meta.json'); - const meta = await fs.readJSON(metaPath).catch(() => null); - const dim = typeof meta?.dim === 'number' ? meta.dim : requestedDim; - const isTTY = Boolean(process.stderr.isTTY) && !process.env.CI; - let renderedInTTY = false; - let finishedInTTY = false; - const renderProgress = (p: { totalFiles: number; processedFiles: number; currentFile?: string }): void => { - const total = Math.max(0, p.totalFiles); - const done = Math.max(0, Math.min(total, p.processedFiles)); - if (!isTTY) { - if (done === 0) { - process.stderr.write(`Indexing ${total} files...\n`); - } else if (done === total || done % 200 === 0) { - process.stderr.write((`[${done}/${total}] ${p.currentFile ?? ''}`.trim()) + '\n'); - } - return; - } - const columns = Math.max(40, Number(process.stderr.columns ?? 100)); - const prefix = `${done}/${total}`; - const percent = total > 0 ? Math.floor((done / total) * 100) : 100; - const reserved = prefix.length + 1 + 6 + 1 + 1; - const barWidth = Math.max(10, Math.min(30, columns - reserved - 20)); - const filled = total > 0 ? Math.round((done / total) * barWidth) : barWidth; - const bar = `${'='.repeat(filled)}${'-'.repeat(Math.max(0, barWidth - filled))}`; - const fileSuffix = p.currentFile ? ` ${p.currentFile}` : ''; - const line = `[${bar}] ${String(percent).padStart(3)}% ${prefix}${fileSuffix}`; - const clipped = line.length >= columns ? line.slice(0, columns - 1) : line; - process.stderr.write(`\r${clipped.padEnd(columns - 1, ' ')}`); - renderedInTTY = true; - if (done === total && !finishedInTTY) { - process.stderr.write('\n'); - finishedInTTY = true; - } - }; - - if (incremental) { - const changes = staged ? await getStagedNameStatus(repoRoot) : await getWorktreeNameStatus(repoRoot); - const indexer = new IncrementalIndexerV2({ - repoRoot, - scanRoot, - dim, - source: staged ? 'staged' : 'worktree', - changes, - onProgress: renderProgress, - }); - await indexer.run(); - } else { - const indexer = new IndexerV2({ repoRoot, scanRoot, dim, overwrite, onProgress: renderProgress }); - await indexer.run(); - } - if (renderedInTTY && !finishedInTTY) process.stderr.write('\n'); - log.info('index_repo', { ok: true, repoRoot, scanRoot, dim, overwrite, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ ok: true, repoRoot, scanRoot, dim, overwrite, incremental, staged }, null, 2)); - } catch (e) { - log.error('index_repo', { ok: false, duration_ms: Date.now() - startedAt, err: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { message: String(e) } }); - process.exit(1); - } - }); diff --git a/src/commands/pack.ts b/src/commands/pack.ts deleted file mode 100644 index 1d2bbf3..0000000 --- a/src/commands/pack.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import { resolveGitRoot } from '../core/git'; -import { packLanceDb } from '../core/archive'; -import { ensureLfsTracking } from '../core/lfs'; -import { createLogger } from '../core/log'; - -export const packCommand = new Command('pack') - .description('Pack .git-ai/lancedb into .git-ai/lancedb.tar.gz') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--lfs', 'Run git lfs track for .git-ai/lancedb.tar.gz', false) - .action(async (options) => { - const log = createLogger({ component: 'cli', cmd: 'ai pack' }); - const startedAt = Date.now(); - try { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const packed = await packLanceDb(repoRoot); - const lfs = options.lfs ? ensureLfsTracking(repoRoot, '.git-ai/lancedb.tar.gz') : { tracked: false }; - log.info('pack_index', { ok: true, repoRoot, packed: packed.packed, lfs: Boolean(lfs.tracked), duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, ...packed, lfs }, null, 2)); - } catch (e) { - log.error('pack_index', { ok: false, duration_ms: Date.now() - startedAt, err: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { message: String(e) } }); - process.exit(1); - } - }); diff --git a/src/commands/query.ts b/src/commands/query.ts deleted file mode 100644 index f311f0f..0000000 --- a/src/commands/query.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import fs from 'fs-extra'; -import { inferWorkspaceRoot, resolveGitRoot } from '../core/git'; -import { IndexLang, defaultDbDir, openTablesByLang } from '../core/lancedb'; -import { queryManifestWorkspace } from '../core/workspace'; -import { buildCoarseWhere, filterAndRankSymbolRows, inferSymbolSearchMode, pickCoarseToken, SymbolSearchMode } from '../core/symbolSearch'; -import { createLogger } from '../core/log'; -import { checkIndex, resolveLangs } from '../core/indexCheck'; -import { generateRepoMap, type FileRank } from '../core/repoMap'; - -export const queryCommand = new Command('query') - .description('Query refs table by symbol match (substring/prefix/wildcard/regex/fuzzy)') - .argument('', 'Symbol substring') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--limit ', 'Limit results', '50') - .option('--mode ', 'Mode: substring|prefix|wildcard|regex|fuzzy (default: auto)') - .option('--case-insensitive', 'Case-insensitive matching', false) - .option('--max-candidates ', 'Max candidates to fetch before filtering', '1000') - .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') - .option('--with-repo-map', 'Attach a lightweight repo map (ranked files + top symbols + wiki links)', false) - .option('--repo-map-files ', 'Max repo map files', '20') - .option('--repo-map-symbols ', 'Max repo map symbols per file', '5') - .option('--wiki ', 'Wiki directory (default: docs/wiki or wiki)', '') - .action(async (keyword, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai query' }); - const startedAt = Date.now(); - try { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const limit = Number(options.limit); - const q = String(keyword); - const mode = inferSymbolSearchMode(q, options.mode as SymbolSearchMode | undefined); - const caseInsensitive = Boolean(options.caseInsensitive ?? false); - const maxCandidates = Math.max(limit, Number(options.maxCandidates ?? Math.min(2000, limit * 20))); - const langSel = String(options.lang ?? 'auto'); - const withRepoMap = Boolean((options as any).withRepoMap ?? false); - - if (inferWorkspaceRoot(repoRoot)) { - const coarse = (mode === 'substring' || mode === 'prefix') ? q : pickCoarseToken(q); - const res = await queryManifestWorkspace({ manifestRepoRoot: repoRoot, keyword: coarse, limit: maxCandidates }); - const filteredByLang = filterWorkspaceRowsByLang(res.rows, langSel); - const rows = filterAndRankSymbolRows(filteredByLang, { query: q, mode, caseInsensitive, limit }); - log.info('query_symbols', { ok: true, repoRoot, workspace: true, mode, case_insensitive: caseInsensitive, limit, max_candidates: maxCandidates, candidates: res.rows.length, rows: rows.length, duration_ms: Date.now() - startedAt }); - const repoMap = withRepoMap ? { enabled: false, skippedReason: 'workspace_mode_not_supported' } : undefined; - console.log(JSON.stringify({ ...res, rows, ...(repoMap ? { repo_map: repoMap } : {}) }, null, 2)); - return; - } - - const status = await checkIndex(repoRoot); - if (!status.ok) { - process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); - process.exit(2); - return; - } - - const langs = resolveLangs(status.found.meta ?? null, langSel as any); - if (langs.length === 0) { - process.stderr.write(JSON.stringify({ ok: false, reason: 'lang_not_available', lang: langSel, available: status.found.meta?.languages ?? [] }, null, 2) + '\n'); - process.exit(2); - return; - } - - const dbDir = defaultDbDir(repoRoot); - const dim = typeof status.found.meta?.dim === 'number' ? status.found.meta.dim : 256; - const { byLang } = await openTablesByLang({ dbDir, dim, mode: 'open_only', languages: langs }); - const where = buildCoarseWhere({ query: q, mode, caseInsensitive }); - const candidates: any[] = []; - for (const lang of langs) { - const t = byLang[lang]; - if (!t) continue; - const rows = where - ? await t.refs.query().where(where).limit(maxCandidates).toArray() - : await t.refs.query().limit(maxCandidates).toArray(); - for (const r of rows as any[]) candidates.push({ ...r, lang }); - } - const rows = filterAndRankSymbolRows(candidates as any[], { query: q, mode, caseInsensitive, limit }); - log.info('query_symbols', { ok: true, repoRoot, workspace: false, lang: langSel, langs, mode, case_insensitive: caseInsensitive, limit, max_candidates: maxCandidates, candidates: candidates.length, rows: rows.length, duration_ms: Date.now() - startedAt }); - const repoMap = withRepoMap ? await buildRepoMapAttachment(repoRoot, options) : undefined; - console.log(JSON.stringify({ repoRoot, count: rows.length, lang: langSel, rows, ...(repoMap ? { repo_map: repoMap } : {}) }, null, 2)); - } catch (e) { - log.error('query_symbols', { ok: false, duration_ms: Date.now() - startedAt, err: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { message: String(e) } }); - process.exit(1); - } - }); - -async function buildRepoMapAttachment(repoRoot: string, options: any): Promise<{ enabled: boolean; wikiDir: string; files: FileRank[] } | { enabled: boolean; skippedReason: string }> { - try { - const wikiDir = resolveWikiDir(repoRoot, String(options.wiki ?? '')); - const files = await generateRepoMap({ - repoRoot, - maxFiles: Number(options.repoMapFiles ?? 20), - maxSymbolsPerFile: Number(options.repoMapSymbols ?? 5), - wikiDir, - }); - return { enabled: true, wikiDir, files }; - } catch (e: any) { - return { enabled: false, skippedReason: String(e?.message ?? e) }; - } -} - -function resolveWikiDir(repoRoot: string, wikiOpt: string): string { - const w = String(wikiOpt ?? '').trim(); - if (w) return path.resolve(repoRoot, w); - const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - return ''; -} - -function inferLangFromFile(file: string): IndexLang { - const f = String(file); - if (f.endsWith('.md') || f.endsWith('.mdx')) return 'markdown'; - if (f.endsWith('.yml') || f.endsWith('.yaml')) return 'yaml'; - if (f.endsWith('.java')) return 'java'; - if (f.endsWith('.c') || f.endsWith('.h')) return 'c'; - if (f.endsWith('.go')) return 'go'; - if (f.endsWith('.py')) return 'python'; - if (f.endsWith('.rs')) return 'rust'; - return 'ts'; -} - -function filterWorkspaceRowsByLang(rows: any[], langSel: string): any[] { - const sel = String(langSel ?? 'auto'); - if (sel === 'auto' || sel === 'all') return rows; - const target = sel as IndexLang; - return rows.filter(r => inferLangFromFile(String((r as any).file ?? '')) === target); -} diff --git a/src/commands/semantic.ts b/src/commands/semantic.ts deleted file mode 100644 index 2b8bff8..0000000 --- a/src/commands/semantic.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import fs from 'fs-extra'; -import { resolveGitRoot } from '../core/git'; -import { defaultDbDir, openTablesByLang } from '../core/lancedb'; -import { buildQueryVector, scoreAgainst } from '../core/search'; -import { createLogger } from '../core/log'; -import { checkIndex, resolveLangs } from '../core/indexCheck'; -import { generateRepoMap, type FileRank } from '../core/repoMap'; - -export const semanticCommand = new Command('semantic') - .description('Semantic search using SQ8 vectors (brute-force over chunks)') - .argument('', 'Query text') - .option('-p, --path ', 'Path inside the repository', '.') - .option('-k, --topk ', 'Top K results', '10') - .option('--lang ', 'Language: auto|all|java|ts|python|go|rust|c|markdown|yaml', 'auto') - .option('--with-repo-map', 'Attach a lightweight repo map (ranked files + top symbols + wiki links)', false) - .option('--repo-map-files ', 'Max repo map files', '20') - .option('--repo-map-symbols ', 'Max repo map symbols per file', '5') - .option('--wiki ', 'Wiki directory (default: docs/wiki or wiki)', '') - .action(async (text, options) => { - const log = createLogger({ component: 'cli', cmd: 'ai semantic' }); - const startedAt = Date.now(); - try { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const withRepoMap = Boolean((options as any).withRepoMap ?? false); - const status = await checkIndex(repoRoot); - if (!status.ok) { - process.stderr.write(JSON.stringify({ ...status, ok: false, reason: 'index_incompatible' }, null, 2) + '\n'); - process.exit(2); - return; - } - - const dbDir = defaultDbDir(repoRoot); - const k = Number(options.topk); - const dim = typeof status.found.meta?.dim === 'number' ? status.found.meta.dim : 256; - const q = buildQueryVector(String(text), dim); - const langSel = String(options.lang ?? 'auto'); - const langs = resolveLangs(status.found.meta ?? null, langSel as any); - if (langs.length === 0) { - process.stderr.write(JSON.stringify({ ok: false, reason: 'lang_not_available', lang: langSel, available: status.found.meta?.languages ?? [] }, null, 2) + '\n'); - process.exit(2); - return; - } - - const { byLang } = await openTablesByLang({ dbDir, dim, mode: 'open_only', languages: langs }); - - const allScored: any[] = []; - let totalChunks = 0; - for (const lang of langs) { - const t = byLang[lang]; - if (!t) continue; - const chunkRows = await t.chunks.query().select(['content_hash', 'text', 'dim', 'scale', 'qvec_b64']).limit(1_000_000).toArray(); - totalChunks += (chunkRows as any[]).length; - for (const r of chunkRows as any[]) { - allScored.push({ - lang, - content_hash: String(r.content_hash), - score: scoreAgainst(q, { dim: Number(r.dim), scale: Number(r.scale), qvec: new Int8Array(Buffer.from(String(r.qvec_b64), 'base64')) }), - text: String(r.text), - }); - } - } - const scored = allScored.sort((a, b) => b.score - a.score).slice(0, k); - - const neededByLang: Partial>> = {}; - for (const s of scored) { - if (!neededByLang[s.lang]) neededByLang[s.lang] = new Set(); - neededByLang[s.lang]!.add(String(s.content_hash)); - } - - const refsByLangAndId = new Map>(); - for (const lang of langs) { - const t = byLang[lang]; - if (!t) continue; - const needed = neededByLang[lang]; - if (!needed || needed.size === 0) continue; - const refsRows = await t.refs.query().select(['content_hash', 'file', 'symbol', 'kind', 'signature', 'start_line', 'end_line']) - .limit(1_000_000) - .toArray(); - const byId = new Map(); - for (const r of refsRows as any[]) { - const id = String(r.content_hash); - if (!needed.has(id)) continue; - if (!byId.has(id)) byId.set(id, []); - byId.get(id)!.push(r); - } - refsByLangAndId.set(lang, byId); - } - - const hits = scored.map(s => ({ - ...s, - refs: (refsByLangAndId.get(s.lang)?.get(s.content_hash) || []).slice(0, 5), - })); - - log.info('semantic_search', { ok: true, repoRoot, topk: k, lang: langSel, langs, chunks: totalChunks, hits: hits.length, duration_ms: Date.now() - startedAt }); - const repoMap = withRepoMap ? await buildRepoMapAttachment(repoRoot, options) : undefined; - console.log(JSON.stringify({ repoRoot, topk: k, lang: langSel, hits, ...(repoMap ? { repo_map: repoMap } : {}) }, null, 2)); - } catch (e) { - log.error('semantic_search', { ok: false, duration_ms: Date.now() - startedAt, err: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { message: String(e) } }); - process.exit(1); - } - }); - -async function buildRepoMapAttachment(repoRoot: string, options: any): Promise<{ enabled: boolean; wikiDir: string; files: FileRank[] } | { enabled: boolean; skippedReason: string }> { - try { - const wikiDir = resolveWikiDir(repoRoot, String(options.wiki ?? '')); - const files = await generateRepoMap({ - repoRoot, - maxFiles: Number(options.repoMapFiles ?? 20), - maxSymbolsPerFile: Number(options.repoMapSymbols ?? 5), - wikiDir, - }); - return { enabled: true, wikiDir, files }; - } catch (e: any) { - return { enabled: false, skippedReason: String(e?.message ?? e) }; - } -} - -function resolveWikiDir(repoRoot: string, wikiOpt: string): string { - const w = String(wikiOpt ?? '').trim(); - if (w) return path.resolve(repoRoot, w); - const candidates = [path.join(repoRoot, 'docs', 'wiki'), path.join(repoRoot, 'wiki')]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - return ''; -} diff --git a/src/commands/serve.ts b/src/commands/serve.ts deleted file mode 100644 index ce436a2..0000000 --- a/src/commands/serve.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Command } from 'commander'; -import { GitAIV2MCPServer } from '../mcp/server'; - -export const serveCommand = new Command('serve') - .description('Start MCP server (stdio). Repository is specified by path in each tool call.') - .option('--disable-mcp-log', 'Disable MCP access logging') - .action(async (options) => { - const server = new GitAIV2MCPServer(process.cwd(), { - disableAccessLog: !!options.disableMcpLog, - }); - await server.start(); - }); diff --git a/src/commands/trae.ts b/src/commands/trae.ts deleted file mode 100644 index 39b714b..0000000 --- a/src/commands/trae.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import fs from 'fs-extra'; -import { resolveGitRoot } from '../core/git'; - -async function findPackageRoot(startDir: string): Promise { - let cur = path.resolve(startDir); - for (let i = 0; i < 12; i++) { - const pj = path.join(cur, 'package.json'); - if (await fs.pathExists(pj)) return cur; - const parent = path.dirname(cur); - if (parent === cur) break; - cur = parent; - } - return path.resolve(startDir); -} - -async function listDirNames(p: string): Promise { - if (!await fs.pathExists(p)) return []; - const entries = await fs.readdir(p); - const out: string[] = []; - for (const n of entries) { - const full = path.join(p, n); - try { - const st = await fs.stat(full); - if (st.isDirectory()) out.push(n); - } catch { - } - } - return out.sort(); -} - -export const agentCommand = new Command('agent') - .description('Install Agent skills/rules templates into a target directory') - .alias('trae') - .addCommand( - new Command('install') - .description('Install skills/rules templates (default: /.agents)') - .option('-p, --path ', 'Path inside the repository', '.') - .option('--to ', 'Destination directory (overrides default)', '') - .option('--agent ', 'Template layout: agents|trae', 'agents') - .option('--overwrite', 'Overwrite existing files', false) - .action(async (options) => { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const agent = String((options as any).agent ?? 'agents').trim().toLowerCase(); - const defaultDirName = agent === 'trae' ? '.trae' : '.agents'; - const destDir = String(options.to ?? '').trim() ? path.resolve(String(options.to)) : path.join(repoRoot, defaultDirName); - const overwrite = Boolean(options.overwrite ?? false); - - const packageRoot = await findPackageRoot(__dirname); - const srcTemplateDir = path.join(packageRoot, 'templates', 'agents', 'common'); - const srcSkillsDir = path.join(srcTemplateDir, 'skills'); - const srcRulesDir = path.join(srcTemplateDir, 'rules'); - if (!await fs.pathExists(srcSkillsDir) || !await fs.pathExists(srcRulesDir)) { - console.log(JSON.stringify({ ok: false, repoRoot, error: 'template_missing', srcTemplateDir }, null, 2)); - process.exitCode = 2; - return; - } - - const dstSkillsDir = path.join(destDir, 'skills'); - const dstRulesDir = path.join(destDir, 'rules'); - await fs.ensureDir(destDir); - await fs.copy(srcSkillsDir, dstSkillsDir, { overwrite }); - await fs.copy(srcRulesDir, dstRulesDir, { overwrite }); - - const installed = { - skills: await listDirNames(dstSkillsDir), - rules: await listDirNames(dstRulesDir), - }; - console.log(JSON.stringify({ ok: true, repoRoot, agent, destDir, overwrite, installed }, null, 2)); - }) - ); diff --git a/src/commands/unpack.ts b/src/commands/unpack.ts deleted file mode 100644 index b2ba63a..0000000 --- a/src/commands/unpack.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Command } from 'commander'; -import path from 'path'; -import { resolveGitRoot } from '../core/git'; -import { unpackLanceDb } from '../core/archive'; -import { createLogger } from '../core/log'; - -export const unpackCommand = new Command('unpack') - .description('Unpack .git-ai/lancedb.tar.gz into .git-ai/lancedb') - .option('-p, --path ', 'Path inside the repository', '.') - .action(async (options) => { - const log = createLogger({ component: 'cli', cmd: 'ai unpack' }); - const startedAt = Date.now(); - try { - const repoRoot = await resolveGitRoot(path.resolve(options.path)); - const unpacked = await unpackLanceDb(repoRoot); - log.info('unpack_index', { ok: true, repoRoot, unpacked: unpacked.unpacked, duration_ms: Date.now() - startedAt }); - console.log(JSON.stringify({ repoRoot, ...unpacked }, null, 2)); - } catch (e) { - log.error('unpack_index', { ok: false, duration_ms: Date.now() - startedAt, err: e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { message: String(e) } }); - process.exit(1); - } - }); diff --git a/src/core/cozo.ts b/src/core/cozo.ts index dd6a826..08b2c8d 100644 --- a/src/core/cozo.ts +++ b/src/core/cozo.ts @@ -2,7 +2,7 @@ import fs from 'fs-extra'; import path from 'path'; export interface CozoClient { - backend: 'cozo-node' | 'cozo-wasm'; + backend: 'cozo-node'; run: (script: string, params?: Record) => Promise; exportRelations?: (relations: string[]) => Promise; importRelations?: (data: any) => Promise; @@ -19,8 +19,6 @@ export function repoAstGraphExportPath(repoRoot: string): string { return path.join(repoRoot, '.git-ai', 'ast-graph.export.json'); } -let cozoWasmInit: Promise | null = null; - async function tryImportFromExport(repoRoot: string, client: CozoClient): Promise { if (!client.importRelations) return; if (client.engine === 'sqlite') return; @@ -47,7 +45,15 @@ async function openCozoNode(repoRoot: string): Promise { const moduleName: string = 'cozo-node'; mod = await import(moduleName); } catch (e: any) { - throw new Error(`Failed to load cozo-node: ${String(e?.message ?? e)}`); + const msg = String(e?.message ?? e); + // Provide helpful error message for common installation issues + const hint = msg.includes('Cannot find') || msg.includes('not found') + ? '\n\nTroubleshooting:\n' + + '1. For China users: npm install --cozo_node_prebuilt_binary_host_mirror=https://gitee.com/cozodb/cozo-lib-nodejs/releases/download/\n' + + '2. Check network/proxy settings\n' + + '3. Manual download: https://github.com/cozodb/cozo-lib-nodejs/releases' + : ''; + throw new Error(`Failed to load cozo-node: ${msg}${hint}`); } const CozoDb = mod?.CozoDb ?? mod?.default?.CozoDb ?? mod?.default ?? mod; @@ -84,68 +90,6 @@ async function openCozoNode(repoRoot: string): Promise { return client; } -async function openCozoWasm(repoRoot: string): Promise { - let mod: any; - try { - const moduleName: string = 'cozo-lib-wasm'; - mod = await import(moduleName); - } catch (e: any) { - throw new Error(`Failed to load cozo-lib-wasm: ${String(e?.message ?? e)}`); - } - - const init = mod?.default; - const CozoDb = mod?.CozoDb; - if (typeof init !== 'function' || typeof CozoDb?.new !== 'function') { - throw new Error('cozo-lib-wasm loaded but exports are not compatible'); - } - - if (!cozoWasmInit) cozoWasmInit = Promise.resolve(init()).then(() => {}); - await cozoWasmInit; - - const db: any = CozoDb.new(); - - const run = async (script: string, params?: Record) => { - const out = db.run(String(script), JSON.stringify(params ?? {})); - try { - return JSON.parse(String(out)); - } catch { - return out; - } - }; - - const exportRelations = async (relations: string[]) => { - if (typeof db.export_relations !== 'function') return null; - const out = db.export_relations(JSON.stringify(relations)); - try { - return JSON.parse(String(out)); - } catch { - return out; - } - }; - - const importRelations = async (data: any) => { - if (typeof db.import_relations !== 'function') return null; - const out = db.import_relations(JSON.stringify(data)); - try { - return JSON.parse(String(out)); - } catch { - return out; - } - }; - - const client: CozoClient = { - backend: 'cozo-wasm', - engine: 'mem', - run, - exportRelations, - importRelations, - close: typeof db.free === 'function' ? async () => { db.free(); } : undefined, - }; - - await tryImportFromExport(repoRoot, client); - return client; -} - export async function openCozoDbAtPath(dbPath: string, exportPath?: string): Promise { const errors: string[] = []; try { @@ -185,65 +129,15 @@ export async function openCozoDbAtPath(dbPath: string, exportPath?: string): Pro errors.push(String(e?.message ?? e)); } - try { - const moduleName: string = 'cozo-lib-wasm'; - const mod = await import(moduleName); - const init = mod?.default; - const CozoDb = mod?.CozoDb; - if (typeof init !== 'function' || typeof CozoDb?.new !== 'function') { - throw new Error('cozo-lib-wasm loaded but exports are not compatible'); - } - - if (!cozoWasmInit) cozoWasmInit = Promise.resolve(init()).then(() => {}); - await cozoWasmInit; - - const db: any = CozoDb.new(); - const run = async (script: string, params?: Record) => { - const out = db.run(String(script), JSON.stringify(params ?? {})); - try { - return JSON.parse(String(out)); - } catch { - return out; - } - }; - - const exportRelations = async (relations: string[]) => { - if (typeof db.export_relations !== 'function') return null; - const out = db.export_relations(JSON.stringify(relations)); - try { - return JSON.parse(String(out)); - } catch { - return out; - } - }; - - const importRelations = async (data: any) => { - if (typeof db.import_relations !== 'function') return null; - const out = db.import_relations(JSON.stringify(data)); - try { - return JSON.parse(String(out)); - } catch { - return out; - } - }; - - const client: CozoClient = { - backend: 'cozo-wasm', - engine: 'mem', - run, - exportRelations, - importRelations, - close: typeof db.free === 'function' ? async () => { db.free(); } : undefined, - }; - - await tryImportFromExportPath(exportPath, client); - return client; - } catch (e: any) { - errors.push(String(e?.message ?? e)); - } - await fs.ensureDir(path.dirname(dbPath)); - await fs.writeJSON(path.join(path.dirname(dbPath), 'cozo.error.json'), { errors }, { spaces: 2 }).catch(() => {}); + await fs.writeJSON(path.join(path.dirname(dbPath), 'cozo.error.json'), { + errors, + troubleshooting: { + gitee_mirror: 'npm install --cozo_node_prebuilt_binary_host_mirror=https://gitee.com/cozodb/cozo-lib-nodejs/releases/download/', + manual_download: 'https://github.com/cozodb/cozo-lib-nodejs/releases', + docs: 'https://github.com/mars167/git-ai-cli#troubleshooting' + } + }, { spaces: 2 }).catch(() => {}); return null; } @@ -254,12 +148,14 @@ export async function openRepoCozoDb(repoRoot: string): Promise {}); + await fs.writeJSON(path.join(repoRoot, '.git-ai', 'cozo.error.json'), { + errors, + troubleshooting: { + gitee_mirror: 'npm install --cozo_node_prebuilt_binary_host_mirror=https://gitee.com/cozodb/cozo-lib-nodejs/releases/download/', + manual_download: 'https://github.com/cozodb/cozo-lib-nodejs/releases', + docs: 'https://github.com/mars167/git-ai-cli#troubleshooting' + } + }, { spaces: 2 }).catch(() => {}); return null; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7d09779..18c83d6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,6 +1,9 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { randomUUID } from 'node:crypto'; import fs from 'fs-extra'; import path from 'path'; import os from 'os'; @@ -13,6 +16,9 @@ import * as schemas from './schemas'; export interface GitAIV2MCPServerOptions { disableAccessLog?: boolean; + transport?: 'stdio' | 'http'; + port?: number; + stateless?: boolean; } export class GitAIV2MCPServer { @@ -66,7 +72,7 @@ export class GitAIV2MCPServer { repo: repoRoot ? path.basename(repoRoot) : 'unknown', duration_ms: duration, ok, - args: JSON.stringify(args).slice(0, 1000), // Avoid overly large logs + args: JSON.stringify(args).slice(0, 1000), }; await fs.appendFile(logFile, JSON.stringify(entry) + '\n', 'utf-8'); } catch (e) { @@ -75,7 +81,6 @@ export class GitAIV2MCPServer { } private setupHandlers() { - // Register all tools with their schemas const schemaMap: Record = { get_repo: schemas.GetRepoArgsSchema, check_index: schemas.CheckIndexArgsSchema, @@ -127,8 +132,118 @@ export class GitAIV2MCPServer { } async start() { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - createLogger({ component: 'mcp' }).info('server_started', { startDir: this.startDir, transport: 'stdio' }); + const log = createLogger({ component: 'mcp' }); + const transport = this.options.transport ?? 'stdio'; + + if (transport === 'http') { + await this.startHttp(); + } else { + const stdioTransport = new StdioServerTransport(); + await this.server.connect(stdioTransport); + log.info('server_started', { startDir: this.startDir, transport: 'stdio' }); + } + } + + private async startHttp() { + const log = createLogger({ component: 'mcp' }); + const port = this.options.port ?? 3000; + + const sessions = new Map(); + + const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + + if (url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', sessions: sessions.size })); + return; + } + + if (url.pathname !== '/mcp') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found. Use /mcp for MCP endpoint or /health for health check.' })); + return; + } + + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && sessions.has(sessionId)) { + transport = sessions.get(sessionId)!; + } else if (this.options.stateless) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + const serverInstance = new Server( + { name: 'git-ai-v2', version: '2.0.0' }, + { capabilities: { tools: {} } } + ); + this.setupServerHandlers(serverInstance); + await serverInstance.connect(transport); + } else { + const newSessionId = randomUUID(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => newSessionId, + }); + + const serverInstance = new Server( + { name: 'git-ai-v2', version: '2.0.0' }, + { capabilities: { tools: {} } } + ); + this.setupServerHandlers(serverInstance); + await serverInstance.connect(transport); + + sessions.set(newSessionId, transport); + log.info('session_created', { sessionId: newSessionId, totalSessions: sessions.size }); + + transport.onclose = () => { + sessions.delete(newSessionId); + log.info('session_closed', { sessionId: newSessionId, totalSessions: sessions.size }); + }; + } + + await transport.handleRequest(req, res); + }); + + httpServer.listen(port, () => { + log.info('server_started', { + startDir: this.startDir, + transport: 'http', + port, + endpoint: `http://localhost:${port}/mcp`, + health: `http://localhost:${port}/health`, + stateless: !!this.options.stateless, + }); + console.error(JSON.stringify({ + ts: new Date().toISOString(), + level: 'info', + msg: 'MCP HTTP server started', + port, + endpoint: `http://localhost:${port}/mcp`, + health: `http://localhost:${port}/health`, + })); + }); + } + + private setupServerHandlers(serverInstance: Server) { + serverInstance.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: this.registry.listTools() }; + }); + + serverInstance.setRequestHandler(CallToolRequestSchema, async (request) => { + const name = request.params.name; + const args = request.params.arguments ?? {}; + const callPath = typeof (args as any).path === 'string' ? String((args as any).path) : undefined; + const log = createLogger({ component: 'mcp', tool: name }); + const startedAt = Date.now(); + const context: ToolContext = { startDir: this.startDir, options: this.options }; + + const response = await this.registry.execute(name, args, context); + + log.info('tool_call', { ok: !response.isError, duration_ms: Date.now() - startedAt, path: callPath }); + const repoRootForLog = await this.resolveRepoRoot(callPath).catch(() => undefined); + await this.writeAccessLog(name, args, Date.now() - startedAt, !response.isError, repoRootForLog); + return response; + }); } } diff --git a/templates/agents/common/skills/git-ai-code-search/SKILL.md b/templates/agents/common/skills/git-ai-code-search/SKILL.md new file mode 100644 index 0000000..52127c7 --- /dev/null +++ b/templates/agents/common/skills/git-ai-code-search/SKILL.md @@ -0,0 +1,49 @@ +--- +name: git-ai-code-search +description: | + Semantic code search and codebase understanding using git-ai MCP tools. Use when: (1) Searching for symbols, functions, or semantic concepts, (2) Understanding project architecture, (3) Analyzing call graphs and code relationships, (4) Tracking symbol history via DSR. Triggers: "find X", "search for X", "who calls X", "where is X", "history of X", "understand this codebase". +--- + +# git-ai Code Search + +Semantic code search with AST analysis and change tracking. + +## Quick Start + +**For Agents** - 3-step pattern: +``` +1. check_index({ path }) → verify index exists +2. semantic_search({ path, query }) → find relevant code +3. read_file({ path, file }) → read the actual code +``` + +**For Users** - build index first: +```bash +cd your-repo +git-ai ai index # build index +git-ai ai semantic "authentication logic" # search +``` + +## Core Tools + +| Need | Tool | Example | +|------|------|---------| +| Search by meaning | `semantic_search` | `{ path, query: "error handling", topk: 10 }` | +| Search by name | `search_symbols` | `{ path, query: "handleAuth", mode: "substring" }` | +| Who calls X | `ast_graph_callers` | `{ path, name: "processOrder" }` | +| What X calls | `ast_graph_callees` | `{ path, name: "processOrder" }` | +| Call chain | `ast_graph_chain` | `{ path, name: "main", direction: "downstream" }` | +| Symbol history | `dsr_symbol_evolution` | `{ path, symbol: "UserService" }` | +| Project overview | `repo_map` | `{ path, max_files: 20 }` | + +## Rules + +1. **Always pass `path`** - Every tool requires explicit repository path +2. **Check index first** - Run `check_index` before search tools +3. **Read before modify** - Use `read_file` to understand code before changes +4. **Use DSR for history** - Never parse git log manually + +## References + +- [Tool Documentation](references/tools.md) +- [Behavioral Constraints](references/constraints.md) diff --git a/templates/agents/common/skills/git-ai-mcp/references/constraints.md b/templates/agents/common/skills/git-ai-code-search/references/constraints.md similarity index 100% rename from templates/agents/common/skills/git-ai-mcp/references/constraints.md rename to templates/agents/common/skills/git-ai-code-search/references/constraints.md diff --git a/templates/agents/common/skills/git-ai-mcp/references/tools.md b/templates/agents/common/skills/git-ai-code-search/references/tools.md similarity index 100% rename from templates/agents/common/skills/git-ai-mcp/references/tools.md rename to templates/agents/common/skills/git-ai-code-search/references/tools.md diff --git a/templates/agents/common/skills/git-ai-mcp/SKILL.md b/templates/agents/common/skills/git-ai-mcp/SKILL.md deleted file mode 100644 index 96efa26..0000000 --- a/templates/agents/common/skills/git-ai-mcp/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: git-ai-mcp -description: | - Efficient codebase understanding and navigation using git-ai MCP tools. Use when working with code repositories that have git-ai indexed, including: (1) Understanding project structure and architecture, (2) Searching for symbols, functions, or semantic concepts, (3) Analyzing code relationships and call graphs, (4) Tracking symbol evolution and change history via DSR, (5) Reading and navigating code files. Triggers: "understand this project", "find function X", "who calls X", "what does X call", "history of X", "where is X implemented". ---- - -# git-ai MCP Skill - -Guide for using git-ai MCP tools to understand and navigate codebases efficiently. - -## Overview - -git-ai provides semantic code understanding through: - -- **Hyper RAG**: Vector + Graph + DSR retrieval -- **AST Analysis**: Symbol relationships and call graphs -- **DSR**: Deterministic Semantic Records for change tracking - -## Workflow - -Understanding a codebase involves these steps: - -1. Get global view (run `repo_map`) -2. Check index status (run `check_index`, rebuild if needed) -3. Locate code (run `search_symbols` or `semantic_search`) -4. Analyze relationships (run `ast_graph_callers/callees/chain`) -5. Trace history (run `dsr_symbol_evolution`) -6. Read code (run `read_file`) - -## Tool Selection - -| Task | Tool | Key Parameters | -|------|------|----------------| -| Project overview | `repo_map` | `path`, `max_files: 20` | -| Find by name | `search_symbols` | `path`, `query`, `mode: substring` | -| Find by meaning | `semantic_search` | `path`, `query`, `topk: 10` | -| Who calls X | `ast_graph_callers` | `path`, `name` | -| What X calls | `ast_graph_callees` | `path`, `name` | -| Call chain | `ast_graph_chain` | `path`, `name`, `direction`, `max_depth` | -| Symbol history | `dsr_symbol_evolution` | `path`, `symbol`, `limit` | -| Read code | `read_file` | `path`, `file`, `start_line`, `end_line` | -| Index health | `check_index` | `path` | -| Rebuild index | `rebuild_index` | `path` | - -## Critical Rules - -**MUST follow:** - -1. **Always pass `path` explicitly** - Never rely on implicit working directory -2. **Check index before search** - Run `check_index` before using search/graph tools -3. **Read before modify** - Use `read_file` to understand code before making changes -4. **Use DSR for history** - Never manually parse git log; use `dsr_symbol_evolution` - -**NEVER do:** - -- Assume symbol locations without searching -- Modify files without reading them first -- Search when index is missing or incompatible -- Ignore DSR risk levels (high risk = extra review needed) - -## Examples - -**Find authentication code:** -```js -semantic_search({ path: "/repo", query: "user authentication logic", topk: 10 }) -``` - -**Find who calls a function:** -```js -ast_graph_callers({ path: "/repo", name: "handleRequest", limit: 50 }) -``` - -**Trace call chain upstream:** -```js -ast_graph_chain({ path: "/repo", name: "processOrder", direction: "upstream", max_depth: 3 }) -``` - -**View symbol history:** -```js -dsr_symbol_evolution({ path: "/repo", symbol: "authenticateUser", limit: 50 }) -``` - -## References - -- **Tool details**: See [references/tools.md](references/tools.md) for complete tool documentation -- **Constraints**: See [references/constraints.md](references/constraints.md) for behavioral rules