diff --git a/AGENTS.md b/AGENTS.md index 11ed9ecc7..e14a911af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ $`rm ${tmpFile}`.quiet().nothrow(); ``` **Use `Bun.spawn`/`Bun.spawnSync`** only when: -- Long-running processes (LSP servers, Python kernels, voice services) +- Long-running processes (LSP servers, Python kernels) - Streaming stdin/stdout/stderr required (SSE, JSON-RPC) - Process control needed (signals, kill, complex lifecycle) diff --git a/README.md b/README.md index cdaa71e21..5be1cabd5 100644 --- a/README.md +++ b/README.md @@ -283,25 +283,6 @@ Create images directly from the agent: - **Inline display**: Images render in terminals supporting Kitty/iTerm2 graphics - Saves to temp files and reports paths for further manipulation -## + Voice Mode (Experimental) - -Real-time voice interaction using OpenAI Realtime API: - -- **Continuous streaming**: Mic input with semantic VAD turn detection -- **Interruptible**: Speak over the assistant to redirect mid-response -- **Echo suppression**: Filters out speaker playback during listening -- **Progress updates**: Speaks partial results during long-running tasks -- Toggle with `Ctrl+Y`, auto-sends on silence - -## + Worktree Management (Experimental) - -Git worktree operations with `/wt` command: - -- **Create/list/remove**: Manage worktrees directly from the agent -- **Parallel operations**: Run commands across multiple worktrees simultaneously -- **Collapse strategies**: Merge changes between worktrees (simple, merge-base, rebase) -- **Session tracking**: Manages agent tasks across isolated worktrees - ## + TUI Overhaul Modern terminal interface with smart session management: @@ -330,7 +311,6 @@ Handles whitespace and indentation variance automatically: - **`xhigh` thinking level**: Extended reasoning for Anthropic models with increased token budgets - **Background mode**: `/background` detaches UI and continues agent execution - **Completion notifications**: Configurable bell/OSC99/OSC9 when agent finishes -- **Structured git tool**: Safety guards, caching, GitHub operations with typed output - **65+ built-in themes**: Catppuccin, Dracula, Nord, Gruvbox, Tokyo Night, and material variants - **Auto environment detection**: OS, distro, kernel, CPU, GPU, shell, terminal, DE in system prompt - **Git context**: System prompt includes branch, status, recent commits @@ -349,7 +329,6 @@ Handles whitespace and indentation variance automatically: | **[@oh-my-pi/pi-ai](packages/ai)** | Multi-provider LLM client (Anthropic, OpenAI, Gemini, Bedrock, Cursor, Codex, Copilot) | | **[@oh-my-pi/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management | | **[@oh-my-pi/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI | -| **[@oh-my-pi/pi-git-tool](packages/git-tool)** | Structured Git tool with safety guards and typed output | | **[@oh-my-pi/pi-tui](packages/tui)** | Terminal UI library with differential rendering | --- diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts deleted file mode 100644 index bcc497faa..000000000 --- a/packages/agent/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "node", - testTimeout: 30000, // 30 seconds for API calls - }, -}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8ed2afeae..7bb23d8ae 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Removed + +- Removed openai-codex provider exports from main package index +- Removed openai-codex prompt utilities and moved them inline +- Removed vitest configuration file ## [6.8.4] - 2026-01-21 ### Changed diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 078772d5e..c07928648 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -6,7 +6,6 @@ export * from "./providers/cursor"; export * from "./providers/google"; export * from "./providers/google-gemini-cli"; export * from "./providers/google-vertex"; -export * from "./providers/openai-codex/index"; export * from "./providers/openai-completions"; export * from "./providers/openai-responses"; export * from "./stream"; diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 7c79c472b..2c334ca7c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -36,8 +36,6 @@ import { OPENAI_HEADERS, URL_PATHS, } from "./openai-codex/constants"; -import { getCodexInstructions } from "./openai-codex/prompts/codex"; -import { buildCodexSystemPrompt } from "./openai-codex/prompts/system-prompt"; import { type CodexRequestOptions, type RequestBody, transformRequestBody } from "./openai-codex/request-transformer"; import { parseCodexError, parseCodexSseStream } from "./openai-codex/response-handler"; import { transformMessages } from "./transform-messages"; @@ -50,6 +48,27 @@ export interface OpenAICodexResponsesOptions extends StreamOptions { codexMode?: boolean; } +export const CODEX_INSTRUCTIONS = `You are an expert coding assistant operating inside pi, a coding agent harness.`; + +export interface CodexSystemPrompt { + instructions: string; + developerMessages: string[]; +} + +export function buildCodexSystemPrompt(args: { userSystemPrompt?: string }): CodexSystemPrompt { + const { userSystemPrompt } = args; + const developerMessages: string[] = []; + + if (userSystemPrompt && userSystemPrompt.trim().length > 0) { + developerMessages.push(userSystemPrompt.trim()); + } + + return { + instructions: CODEX_INSTRUCTIONS, + developerMessages, + }; +} + const CODEX_DEBUG = process.env.OMP_CODEX_DEBUG === "1" || process.env.OMP_CODEX_DEBUG === "true"; const CODEX_MAX_RETRIES = 2; const CODEX_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); @@ -135,9 +154,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" params.tools = convertTools(context.tools); } - const codexInstructions = getCodexInstructions(); const systemPrompt = buildCodexSystemPrompt({ - codexInstructions, userSystemPrompt: context.systemPrompt, }); diff --git a/packages/ai/src/providers/openai-codex/index.ts b/packages/ai/src/providers/openai-codex/index.ts deleted file mode 100644 index 83e688e7c..000000000 --- a/packages/ai/src/providers/openai-codex/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * OpenAI Codex utilities - exported for use by coding-agent export - */ - -export { getCodexInstructions } from "./prompts/codex"; -export { buildCodexSystemPrompt, type CodexSystemPrompt } from "./prompts/system-prompt"; diff --git a/packages/ai/src/providers/openai-codex/prompts/codex.ts b/packages/ai/src/providers/openai-codex/prompts/codex.ts deleted file mode 100644 index e339f322e..000000000 --- a/packages/ai/src/providers/openai-codex/prompts/codex.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const CODEX_INSTRUCTIONS = `You are an expert coding assistant operating inside pi, a coding agent harness.`; - -export function getCodexInstructions(): string { - return CODEX_INSTRUCTIONS.trim(); -} diff --git a/packages/ai/src/providers/openai-codex/prompts/system-prompt.ts b/packages/ai/src/providers/openai-codex/prompts/system-prompt.ts deleted file mode 100644 index f5fc14d71..000000000 --- a/packages/ai/src/providers/openai-codex/prompts/system-prompt.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface CodexSystemPrompt { - instructions: string; - developerMessages: string[]; -} - -export function buildCodexSystemPrompt(args: { - codexInstructions: string; - userSystemPrompt?: string; -}): CodexSystemPrompt { - const { codexInstructions, userSystemPrompt } = args; - const developerMessages: string[] = []; - - if (userSystemPrompt && userSystemPrompt.trim().length > 0) { - developerMessages.push(userSystemPrompt.trim()); - } - - return { - instructions: codexInstructions.trim(), - developerMessages, - }; -} diff --git a/packages/ai/vitest.config.ts b/packages/ai/vitest.config.ts deleted file mode 100644 index bcc497faa..000000000 --- a/packages/ai/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "node", - testTimeout: 30000, // 30 seconds for API calls - }, -}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1043575be..d43495121 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Removed + +- Removed Git tool and all related functionality +- Removed voice control and TTS features +- Removed worktree management system +- Removed bundled wt custom command +- Removed voice-related settings and configuration options +- Removed @oh-my-pi/pi-git-tool dependency ## [6.8.5] - 2026-01-21 ### Breaking Changes diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 772a3e56c..b47499fc3 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -31,7 +31,6 @@ The coding-agent is structured into distinct layers: │ @oh-my-pi/pi-agent-core (Agent core) │ │ @oh-my-pi/pi-ai (models, providers) │ │ @oh-my-pi/pi-tui (TUI components) │ -│ @oh-my-pi/pi-git-tool (Git tool) │ └─────────────────────────────────────────────────────────────┘ ``` @@ -113,9 +112,6 @@ src/ │ ├── title-generator.ts # Session title generation │ ├── ttsr.ts # Text-to-speech/speech-to-text utilities │ ├── utils.ts # Generic utilities -│ ├── voice.ts # Voice input handling -│ ├── voice-controller.ts # Voice control logic -│ ├── voice-supervisor.ts # Voice supervision logic │ │ │ ├── compaction/ # Context compaction system │ │ └── index.ts # Compaction logic, summary generation @@ -511,7 +507,6 @@ This is part of a monorepo with the following packages: - `@oh-my-pi/pi-coding-agent` (this package) - Main CLI and TUI - `@oh-my-pi/pi-agent-core` - Core agent implementation - `@oh-my-pi/pi-tui` - TUI components -- `@oh-my-pi/pi-git-tool` - Git tool integration - `@oh-my-pi/pi-ai` - External AI provider library ## CLI Flags diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 5620c786a..31852f970 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -42,7 +42,6 @@ "dependencies": { "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-ai": "workspace:*", - "@oh-my-pi/pi-git-tool": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", "@openai/agents": "^0.3.7", diff --git a/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts b/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts deleted file mode 100644 index 6622f63a2..000000000 --- a/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts +++ /dev/null @@ -1,435 +0,0 @@ -import * as path from "node:path"; -import { nanoid } from "nanoid"; -import { type CollapseStrategy, collapse } from "../../../../lib/worktree/collapse"; -import { WorktreeError, WorktreeErrorCode } from "../../../../lib/worktree/errors"; -import { getRepoRoot, git } from "../../../../lib/worktree/git"; -import * as worktree from "../../../../lib/worktree/index"; -import { createSession, updateSession } from "../../../../lib/worktree/session"; -import { formatStats, getStats } from "../../../../lib/worktree/stats"; -import type { HookCommandContext } from "../../../hooks/types"; -import { discoverAgents, getAgent } from "../../../tools/task/discovery"; -import { runSubprocess } from "../../../tools/task/executor"; -import { generateTaskName } from "../../../tools/task/name-generator"; -import type { AgentDefinition } from "../../../tools/task/types"; -import type { CustomCommand, CustomCommandAPI } from "../../types"; - -interface FlagParseResult { - positionals: string[]; - flags: Map; -} - -interface NewArgs { - branch: string; - base?: string; -} - -interface MergeArgs { - source: string; - target?: string; - strategy?: CollapseStrategy; - keep?: boolean; -} - -interface RmArgs { - name: string; - force?: boolean; -} - -interface SpawnArgs { - task: string; - scope?: string; - name?: string; -} - -interface ParallelTask { - task: string; - scope: string; -} - -function parseFlags(args: string[]): FlagParseResult { - const flags = new Map(); - const positionals: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg.startsWith("--")) { - const name = arg.slice(2); - const next = args[i + 1]; - if (next && !next.startsWith("--")) { - flags.set(name, next); - i += 1; - } else { - flags.set(name, true); - } - } else { - positionals.push(arg); - } - } - - return { positionals, flags }; -} - -function getFlagValue(flags: Map, name: string): string | undefined { - const value = flags.get(name); - if (typeof value === "string") return value; - return undefined; -} - -function getFlagBoolean(flags: Map, name: string): boolean { - return flags.get(name) === true; -} - -function formatUsage(): string { - return [ - "Usage:", - " /wt new [--base ]", - " /wt list", - " /wt merge [dst] [--strategy simple|merge-base|rebase] [--keep]", - " /wt rm [--force]", - " /wt status", - ' /wt spawn "" [--scope ] [--name ]', - " /wt parallel --task --scope [--task --scope ]...", - ].join("\n"); -} - -function formatError(err: unknown): string { - if (err instanceof WorktreeError) { - return `${err.code}: ${err.message}`; - } - if (err instanceof Error) return err.message; - return String(err); -} - -async function pickAgent(cwd: string): Promise { - const { agents } = await discoverAgents(cwd); - // Use the bundled "task" agent as the general-purpose default. - const agent = getAgent(agents, "task") ?? agents[0]; - if (!agent) { - throw new Error("No agents available"); - } - return agent; -} - -function parseParallelTasks(args: string[]): ParallelTask[] { - const tasks: ParallelTask[] = []; - let current: Partial = {}; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--task") { - const value = args[i + 1]; - if (!value || value.startsWith("--")) { - throw new Error("Missing value for --task"); - } - current.task = value; - i += 1; - } else if (arg === "--scope") { - const value = args[i + 1]; - if (!value || value.startsWith("--")) { - throw new Error("Missing value for --scope"); - } - current.scope = value; - i += 1; - } else { - throw new Error(`Unknown argument: ${arg}`); - } - - if (current.task && current.scope) { - tasks.push({ task: current.task, scope: current.scope }); - current = {}; - } - } - - if (current.task || current.scope) { - throw new Error("Each --task must be paired with a --scope"); - } - - return tasks; -} - -function validateDisjointScopes(scopes: string[]): void { - for (let i = 0; i < scopes.length; i++) { - for (let j = i + 1; j < scopes.length; j++) { - const a = scopes[i].replace(/\*.*$/, ""); - const b = scopes[j].replace(/\*.*$/, ""); - if (a.startsWith(b) || b.startsWith(a)) { - throw new WorktreeError( - `Overlapping scopes: "${scopes[i]}" and "${scopes[j]}"`, - WorktreeErrorCode.OVERLAPPING_SCOPES, - ); - } - } - } -} - -async function handleNew(args: NewArgs): Promise { - const wt = await worktree.create(args.branch, { base: args.base }); - - return [`Created worktree: ${wt.path}`, `Branch: ${wt.branch ?? "detached"}`, "", `To switch: cd ${wt.path}`].join( - "\n", - ); -} - -async function handleList(ctx: HookCommandContext): Promise { - const worktrees = await worktree.list(); - const cwd = path.resolve(ctx.cwd); - const mainPath = await getRepoRoot(); - - const lines: string[] = []; - - for (const wt of worktrees) { - const stats = await getStats(wt.path); - const isCurrent = cwd === wt.path || cwd.startsWith(wt.path + path.sep); - const isMain = wt.path === mainPath; - - const marker = isCurrent ? "->" : " "; - const mainTag = isMain ? " [main]" : ""; - const branch = wt.branch ?? "detached"; - const statsStr = formatStats(stats); - - lines.push(`${marker} ${branch}${mainTag} (${statsStr})`); - } - - return lines.join("\n") || "No worktrees found"; -} - -async function handleMerge(args: MergeArgs): Promise { - const target = args.target ?? "main"; - const strategy = args.strategy ?? "rebase"; - - const result = await collapse(args.source, target, { - strategy, - keepSource: args.keep, - }); - - const lines = [ - `Collapsed ${args.source} -> ${target}`, - `Strategy: ${strategy}`, - `Changes: +${result.insertions} -${result.deletions} in ${result.filesChanged} files`, - ]; - - if (!args.keep) { - lines.push("Source worktree removed"); - } - - return lines.join("\n"); -} - -async function handleRm(args: RmArgs): Promise { - const wt = await worktree.find(args.name); - await worktree.remove(args.name, { force: args.force }); - - const mainPath = await getRepoRoot(); - if (wt.branch) { - await git(["branch", "-D", wt.branch], mainPath); - return `Removed worktree and branch: ${wt.branch}`; - } - - return `Removed worktree: ${wt.path}`; -} - -async function handleStatus(): Promise { - const worktrees = await worktree.list(); - const sections: string[] = []; - - for (const wt of worktrees) { - const branch = wt.branch ?? "detached"; - const name = path.basename(wt.path); - - const statusResult = await git(["status", "--short"], wt.path); - const status = statusResult.stdout.trim() || "(clean)"; - - sections.push(`${name} (${branch})\n${"-".repeat(40)}\n${status}`); - } - - return sections.join("\n\n"); -} - -async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise { - const branch = args.name ?? `wt-agent-${nanoid(6)}`; - const wt = await worktree.create(branch); - - const session = await createSession({ - branch, - path: wt.path, - scope: args.scope ? [args.scope] : undefined, - task: args.task, - }); - await updateSession(session.id, { status: "active" }); - - const agent = await pickAgent(ctx.cwd); - const context = args.scope ? `Scope: ${args.scope}` : undefined; - - // Command context doesn't expose a spawn API, so run the task subprocess directly. - const result = await runSubprocess({ - cwd: wt.path, - agent, - task: args.task, - index: 0, - taskId: generateTaskName(), - context, - }); - - await updateSession(session.id, { - status: result.exitCode === 0 ? "completed" : "failed", - completedAt: Date.now(), - }); - - if (result.exitCode !== 0) { - return [ - `Agent failed in worktree: ${branch}`, - result.stderr.trim() ? `Error: ${result.stderr.trim()}` : "Error: agent execution failed", - "", - "Actions:", - ` /wt merge ${branch} - Apply changes to main`, - " /wt status - Inspect changes", - ` /wt rm ${branch} - Discard changes`, - ].join("\n"); - } - - return [ - `Agent completed in worktree: ${branch}`, - "", - "Actions:", - ` /wt merge ${branch} - Apply changes to main`, - " /wt status - Inspect changes", - ` /wt rm ${branch} - Discard changes`, - ].join("\n"); -} - -async function handleParallel(args: ParallelTask[], ctx: HookCommandContext): Promise { - validateDisjointScopes(args.map((t) => t.scope)); - - const sessionId = `parallel-${nanoid()}`; - const agent = await pickAgent(ctx.cwd); - - const worktrees: Array<{ task: ParallelTask; wt: worktree.Worktree; session: worktree.WorktreeSession }> = []; - for (let i = 0; i < args.length; i++) { - const task = args[i]; - const branch = `wt-parallel-${sessionId}-${i}`; - const wt = await worktree.create(branch); - const session = await createSession({ - branch, - path: wt.path, - scope: [task.scope], - task: task.task, - }); - worktrees.push({ task, wt, session }); - } - - const agentPromises = worktrees.map(async ({ task, wt, session }, index) => { - await updateSession(session.id, { status: "active" }); - const result = await runSubprocess({ - cwd: wt.path, - agent, - task: task.task, - index, - taskId: generateTaskName(), - context: `Scope: ${task.scope}`, - }); - await updateSession(session.id, { - status: result.exitCode === 0 ? "completed" : "failed", - completedAt: Date.now(), - }); - return { wt, session, result }; - }); - - const results = await Promise.all(agentPromises); - - const mergeResults: string[] = []; - - for (const { wt, session } of results) { - try { - await updateSession(session.id, { status: "merging" }); - const collapseResult = await collapse(wt.branch ?? wt.path, "main", { - strategy: "simple", - keepSource: false, - }); - await updateSession(session.id, { status: "merged" }); - mergeResults.push( - `ok ${wt.branch ?? path.basename(wt.path)}: +${collapseResult.insertions} -${collapseResult.deletions}`, - ); - } catch (err) { - await updateSession(session.id, { status: "failed" }); - mergeResults.push(`err ${wt.branch ?? path.basename(wt.path)}: ${formatError(err)}`); - } - } - - return [`Parallel execution complete (${args.length} agents)`, "", "Results:", ...mergeResults].join("\n"); -} - -export class WorktreeCommand implements CustomCommand { - name = "wt"; - description = "Git worktree management"; - - // biome-ignore lint/complexity/noUselessConstructor: interface conformance - loader passes API to all commands - constructor(_api: CustomCommandAPI) {} - - async execute(args: string[], ctx: HookCommandContext): Promise { - if (args.length === 0) return formatUsage(); - - const subcommand = args[0]; - const rest = args.slice(1); - - try { - switch (subcommand) { - case "new": { - const parsed = parseFlags(rest); - const branch = parsed.positionals[0]; - if (!branch) return formatUsage(); - const base = getFlagValue(parsed.flags, "base"); - if (parsed.flags.get("base") === true) { - return "Missing value for --base"; - } - return await handleNew({ branch, base }); - } - case "list": - return await handleList(ctx); - case "merge": { - const parsed = parseFlags(rest); - const source = parsed.positionals[0]; - const target = parsed.positionals[1]; - if (!source) return formatUsage(); - const strategyRaw = getFlagValue(parsed.flags, "strategy"); - if (parsed.flags.get("strategy") === true) { - return "Missing value for --strategy"; - } - const strategy = strategyRaw as CollapseStrategy | undefined; - const keep = getFlagBoolean(parsed.flags, "keep"); - return await handleMerge({ source, target, strategy, keep }); - } - case "rm": { - const parsed = parseFlags(rest); - const name = parsed.positionals[0]; - if (!name) return formatUsage(); - const force = getFlagBoolean(parsed.flags, "force"); - return await handleRm({ name, force }); - } - case "status": - return await handleStatus(); - case "spawn": { - const parsed = parseFlags(rest); - const task = parsed.positionals[0]; - if (!task) return formatUsage(); - const scope = getFlagValue(parsed.flags, "scope"); - if (parsed.flags.get("scope") === true) { - return "Missing value for --scope"; - } - const name = getFlagValue(parsed.flags, "name"); - return await handleSpawn({ task, scope, name }, ctx); - } - case "parallel": { - const tasks = parseParallelTasks(rest); - if (tasks.length === 0) return formatUsage(); - return await handleParallel(tasks, ctx); - } - default: - return formatUsage(); - } - } catch (err) { - return formatError(err); - } - } -} - -export default WorktreeCommand; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5a6c26ebb..1072c0343 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -86,7 +86,6 @@ import { createTools, EditTool, FindTool, - GitTool, GrepTool, getWebSearchTools, LsTool, @@ -218,7 +217,6 @@ export { createTools, EditTool, FindTool, - GitTool, GrepTool, loadSshTool, LsTool, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 1cea8b8b6..d362d4c4b 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -98,10 +98,6 @@ export interface BashInterceptorSettings { patterns?: BashInterceptorRule[]; // default: built-in rules } -export interface GitSettings { - enabled?: boolean; // default: false (structured git tool; use bash for git commands when disabled) -} - export interface MCPSettings { enableProjectConfig?: boolean; // default: true (load .mcp.json from project root) } @@ -145,15 +141,6 @@ export interface TodoCompletionSettings { maxReminders?: number; // default: 3 - maximum reminders before giving up } -export interface VoiceSettings { - enabled?: boolean; // default: false - transcriptionModel?: string; // default: "whisper-1" - transcriptionLanguage?: string; // optional language hint (e.g., "en") - ttsModel?: string; // default: "gpt-4o-mini-tts" - ttsVoice?: string; // default: "alloy" - ttsFormat?: "wav" | "mp3" | "opus" | "aac" | "flac"; // default: "wav" -} - export type StatusLineSegmentId = | "pi" | "model" @@ -224,14 +211,12 @@ export interface Settings { enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) exa?: ExaSettings; bashInterceptor?: BashInterceptorSettings; - git?: GitSettings; mcp?: MCPSettings; lsp?: LspSettings; python?: PythonSettings; edit?: EditSettings; ttsr?: TtsrSettings; todoCompletion?: TodoCompletionSettings; - voice?: VoiceSettings; providers?: ProviderSettings; disabledProviders?: string[]; // Discovery provider IDs that are disabled disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit") @@ -252,12 +237,6 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [ tool: "grep", message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.", }, - { - pattern: "^\\s*git(\\s+|$)", - tool: "git", - message: - "Use the `git` tool instead of running git in bash. It provides structured output and safety confirmations.", - }, { pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)", tool: "find", @@ -319,19 +298,11 @@ const DEFAULT_SETTINGS: Settings = { enableWebsets: false, }, bashInterceptor: DEFAULT_BASH_INTERCEPTOR_SETTINGS, - git: { enabled: false }, mcp: { enableProjectConfig: true }, lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false }, python: { toolMode: "both", kernelMode: "session", sharedGateway: true }, edit: { fuzzyMatch: true, fuzzyThreshold: 0.95, streamingAbort: false }, ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 }, - voice: { - enabled: false, - transcriptionModel: "whisper-1", - ttsModel: "gpt-4o-mini-tts", - ttsVoice: "alloy", - ttsFormat: "wav", - }, providers: { webSearch: "auto", image: "auto" }, } satisfies Settings; @@ -1170,10 +1141,6 @@ export class SettingsManager { await this.save(); } - getGitToolEnabled(): boolean { - return this.settings.git?.enabled ?? false; - } - getPythonToolMode(): PythonToolMode { return this.settings.python?.toolMode ?? "both"; } @@ -1210,14 +1177,6 @@ export class SettingsManager { await this.save(); } - async setGitToolEnabled(enabled: boolean): Promise { - if (!this.globalSettings.git) { - this.globalSettings.git = {}; - } - this.globalSettings.git.enabled = enabled; - await this.save(); - } - getMCPProjectConfigEnabled(): boolean { return this.settings.mcp?.enableProjectConfig ?? true; } @@ -1430,70 +1389,6 @@ export class SettingsManager { await this.save(); } - getVoiceSettings(): Required { - return { - enabled: this.settings.voice?.enabled ?? false, - transcriptionModel: this.settings.voice?.transcriptionModel ?? "whisper-1", - transcriptionLanguage: this.settings.voice?.transcriptionLanguage ?? "", - ttsModel: this.settings.voice?.ttsModel ?? "tts-1", - ttsVoice: this.settings.voice?.ttsVoice ?? "alloy", - ttsFormat: this.settings.voice?.ttsFormat ?? "wav", - }; - } - - async setVoiceSettings(settings: VoiceSettings): Promise { - this.globalSettings.voice = { ...this.globalSettings.voice, ...settings }; - await this.save(); - } - - getVoiceEnabled(): boolean { - return this.settings.voice?.enabled ?? false; - } - - async setVoiceEnabled(enabled: boolean): Promise { - if (!this.globalSettings.voice) { - this.globalSettings.voice = {}; - } - this.globalSettings.voice.enabled = enabled; - await this.save(); - } - - getVoiceTtsModel(): string { - return this.settings.voice?.ttsModel ?? "gpt-4o-mini-tts"; - } - - async setVoiceTtsModel(model: string): Promise { - if (!this.globalSettings.voice) { - this.globalSettings.voice = {}; - } - this.globalSettings.voice.ttsModel = model; - await this.save(); - } - - getVoiceTtsVoice(): string { - return this.settings.voice?.ttsVoice ?? "alloy"; - } - - async setVoiceTtsVoice(voice: string): Promise { - if (!this.globalSettings.voice) { - this.globalSettings.voice = {}; - } - this.globalSettings.voice.ttsVoice = voice; - await this.save(); - } - - getVoiceTtsFormat(): "wav" | "mp3" | "opus" | "aac" | "flac" { - return this.settings.voice?.ttsFormat ?? "wav"; - } - - async setVoiceTtsFormat(format: "wav" | "mp3" | "opus" | "aac" | "flac"): Promise { - if (!this.globalSettings.voice) { - this.globalSettings.voice = {}; - } - this.globalSettings.voice.ttsFormat = format; - await this.save(); - } - // ═══════════════════════════════════════════════════════════════════════════ // Status Line Settings // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/core/tools/git.ts b/packages/coding-agent/src/core/tools/git.ts deleted file mode 100644 index 4723680e0..000000000 --- a/packages/coding-agent/src/core/tools/git.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-ai"; -import { type GitParams, gitTool as gitToolCore, type ToolResponse } from "@oh-my-pi/pi-git-tool"; -import { type Static, Type } from "@sinclair/typebox"; -import gitDescription from "../../prompts/tools/git.md" with { type: "text" }; -import { renderPromptTemplate } from "../prompt-templates"; -import type { ToolSession } from "./index"; - -const gitSchema = Type.Object({ - operation: StringEnum([ - "status", - "diff", - "log", - "show", - "blame", - "branch", - "add", - "restore", - "commit", - "checkout", - "merge", - "rebase", - "stash", - "cherry-pick", - "fetch", - "pull", - "push", - "tag", - "pr", - "issue", - "ci", - "release", - ]), - - // Status - only: Type.Optional(StringEnum(["branch", "modified", "staged", "untracked", "conflicts", "sync"])), - ignored: Type.Optional(Type.Boolean()), - - // Diff - target: Type.Optional( - Type.Union([ - Type.Object({ - from: Type.String(), - to: Type.Optional(Type.String()), - }), - Type.String(), - ]), - ), - paths: Type.Optional(Type.Array(Type.String())), - stat_only: Type.Optional(Type.Boolean()), - name_only: Type.Optional(Type.Boolean()), - context: Type.Optional(Type.Number()), - max_lines: Type.Optional(Type.Number()), - ignore_whitespace: Type.Optional(Type.Boolean()), - - // Log - limit: Type.Optional(Type.Number()), - ref: Type.Optional(Type.String()), - author: Type.Optional(Type.String()), - since: Type.Optional(Type.String()), - until: Type.Optional(Type.String()), - grep: Type.Optional(Type.String()), - format: Type.Optional(StringEnum(["oneline", "short", "full"])), - stat: Type.Optional(Type.Boolean()), - merges: Type.Optional(Type.Boolean()), - first_parent: Type.Optional(Type.Boolean()), - - // Show - path: Type.Optional(Type.String()), - diff: Type.Optional(Type.Boolean()), - lines: Type.Optional( - Type.Object({ - start: Type.Number(), - end: Type.Number(), - }), - ), - - // Blame - root: Type.Optional(Type.Boolean()), - - // Branch - action: Type.Optional(StringEnum(["list", "create", "delete", "rename", "current"])), - name: Type.Optional(Type.String()), - newName: Type.Optional(Type.String()), - startPoint: Type.Optional(Type.String()), - remotes: Type.Optional(Type.Boolean()), - force: Type.Optional(Type.Boolean()), - - // Add/Restore - update: Type.Optional(Type.Boolean()), - all: Type.Optional(Type.Boolean()), - dry_run: Type.Optional(Type.Boolean()), - staged: Type.Optional(Type.Boolean()), - worktree: Type.Optional(Type.Boolean()), - source: Type.Optional(Type.String()), - - // Commit - message: Type.Optional(Type.String()), - allow_empty: Type.Optional(Type.Boolean()), - sign: Type.Optional(Type.Boolean()), - no_verify: Type.Optional(Type.Boolean()), - amend: Type.Optional(Type.Boolean()), - - // Checkout - create: Type.Optional(Type.Boolean()), - - // Merge - no_ff: Type.Optional(Type.Boolean()), - ff_only: Type.Optional(Type.Boolean()), - squash: Type.Optional(Type.Boolean()), - abort: Type.Optional(Type.Boolean()), - continue: Type.Optional(Type.Boolean()), - - // Rebase - onto: Type.Optional(Type.String()), - upstream: Type.Optional(Type.String()), - skip: Type.Optional(Type.Boolean()), - - // Stash - include_untracked: Type.Optional(Type.Boolean()), - index: Type.Optional(Type.Number()), - keep_index: Type.Optional(Type.Boolean()), - - // Cherry-pick - commits: Type.Optional(Type.Array(Type.String())), - no_commit: Type.Optional(Type.Boolean()), - - // Fetch/Pull/Push/Tag - remote: Type.Optional(Type.String()), - branch: Type.Optional(Type.String()), - prune: Type.Optional(Type.Boolean()), - tags: Type.Optional(Type.Boolean()), - rebase: Type.Optional(Type.Boolean()), - set_upstream: Type.Optional(Type.Boolean()), - force_with_lease: Type.Optional(Type.Boolean()), - delete: Type.Optional(Type.Boolean()), - force_override: Type.Optional(Type.Boolean()), - - // Tag - // (name/message/ref already covered) - - // PR - number: Type.Optional(Type.Number()), - title: Type.Optional(Type.String()), - body: Type.Optional(Type.String()), - base: Type.Optional(Type.String()), - head: Type.Optional(Type.String()), - draft: Type.Optional(Type.Boolean()), - state: Type.Optional(StringEnum(["open", "closed", "merged", "all"])), - merge_method: Type.Optional(StringEnum(["merge", "squash", "rebase"])), - review_action: Type.Optional(StringEnum(["approve", "request-changes", "comment"])), - review_body: Type.Optional(Type.String()), - - // Issue - labels: Type.Optional(Type.Array(Type.String())), - assignee: Type.Optional(Type.String()), - comment_body: Type.Optional(Type.String()), - - // CI - workflow: Type.Optional(Type.String()), - run_id: Type.Optional(Type.Number()), - inputs: Type.Optional(Type.Record(Type.String(), Type.String())), - logs_failed: Type.Optional(Type.Boolean()), - - // Release - notes: Type.Optional(Type.String()), - generate_notes: Type.Optional(Type.Boolean()), - prerelease: Type.Optional(Type.Boolean()), - assets: Type.Optional(Type.Array(Type.String())), -}); - -export type GitToolDetails = ToolResponse; - -export class GitTool implements AgentTool { - public readonly name = "git"; - public readonly label = "Git"; - public readonly description: string; - public readonly parameters = gitSchema; - - private readonly session: ToolSession; - - constructor(session: ToolSession) { - this.session = session; - this.description = renderPromptTemplate(gitDescription); - } - - static createIf(session: ToolSession): GitTool | null { - return session.settings?.getGitToolEnabled() === false ? null : new GitTool(session); - } - - public async execute( - _toolCallId: string, - params: Static, - _signal?: AbortSignal, - _onUpdate?: AgentToolUpdateCallback, - _context?: AgentToolContext, - ): Promise> { - if (params.operation === "commit" && !params.message) { - throw new Error("Git commit requires a message to avoid an interactive editor. Provide `message`."); - } - - const result = await gitToolCore(params as GitParams, this.session.cwd); - if ("error" in result) { - const message = result._rendered ?? result.error; - return { content: [{ type: "text", text: message }], details: result }; - } - if ("confirm" in result) { - const message = result._rendered ?? result.confirm; - return { content: [{ type: "text", text: message }], details: result }; - } - return { content: [{ type: "text", text: result._rendered }], details: result }; - } -} diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index d2c1266e7..a3ca334f7 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -7,7 +7,6 @@ export { exaTools } from "./exa/index"; export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types"; export { type FindOperations, FindTool, type FindToolDetails, type FindToolOptions } from "./find"; export { setPreferredImageProvider } from "./gemini-image"; -export { GitTool, type GitToolDetails } from "./git"; export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolOptions } from "./grep"; export { type LsOperations, LsTool, type LsToolDetails, type LsToolOptions } from "./ls"; export { @@ -72,7 +71,6 @@ import { BashTool } from "./bash"; import { CalculatorTool } from "./calculator"; import { CompleteTool } from "./complete"; import { FindTool } from "./find"; -import { GitTool } from "./git"; import { GrepTool } from "./grep"; import { LsTool } from "./ls"; import { LspTool } from "./lsp/index"; @@ -132,7 +130,6 @@ export interface ToolSession { getEditFuzzyMatch(): boolean; getEditFuzzyThreshold?(): number; getEditPatchMode?(): boolean; - getGitToolEnabled(): boolean; getBashInterceptorEnabled(): boolean; getBashInterceptorSimpleLsEnabled(): boolean; getBashInterceptorRules(): BashInterceptorRule[]; @@ -152,7 +149,6 @@ export const BUILTIN_TOOLS: Record = { ssh: loadSshTool, edit: (s) => new EditTool(s), find: (s) => new FindTool(s), - git: GitTool.createIf, grep: (s) => new GrepTool(s), ls: (s) => new LsTool(s), lsp: LspTool.createIf, diff --git a/packages/coding-agent/src/core/voice-controller.ts b/packages/coding-agent/src/core/voice-controller.ts deleted file mode 100644 index 8a5cc5096..000000000 --- a/packages/coding-agent/src/core/voice-controller.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { logger } from "@oh-my-pi/pi-utils"; -import { Agent, run, setDefaultOpenAIKey } from "@openai/agents"; -import { z } from "zod"; -import type { ModelRegistry } from "./model-registry"; - -const DEFAULT_CONTROLLER_MODEL = process.env.OMP_VOICE_CONTROLLER_MODEL ?? "gpt-4o-mini"; -const DEFAULT_SUMMARY_MODEL = process.env.OMP_VOICE_SUMMARY_MODEL ?? DEFAULT_CONTROLLER_MODEL; -const MAX_INPUT_CHARS = 8000; - -export type VoiceSteeringDecision = { action: "pass" | "ask"; text: string }; -export type VoicePresentationDecision = { action: "skip" | "speak"; text?: string }; -type VoiceSummaryOutput = { text: string }; - -const steeringSchema: z.ZodType = z.object({ - action: z.enum(["pass", "ask"]), - text: z.string().min(1), -}); - -const presentationSchema: z.ZodType = z.object({ - action: z.enum(["skip", "speak"]), - text: z.string().min(1).optional(), -}); - -const summarySchema: z.ZodType = z.object({ - text: z.string().min(1), -}); - -function normalizeText(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -function truncateText(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}...`; -} - -export class VoiceController { - private lastApiKey: string | undefined; - - constructor(private registry: ModelRegistry) {} - - private async ensureApiKey(): Promise { - const apiKey = await this.registry.getApiKeyForProvider("openai"); - if (!apiKey) { - logger.debug("voice-controller: no OpenAI API key available"); - return null; - } - if (apiKey !== this.lastApiKey) { - setDefaultOpenAIKey(apiKey); - this.lastApiKey = apiKey; - } - return apiKey; - } - - async steerUserInput(text: string): Promise { - if (!(await this.ensureApiKey())) return null; - - const normalized = truncateText(normalizeText(text), MAX_INPUT_CHARS); - const agent = new Agent({ - name: "Voice Input Steering", - instructions: - "You are a voice-input controller for a coding agent. " + - "Given a user's speech transcript, decide if it is clear enough to send to the agent. " + - "If unclear or missing key details, ask exactly one short question. " + - "If clear, rewrite it as a concise instruction for the agent. " + - "Keep it short and preserve intent.", - model: DEFAULT_CONTROLLER_MODEL, - outputType: steeringSchema, - }); - - try { - const result = await run(agent, normalized); - return result.finalOutput ?? null; - } catch (error) { - logger.debug("voice-controller: steering error", { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } - - async decidePresentation(text: string): Promise { - if (!(await this.ensureApiKey())) return null; - - const normalized = truncateText(normalizeText(text), MAX_INPUT_CHARS); - const agent = new Agent({ - name: "Voice Presentation Gate", - instructions: - "You are a voice presentation gate for a coding agent. " + - "Decide whether to speak the assistant response to the user. " + - "Speak when there is a decision, summary, or a question for the user. " + - "Skip if it is mostly tool output, verbose logs, or not useful to speak. " + - "When speaking, respond in 1-3 short sentences (<=45 words) in a casual, concise tone. " + - "If user input is needed, ask exactly one short question.", - model: DEFAULT_CONTROLLER_MODEL, - outputType: presentationSchema, - }); - - try { - const result = await run(agent, normalized); - return result.finalOutput ?? null; - } catch (error) { - logger.debug("voice-controller: presentation error", { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } - - async summarizeForVoice(text: string): Promise { - if (!(await this.ensureApiKey())) return null; - - const normalized = truncateText(normalizeText(text), MAX_INPUT_CHARS); - const agent = new Agent({ - name: "Voice Summary", - instructions: - "Summarize the assistant response for voice playback. " + - "Use 1-2 short sentences. " + - "If a question is required from the user, ask one short question.", - model: DEFAULT_SUMMARY_MODEL, - outputType: summarySchema, - }); - - try { - const result = await run(agent, normalized); - const output = result.finalOutput?.text ?? ""; - return output.trim() || null; - } catch (error) { - logger.debug("voice-controller: summary error", { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } -} diff --git a/packages/coding-agent/src/core/voice-supervisor.ts b/packages/coding-agent/src/core/voice-supervisor.ts deleted file mode 100644 index e31e368f7..000000000 --- a/packages/coding-agent/src/core/voice-supervisor.ts +++ /dev/null @@ -1,976 +0,0 @@ -import { logger, ptree } from "@oh-my-pi/pi-utils"; -import { - RealtimeAgent, - RealtimeSession, - type RealtimeSessionConfig, - type TransportEvent, - type TransportLayerAudio, - tool, -} from "@openai/agents/realtime"; -import type { ReadableStreamDefaultReader as WebReadableStreamDefaultReader } from "stream/web"; -import { z } from "zod"; -import type { ModelRegistry } from "./model-registry"; - -const DEFAULT_REALTIME_MODEL = process.env.OMP_VOICE_REALTIME_MODEL ?? "gpt-realtime"; -const DEFAULT_REALTIME_VOICE = process.env.OMP_VOICE_REALTIME_VOICE ?? "marin"; -const DEFAULT_SAMPLE_RATE = 24000; -const DEFAULT_CHANNELS = 1; -const DEFAULT_BITS = 16; -const INTERRUPT_DEBOUNCE_MS = 200; -const MAX_RESULT_CHARS = 6000; -const MAX_PROGRESS_CHARS = 1400; -const PLAYBACK_ACTIVE_WINDOW_MS = 350; -// Echo cancellation: only suppress mic when playback is active and mic is much quieter -const ECHO_SUPPRESSION_RATIO = 2.5; -// Minimum RMS to ever send (absolute noise floor) -const MIC_NOISE_FLOOR = 0.005; -const PLAYBACK_ERROR_COOLDOWN_MS = 2000; - -const SUPERVISOR_INSTRUCTIONS = [ - "You are the realtime voice supervisor for a terminal coding agent.", - "Manage conversation flow, turn-taking, and what gets spoken aloud.", - "For user speech: if unclear, ask exactly one short question.", - "If clear, call send_to_agent with a concise instruction for the coding agent.", - "If the user is greeting/smalltalk or gives no actionable request, respond briefly and do not call send_to_agent.", - "Keep spoken responses to 1-2 short sentences (<=40 words).", - "You will receive system updates prefixed with SYSTEM_EVENT, PROGRESS_UPDATE, or AGENT_OUTPUT.", - "For AGENT_OUTPUT, always respond with a brief spoken summary and any single question needed.", - "For PROGRESS_UPDATE, speak a short update only if it helps the user stay oriented.", - "Do not call send_to_agent for system updates.", - "If the user asks to stop or cancel work, call interrupt_agent.", -].join(" "); - -type VoiceSupervisorCallbacks = { - onSendToAgent: (text: string) => Promise | void; - onInterruptAgent: (reason?: string) => Promise | void; - onStatus: (status?: string) => void; - onError: (error: Error) => void; - onWarning?: (message: string) => void; -}; - -function normalizeText(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -function truncateText(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}...`; -} - -function toArrayBuffer(chunk: Uint8Array): ArrayBuffer { - const buffer = chunk.buffer; - if (buffer instanceof ArrayBuffer) { - if (chunk.byteOffset === 0 && chunk.byteLength === buffer.byteLength) { - return buffer; - } - return buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength); - } - const copy = new Uint8Array(chunk.byteLength); - copy.set(chunk); - return copy.buffer; -} - -function describeError(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === "string") return error; - if (error && typeof error === "object") { - const maybeMessage = (error as { message?: unknown }).message; - if (typeof maybeMessage === "string") return maybeMessage; - const nested = (error as { error?: unknown }).error; - if (nested) return describeError(nested); - try { - return JSON.stringify(error); - } catch { - return String(error); - } - } - return String(error); -} - -type AudioToolStatus = { - capture: { available: boolean; tool?: string; command?: string[] }; - playback: { available: boolean; tool?: string; command?: string[] }; -}; - -function checkAudioTools(sampleRate: number, channels: number): AudioToolStatus { - const captureResult = buildCaptureCommand(sampleRate, channels); - const playbackCmd = buildPlaybackCommand(sampleRate, channels); - - return { - capture: { - available: captureResult !== null, - tool: captureResult?.command[0], - command: captureResult?.command, - }, - playback: { - available: playbackCmd !== null, - tool: playbackCmd?.[0], - command: playbackCmd ?? undefined, - }, - }; -} - -function getMissingToolsMessage(): string { - const platform = process.platform; - const lines: string[] = ["Voice mode requires audio tools. Install one of the following:"]; - - if (platform === "linux") { - lines.push(""); - lines.push(" For capture (microphone):"); - lines.push(" • sox (recommended): sudo dnf install sox"); - lines.push(" • pulseaudio-utils: sudo dnf install pulseaudio-utils"); - lines.push(" • alsa-utils: sudo dnf install alsa-utils"); - lines.push(" • ffmpeg: sudo dnf install ffmpeg"); - lines.push(""); - lines.push(" For playback (speaker):"); - lines.push(" • sox (recommended): sudo dnf install sox"); - lines.push(" • ffmpeg: sudo dnf install ffmpeg"); - lines.push(""); - lines.push(" Set OMP_VOICE_CAPTURE_DEVICE to override the default capture device."); - lines.push(" (Applies to all tools; for sox, this sets AUDIODEV internally.)"); - } else if (platform === "darwin") { - lines.push(""); - lines.push(" • sox (recommended): brew install sox"); - lines.push(" • ffmpeg: brew install ffmpeg"); - } else if (platform === "win32") { - lines.push(""); - lines.push(" • sox: choco install sox"); - lines.push(" • ffmpeg: choco install ffmpeg"); - } - - return lines.join("\n"); -} - -type CaptureCommand = { command: string[]; env?: Record }; - -function buildCaptureCommand(sampleRate: number, channels: number): CaptureCommand | null { - const platform = process.platform; - // Allow user to override capture device via environment - const captureDevice = process.env.OMP_VOICE_CAPTURE_DEVICE; - - // Prefer sox/rec as they work well across platforms - const soxPath = Bun.which("sox") ?? Bun.which("rec"); - if (soxPath) { - const command = [ - soxPath, - "-q", - "-d", - "-t", - "raw", - "-r", - String(sampleRate), - "-e", - "signed-integer", - "-b", - String(DEFAULT_BITS), - "-c", - String(channels), - "-", - ]; - // sox uses AUDIODEV env var to override the default device - const env = captureDevice ? { AUDIODEV: captureDevice } : undefined; - return { command, env }; - } - - // On Linux, try PulseAudio first (parecord) - if (platform === "linux") { - const parecordPath = Bun.which("parecord"); - if (parecordPath) { - const command = [parecordPath, "--raw", "--format=s16le", `--rate=${sampleRate}`, `--channels=${channels}`]; - if (captureDevice) { - command.push(`--device=${captureDevice}`); - } - return { command }; - } - } - - // ALSA arecord as fallback on Linux - const arecordPath = Bun.which("arecord"); - if (arecordPath) { - const device = captureDevice ?? "default"; - return { - command: [ - arecordPath, - "-q", - "-D", - device, - "-f", - "S16_LE", - "-r", - String(sampleRate), - "-c", - String(channels), - "-t", - "raw", - ], - }; - } - - // ffmpeg fallback with platform-specific input - const ffmpegPath = Bun.which("ffmpeg"); - if (ffmpegPath) { - if (platform === "darwin") { - const device = captureDevice ?? ":0"; - return { - command: [ - ffmpegPath, - "-hide_banner", - "-loglevel", - "error", - "-f", - "avfoundation", - "-i", - device, - "-ac", - String(channels), - "-ar", - String(sampleRate), - "-f", - "s16le", - "-", - ], - }; - } - if (platform === "linux") { - // Try PulseAudio format first, fall back to ALSA - const hasPulse = Bun.which("pulseaudio") || Bun.which("pipewire-pulse") || process.env.PULSE_SERVER; - const format = hasPulse ? "pulse" : "alsa"; - const device = captureDevice ?? "default"; - return { - command: [ - ffmpegPath, - "-hide_banner", - "-loglevel", - "error", - "-f", - format, - "-i", - device, - "-ac", - String(channels), - "-ar", - String(sampleRate), - "-f", - "s16le", - "-", - ], - }; - } - if (platform === "win32") { - const device = captureDevice ?? "audio=default"; - return { - command: [ - ffmpegPath, - "-hide_banner", - "-loglevel", - "error", - "-f", - "dshow", - "-i", - device, - "-ac", - String(channels), - "-ar", - String(sampleRate), - "-f", - "s16le", - "-", - ], - }; - } - } - - return null; -} - -function buildPlaybackCommand(sampleRate: number, channels: number): string[] | null { - const preferred = process.env.OMP_VOICE_PLAYBACK?.toLowerCase(); - const ffplayPath = Bun.which("ffplay"); - const playPath = Bun.which("play"); - const soxPath = Bun.which("sox"); - - const playCommand = playPath - ? [ - playPath, - "-q", - "-t", - "raw", - "-r", - String(sampleRate), - "-e", - "signed-integer", - "-b", - String(DEFAULT_BITS), - "-c", - String(channels), - "-", - ] - : null; - - const soxCommand = soxPath - ? [ - soxPath, - "-q", - "-t", - "raw", - "-r", - String(sampleRate), - "-e", - "signed-integer", - "-b", - String(DEFAULT_BITS), - "-c", - String(channels), - "-", - "-d", - ] - : null; - - const ffplayCommand = ffplayPath - ? [ - ffplayPath, - "-nodisp", - "-autoexit", - "-hide_banner", - "-loglevel", - "error", - "-fflags", - "nobuffer", - "-flags", - "low_delay", - "-f", - "s16le", - "-ar", - String(sampleRate), - "-ac", - String(channels), - "-", - ] - : null; - - if (preferred === "ffplay") return ffplayCommand; - if (preferred === "play") return playCommand ?? soxCommand; - if (preferred === "sox") return soxCommand ?? playCommand; - - return playCommand ?? soxCommand ?? ffplayCommand; -} - -function rms16le(buffer: Uint8Array): number { - if (buffer.byteLength < 2) return 0; - const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); - let sum = 0; - let count = 0; - for (let i = 0; i + 1 < buffer.byteLength; i += 2) { - const sample = view.getInt16(i, true) / 32768; - sum += sample * sample; - count += 1; - } - if (count === 0) return 0; - return Math.sqrt(sum / count); -} - -export class VoiceSupervisor { - private session: RealtimeSession | undefined = undefined; - private captureProcess: ptree.ChildProcess | undefined = undefined; - private captureReader: WebReadableStreamDefaultReader | undefined = undefined; - private playbackProcess: ptree.ChildProcess | undefined = undefined; - private playbackWriter: - | { - write: (chunk: Uint8Array) => Promise; - close: () => Promise; - } - | undefined = undefined; - private active = false; - private connected = false; - private sessionReady = false; - private lastInterruptAt = 0; - private lastPlaybackAt = 0; - private lastPlaybackRms = 0; - private lastPlaybackErrorAt = 0; - // Fallback transcript handling: track user speech when no tool call is made - private pendingTranscript = ""; - private pendingResponseHasToolCall = false; - private pendingResponseHasAudioOutput = false; - - constructor( - private registry: ModelRegistry, - private callbacks: VoiceSupervisorCallbacks, - ) {} - - /** - * Check if audio tools are available for voice mode. - * Returns null if all tools are available, or an error message if not. - */ - static checkAvailability(): { available: boolean; error?: string; tools?: AudioToolStatus } { - const status = checkAudioTools(DEFAULT_SAMPLE_RATE, DEFAULT_CHANNELS); - if (status.capture.available && status.playback.available) { - return { available: true, tools: status }; - } - - const missing: string[] = []; - if (!status.capture.available) missing.push("capture"); - if (!status.playback.available) missing.push("playback"); - - return { - available: false, - error: `Missing audio ${missing.join(" and ")} tools.\n\n${getMissingToolsMessage()}`, - tools: status, - }; - } - - get isActive(): boolean { - return this.active; - } - - async start(): Promise { - if (this.active) return; - - const apiKey = await this.registry.getApiKeyForProvider("openai"); - if (!apiKey) { - throw new Error("OpenAI API key not found (set OPENAI_API_KEY or login)."); - } - - this.active = true; - this.lastInterruptAt = 0; - this.sessionReady = false; - this.lastPlaybackErrorAt = 0; - this.pendingTranscript = ""; - this.pendingResponseHasToolCall = false; - this.pendingResponseHasAudioOutput = false; - this.callbacks.onStatus("Connecting realtime voice..."); - - try { - const agent = this.createSupervisorAgent(); - const session = new RealtimeSession(agent, { - transport: "websocket", - model: DEFAULT_REALTIME_MODEL, - config: this.buildSessionConfig(), - }); - - this.session = session; - this.bindSessionEvents(session); - await session.connect({ apiKey }); - this.connected = session.transport.status === "connected"; - this.sessionReady = this.connected; - if (!this.connected) { - await this.waitForConnection(session, 5000); - } - await this.waitForSessionReady(session, 5000); - await this.startCapture(); - await this.ensurePlayback(); - this.callbacks.onStatus("Listening... (auto-send on silence, Ctrl+Y to stop)"); - } catch (error) { - await this.stop(); - throw new Error(describeError(error)); - } - } - - async stop(): Promise { - if (!this.active) return; - this.active = false; - this.connected = false; - this.sessionReady = false; - await this.stopCapture(); - await this.resetPlayback(); - if (this.session) { - this.session.close(); - this.session = undefined; - } - this.callbacks.onStatus(undefined); - } - - notifyProgress(text: string): void { - this.sendSystemMessage("PROGRESS_UPDATE", text, MAX_PROGRESS_CHARS); - } - - notifyResult(text: string): void { - this.sendSystemMessage("AGENT_OUTPUT", text, MAX_RESULT_CHARS); - } - - private sendSystemMessage(prefix: string, text: string, maxChars: number): void { - if (!this.session || !this.active) return; - if (!this.connected || !this.sessionReady || this.session.transport.status !== "connected") return; - const trimmed = normalizeText(text); - if (!trimmed) return; - const payload = `${prefix}: ${truncateText(trimmed, maxChars)}`; - try { - this.session.transport.sendEvent({ - type: "conversation.item.create", - item: { - type: "message", - role: "system", - content: [{ type: "input_text", text: payload }], - }, - }); - this.session.transport.sendEvent({ type: "response.create" }); - } catch (error) { - const message = describeError(error); - if (message.includes("WebSocket is not connected")) return; - this.callbacks.onError(error instanceof Error ? error : new Error(message)); - } - } - - private createSupervisorAgent(): RealtimeAgent { - const sendToAgentTool = tool({ - name: "send_to_agent", - description: "Send a concise instruction to the coding agent.", - parameters: z.object({ - text: z.string().min(1), - }), - execute: async ({ text }) => { - const cleaned = normalizeText(text); - if (cleaned) { - await this.callbacks.onSendToAgent(cleaned); - } - return "sent"; - }, - }); - - const interruptAgentTool = tool({ - name: "interrupt_agent", - description: "Interrupt the coding agent immediately.", - parameters: z.object({ - reason: z.string().optional(), - }), - execute: async ({ reason }) => { - await this.callbacks.onInterruptAgent(reason); - return "interrupted"; - }, - }); - - return new RealtimeAgent({ - name: "Voice Supervisor", - instructions: SUPERVISOR_INSTRUCTIONS, - tools: [sendToAgentTool, interruptAgentTool], - voice: DEFAULT_REALTIME_VOICE, - }); - } - - private buildSessionConfig(): Partial { - return { - outputModalities: ["audio"], - audio: { - input: { - format: { type: "audio/pcm", rate: DEFAULT_SAMPLE_RATE }, - noiseReduction: { type: "near_field" }, - turnDetection: { - type: "semantic_vad", - createResponse: true, - interruptResponse: true, - }, - }, - output: { - format: { type: "audio/pcm", rate: DEFAULT_SAMPLE_RATE }, - ...(DEFAULT_REALTIME_VOICE ? { voice: DEFAULT_REALTIME_VOICE } : {}), - }, - }, - }; - } - - private bindSessionEvents(session: RealtimeSession): void { - session.transport.on("connection_change", (status) => { - this.connected = status === "connected"; - if (this.connected) { - this.sessionReady = true; - } else { - this.sessionReady = false; - } - if (!this.active) return; - if (this.connected) { - this.callbacks.onStatus("Listening... (auto-send on silence, Ctrl+Y to stop)"); - } else { - this.callbacks.onStatus("Reconnecting realtime voice..."); - } - }); - - session.on("audio", (event: TransportLayerAudio) => { - void this.handleAudio(event); - }); - - session.on("audio_start", () => { - if (!this.active) return; - this.pendingResponseHasAudioOutput = true; - this.callbacks.onStatus("Speaking..."); - }); - - session.on("audio_stopped", () => { - if (!this.active) return; - this.callbacks.onStatus("Listening... (auto-send on silence, Ctrl+Y to stop)"); - }); - - session.on("audio_interrupted", () => { - void this.resetPlayback(); - if (!this.active) return; - this.callbacks.onStatus("Listening... (auto-send on silence, Ctrl+Y to stop)"); - }); - - session.on("transport_event", (event: TransportEvent) => { - this.handleTransportEvent(event); - }); - - session.on("error", (error) => { - const message = describeError(error); - logger.debug("voice-supervisor: realtime error", { error: message }); - if (message.includes("WebSocket is not connected")) { - if (this.active) { - this.callbacks.onStatus("Reconnecting realtime voice..."); - } - return; - } - this.callbacks.onError(new Error(message)); - }); - } - - private handleTransportEvent(event: TransportEvent): void { - if (!this.active) return; - - // Session ready - if (event.type === "session.created") { - this.sessionReady = true; - return; - } - - // User speech started - interrupt agent and reset tracking - if (event.type === "input_audio_buffer.speech_started") { - const now = Date.now(); - if (now - this.lastInterruptAt < INTERRUPT_DEBOUNCE_MS) return; - this.lastInterruptAt = now; - this.pendingTranscript = ""; - this.pendingResponseHasToolCall = false; - this.pendingResponseHasAudioOutput = false; - void this.callbacks.onInterruptAgent(); - return; - } - - // User speech transcript completed - store for fallback - if (event.type === "conversation.item.input_audio_transcription.completed") { - const transcript = (event as { transcript?: string }).transcript; - if (transcript && typeof transcript === "string") { - this.pendingTranscript = normalizeText(transcript); - logger.debug("voice-supervisor: transcript captured", { transcript: this.pendingTranscript }); - } - return; - } - - // Response started - begin tracking - if (event.type === "response.created") { - this.pendingResponseHasToolCall = false; - this.pendingResponseHasAudioOutput = false; - return; - } - - // Tool call detected - mark so we know not to use fallback - // Check multiple event types for robustness against API changes - if ( - event.type === "function_call" || - event.type === "response.function_call_arguments.done" || - event.type === "response.function_call_arguments.delta" || - event.type === "response.output_item.added" - ) { - // For output_item.added, only mark if it's a function_call type - if (event.type === "response.output_item.added") { - const item = (event as { item?: { type?: string } }).item; - if (item?.type === "function_call") { - this.pendingResponseHasToolCall = true; - } - } else { - this.pendingResponseHasToolCall = true; - } - return; - } - - // Audio output detected - mark so we don't fallback - if ( - event.type === "response.output_audio.delta" || - event.type === "response.output_audio.done" || - event.type === "response.output_audio_transcript.delta" || - event.type === "response.output_audio_transcript.done" || - event.type === "response.content_part.added" || - event.type === "response.content_part.done" - ) { - this.pendingResponseHasAudioOutput = true; - return; - } - - // Response completed - check if we need fallback - if (event.type === "response.done") { - // Only use fallback if we have a transcript AND there was no tool call AND no audio output - // This prevents duplicate responses when the realtime assistant already spoke - if (this.pendingTranscript && !this.pendingResponseHasToolCall && !this.pendingResponseHasAudioOutput) { - logger.debug("voice-supervisor: using fallback transcript path", { - transcript: this.pendingTranscript, - }); - const transcript = this.pendingTranscript; - this.pendingTranscript = ""; - // Queue the fallback asynchronously to avoid blocking - setImmediate(() => { - if (this.active) { - void this.callbacks.onSendToAgent(transcript); - } - }); - } - return; - } - } - - private async handleAudio(event: TransportLayerAudio): Promise { - if (!this.active) return; - const now = Date.now(); - try { - await this.ensurePlayback(); - } catch (error) { - this.callbacks.onError(new Error(describeError(error))); - return; - } - if (!this.playbackWriter) return; - try { - await this.playbackWriter.write(new Uint8Array(event.data)); - this.lastPlaybackAt = now; - this.lastPlaybackRms = rms16le(new Uint8Array(event.data)); - } catch (error) { - logger.debug("voice-supervisor: playback write failed", { - error: describeError(error), - }); - void this.resetPlayback(); - } - } - - private async startCapture(): Promise { - const captureResult = buildCaptureCommand(DEFAULT_SAMPLE_RATE, DEFAULT_CHANNELS); - if (!captureResult) { - throw new Error(`No audio capture tool found.\n\n${getMissingToolsMessage()}`); - } - - const { command, env: captureEnv } = captureResult; - logger.debug("voice-supervisor: starting mic capture", { command, env: captureEnv }); - const proc = ptree.cspawn(command, { - env: captureEnv ? { ...process.env, ...captureEnv } : undefined, - }); - this.captureProcess = proc; - const reader = proc.stdout.getReader(); - this.captureReader = reader; - - (async () => { - while (this.active) { - const { value, done } = await reader.read(); - if (done || !this.active) break; - if (!value || !this.session) continue; - if (!this.connected || !this.sessionReady || this.session.transport.status !== "connected") { - continue; - } - - const micRms = rms16le(value); - const now = Date.now(); - const playbackActive = now - this.lastPlaybackAt < PLAYBACK_ACTIVE_WINDOW_MS; - - // Echo suppression: only skip if playback is active AND mic is very quiet relative to playback - // This prevents feedback loops while allowing user to speak over the assistant - if (playbackActive && micRms < MIC_NOISE_FLOOR && micRms < this.lastPlaybackRms / ECHO_SUPPRESSION_RATIO) { - continue; - } - - // Send all audio to realtime API - let semantic_vad handle turn detection - const buffer = toArrayBuffer(value); - if (buffer.byteLength === 0) continue; - try { - this.session.sendAudio(buffer); - } catch (error) { - const message = describeError(error); - logger.debug("voice-supervisor: sendAudio failed", { error: message }); - if (message.includes("WebSocket is not connected")) { - continue; - } - this.callbacks.onError(error instanceof Error ? error : new Error(message)); - return; - } - } - if (this.active) { - this.callbacks.onError(new Error("Voice capture stopped unexpectedly.")); - } - })().catch((error) => { - if (!this.active) return; - logger.debug("voice-supervisor: capture loop error", { - error: describeError(error), - }); - this.callbacks.onError(new Error(describeError(error))); - }); - } - - private async stopCapture(): Promise { - if (this.captureReader) { - try { - await this.captureReader.cancel(); - } catch { - // ignore - } - this.captureReader = undefined; - } - if (this.captureProcess) { - try { - this.captureProcess.kill("SIGINT"); - } catch { - // ignore - } - await this.captureProcess.exited; - this.captureProcess = undefined; - } - } - - private async ensurePlayback(): Promise { - if (this.playbackProcess && this.playbackWriter) return; - const command = buildPlaybackCommand(DEFAULT_SAMPLE_RATE, DEFAULT_CHANNELS); - if (!command) { - throw new Error(`No audio playback tool found.\n\n${getMissingToolsMessage()}`); - } - - logger.debug("voice-supervisor: starting audio playback", { command }); - const proc = ptree.cspawn(command, { - stdin: "pipe", - }); - const startedAt = Date.now(); - - this.playbackProcess = proc; - const stdin = proc.stdin; - if (!stdin) { - throw new Error("Audio playback stdin unavailable."); - } - if ("getWriter" in stdin && typeof stdin.getWriter === "function") { - const writer = (stdin as unknown as WritableStream).getWriter(); - this.playbackWriter = { - write: async (chunk) => { - await writer.write(chunk); - }, - close: async () => { - await writer.close(); - }, - }; - } else if ("write" in stdin && typeof (stdin as { write?: unknown }).write === "function") { - const sink = stdin as unknown as { - write: (chunk: Uint8Array) => undefined | number | Promise; - end?: () => undefined | number | Promise; - close?: () => undefined | number | Promise; - }; - this.playbackWriter = { - write: async (chunk) => { - await sink.write(chunk); - }, - close: async () => { - if (sink.end) { - await sink.end(); - } else if (sink.close) { - await sink.close(); - } - }, - }; - } else { - throw new Error("Audio playback stdin is not writable."); - } - - proc.exited - .then(() => { - const code = proc.exitCode; - if (this.playbackProcess === proc) { - this.playbackProcess = undefined; - this.playbackWriter = undefined; - } - const trimmed = proc.peekStderr().trim(); - if (trimmed) { - logger.debug("voice-supervisor: playback stderr", { stderr: trimmed }); - } - const elapsed = Date.now() - startedAt; - if (code !== 0 && elapsed < 2000 && this.active && code !== null) { - this.maybeWarnPlaybackFailure(trimmed || `exit code ${code}`); - } - }) - .catch(() => { - // ignore - }); - } - - private async resetPlayback(): Promise { - if (this.playbackWriter) { - try { - await this.playbackWriter.close(); - } catch { - // ignore - } - } - if (this.playbackProcess) { - try { - this.playbackProcess.kill(); - } catch { - // ignore - } - await this.playbackProcess.exited; - } - this.playbackProcess = undefined; - this.playbackWriter = undefined; - } - - private maybeWarnPlaybackFailure(message: string): void { - if (!this.callbacks.onWarning) return; - const now = Date.now(); - if (now - this.lastPlaybackErrorAt < PLAYBACK_ERROR_COOLDOWN_MS) return; - this.lastPlaybackErrorAt = now; - this.callbacks.onWarning(`Audio playback failed: ${message}`); - } - - private async waitForConnection(session: RealtimeSession, timeoutMs: number): Promise { - if (session.transport.status === "connected") { - this.connected = true; - return; - } - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("Realtime voice connection timed out.")); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - session.transport.off("connection_change", onChange); - }; - - const onChange = (status: string) => { - if (status === "connected") { - this.connected = true; - cleanup(); - resolve(); - } - }; - - session.transport.on("connection_change", onChange); - }); - } - - private async waitForSessionReady(session: RealtimeSession, timeoutMs: number): Promise { - if (this.sessionReady) return; - await new Promise((resolve, reject) => { - let resolved = false; - - const cleanup = () => { - clearTimeout(timeout); - session.off("transport_event", onEvent); - }; - - const timeout = setTimeout(() => { - if (resolved) return; - resolved = true; - cleanup(); - reject(new Error("Realtime voice session not ready.")); - }, timeoutMs); - - const onEvent = (event: TransportEvent) => { - if (resolved) return; - if (event.type === "session.created") { - this.sessionReady = true; - resolved = true; - cleanup(); - resolve(); - } - }; - - session.on("transport_event", onEvent); - }); - } -} diff --git a/packages/coding-agent/src/core/voice.ts b/packages/coding-agent/src/core/voice.ts deleted file mode 100644 index 895210cbc..000000000 --- a/packages/coding-agent/src/core/voice.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { unlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { completeSimple, type Model } from "@oh-my-pi/pi-ai"; -import { logger } from "@oh-my-pi/pi-utils"; -import { nanoid } from "nanoid"; -import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" }; -import type { ModelRegistry } from "./model-registry"; -import { findSmolModel } from "./model-resolver"; -import { renderPromptTemplate } from "./prompt-templates"; -import type { VoiceSettings } from "./settings-manager"; - -const DEFAULT_SAMPLE_RATE = 16000; -const DEFAULT_CHANNELS = 1; -const DEFAULT_BITS = 16; -const SUMMARY_MAX_CHARS = 6000; -const VOICE_SUMMARY_PROMPT = renderPromptTemplate(voiceSummaryPrompt); - -export interface VoiceRecordingHandle { - filePath: string; - stop: () => Promise; - cancel: () => Promise; - cleanup: () => void; -} - -export class VoiceRecording implements VoiceRecordingHandle { - readonly filePath: string; - private proc: ReturnType; - - constructor(_settings: VoiceSettings) { - const sampleRate = DEFAULT_SAMPLE_RATE; - const channels = DEFAULT_CHANNELS; - this.filePath = join(tmpdir(), `omp-voice-${nanoid()}.wav`); - const command = buildRecordingCommand(this.filePath, sampleRate, channels); - if (!command) { - throw new Error("No audio recorder found (install sox, arecord, or ffmpeg)."); - } - - logger.debug("voice: starting recorder", { command }); - this.proc = Bun.spawn(command, { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); - } - - async stop(): Promise { - try { - this.proc.kill(); - } catch { - // ignore - } - await this.proc.exited; - } - - cleanup(): void { - try { - unlinkSync(this.filePath); - } catch { - // ignore cleanup errors - } - } - - async cancel(): Promise { - await this.stop(); - this.cleanup(); - } -} - -export interface VoiceTranscriptionResult { - text: string; -} - -export interface VoiceSynthesisResult { - audio: Uint8Array; - format: "wav" | "mp3" | "opus" | "aac" | "flac"; -} - -function buildRecordingCommand(filePath: string, sampleRate: number, channels: number): string[] | null { - const soxPath = Bun.which("sox") ?? Bun.which("rec"); - if (soxPath) { - return [soxPath, "-d", "-r", String(sampleRate), "-c", String(channels), "-b", String(DEFAULT_BITS), filePath]; - } - - const arecordPath = Bun.which("arecord"); - if (arecordPath) { - return [arecordPath, "-f", "S16_LE", "-r", String(sampleRate), "-c", String(channels), filePath]; - } - - const ffmpegPath = Bun.which("ffmpeg"); - if (ffmpegPath) { - const platform = process.platform; - if (platform === "darwin") { - // avfoundation default input device; users can override by installing sox for reliability. - return [ - ffmpegPath, - "-f", - "avfoundation", - "-i", - ":0", - "-ac", - String(channels), - "-ar", - String(sampleRate), - "-y", - filePath, - ]; - } - if (platform === "linux") { - // alsa default input device (commonly "default"). - return [ - ffmpegPath, - "-f", - "alsa", - "-i", - "default", - "-ac", - String(channels), - "-ar", - String(sampleRate), - "-y", - filePath, - ]; - } - if (platform === "win32") { - // dshow default input device name varies; "audio=default" is a best-effort fallback. - return [ - ffmpegPath, - "-f", - "dshow", - "-i", - "audio=default", - "-ac", - String(channels), - "-ar", - String(sampleRate), - "-y", - filePath, - ]; - } - } - - return null; -} - -export async function transcribeAudio( - filePath: string, - apiKey: string, - settings: VoiceSettings, -): Promise { - const file = Bun.file(filePath); - const buffer = await file.arrayBuffer(); - const blob = new File([buffer], "speech.wav", { type: "audio/wav" }); - const form = new FormData(); - form.append("file", blob); - form.append("model", settings.transcriptionModel ?? "whisper-1"); - if (settings.transcriptionLanguage) { - form.append("language", settings.transcriptionLanguage); - } - - const response = await fetch("https://api.openai.com/v1/audio/transcriptions", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: form, - }); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`Whisper transcription failed: ${response.status} ${errText}`); - } - - const data = (await response.json()) as { text?: string }; - return { text: (data.text ?? "").trim() }; -} - -export async function synthesizeSpeech( - text: string, - apiKey: string, - settings: VoiceSettings, -): Promise { - const format = settings.ttsFormat ?? "wav"; - const response = await fetch("https://api.openai.com/v1/audio/speech", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: settings.ttsModel ?? "tts-1", - voice: settings.ttsVoice ?? "alloy", - format, - input: text, - }), - }); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`TTS synthesis failed: ${response.status} ${errText}`); - } - - const audio = new Uint8Array(await response.arrayBuffer()); - return { audio, format }; -} - -function getPlayerCommand(filePath: string, format: VoiceSynthesisResult["format"]): string[] | null { - const platform = process.platform; - if (platform === "darwin") { - const afplay = Bun.which("afplay"); - if (afplay) return [afplay, filePath]; - } - - if (platform === "linux") { - const paplay = Bun.which("paplay"); - if (paplay) return [paplay, filePath]; - const aplay = Bun.which("aplay"); - if (aplay) return [aplay, filePath]; - const ffplay = Bun.which("ffplay"); - if (ffplay) return [ffplay, "-autoexit", "-nodisp", filePath]; - const play = Bun.which("play"); - if (play) return [play, filePath]; - } - - if (platform === "win32") { - if (format !== "wav") { - return null; - } - const ps = Bun.which("powershell"); - if (ps) { - return [ - ps, - "-NoProfile", - "-Command", - `(New-Object Media.SoundPlayer '${filePath.replace(/'/g, "''")}').PlaySync()`, - ]; - } - } - - return null; -} - -export async function playAudio(audio: Uint8Array, format: VoiceSynthesisResult["format"]): Promise { - const filePath = join(tmpdir(), `omp-tts-${nanoid()}.${format}`); - await Bun.write(filePath, audio); - - const command = getPlayerCommand(filePath, format); - if (!command) { - throw new Error("No audio player available for playback."); - } - - const proc = Bun.spawn(command, { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); - await proc.exited; - - try { - unlinkSync(filePath); - } catch { - // ignore cleanup errors - } -} - -function extractTextFromResponse(response: { content: Array<{ type: string; text?: string }> }): string { - let text = ""; - for (const content of response.content) { - if (content.type === "text" && content.text) { - text += content.text; - } - } - return text.trim(); -} - -export async function summarizeForVoice( - text: string, - registry: ModelRegistry, - savedSmolModel?: string, -): Promise { - const model = await findSmolModel(registry, savedSmolModel); - if (!model) { - logger.debug("voice: no smol model found for summary"); - return null; - } - - const apiKey = await registry.getApiKey(model); - if (!apiKey) { - logger.debug("voice: no API key for summary model", { provider: model.provider, id: model.id }); - return null; - } - - const truncated = text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}...` : text; - const request = { - model: `${model.provider}/${model.id}`, - systemPrompt: VOICE_SUMMARY_PROMPT, - userMessage: `\n${truncated}\n`, - }; - logger.debug("voice: summary request", request); - - try { - const response = await completeSimple( - model as Model, - { - systemPrompt: request.systemPrompt, - messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }], - }, - { apiKey, maxTokens: 120 }, - ); - const summary = extractTextFromResponse(response); - return summary || null; - } catch (error) { - logger.debug("voice: summary error", { error: error instanceof Error ? error.message : String(error) }); - return null; - } -} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 03991f3c6..0be0fd439 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -201,8 +201,6 @@ export { type FindToolDetails, type FindToolOptions, formatSize, - GitTool, - type GitToolDetails, type GrepOperations, type GrepToolDetails, type GrepToolOptions, diff --git a/packages/coding-agent/src/lib/worktree/collapse.ts b/packages/coding-agent/src/lib/worktree/collapse.ts deleted file mode 100644 index 75f180ce2..000000000 --- a/packages/coding-agent/src/lib/worktree/collapse.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { nanoid } from "nanoid"; -import { WorktreeError, WorktreeErrorCode } from "./errors"; -import { git, gitWithInput } from "./git"; -import { find, remove, type Worktree } from "./operations"; - -export type CollapseStrategy = "simple" | "merge-base" | "rebase"; - -export interface CollapseOptions { - strategy?: CollapseStrategy; - keepSource?: boolean; -} - -export interface CollapseResult { - filesChanged: number; - insertions: number; - deletions: number; -} - -function diffStats(diff: string): CollapseResult { - let filesChanged = 0; - let insertions = 0; - let deletions = 0; - - for (const line of diff.split("\n")) { - if (line.startsWith("diff --git ")) { - filesChanged += 1; - continue; - } - if (line.startsWith("+++") || line.startsWith("---")) continue; - if (line.startsWith("+")) { - insertions += 1; - continue; - } - if (line.startsWith("-")) { - deletions += 1; - } - } - - return { filesChanged, insertions, deletions }; -} - -async function requireGitSuccess(result: { code: number; stderr: string }, message: string): Promise { - if (result.code !== 0) { - throw new WorktreeError( - message + (result.stderr ? `\n${result.stderr.trim()}` : ""), - WorktreeErrorCode.COLLAPSE_FAILED, - ); - } -} - -async function ensureHasChanges(result: { stdout: string }): Promise { - const diff = result.stdout; - if (!diff.trim()) { - throw new WorktreeError("No changes to collapse", WorktreeErrorCode.NO_CHANGES); - } - return diff; -} - -async function collapseSimple(src: Worktree): Promise { - await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes"); - return ensureHasChanges(await git(["diff", "HEAD"], src.path)); -} - -async function collapseMergeBase(src: Worktree, dst: Worktree): Promise { - await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes"); - - const baseResult = await git(["merge-base", "HEAD", dst.branch ?? "HEAD"], src.path); - if (baseResult.code !== 0) { - throw new WorktreeError("Could not find merge base", WorktreeErrorCode.COLLAPSE_FAILED); - } - - const base = baseResult.stdout.trim(); - if (!base) { - throw new WorktreeError("Could not find merge base", WorktreeErrorCode.COLLAPSE_FAILED); - } - - return ensureHasChanges(await git(["diff", base], src.path)); -} - -async function collapseRebase(src: Worktree, dst: Worktree): Promise { - await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes"); - - const stagedResult = await git(["diff", "--cached", "--name-only"], src.path); - if (!stagedResult.stdout.trim()) { - throw new WorktreeError("No changes to collapse", WorktreeErrorCode.NO_CHANGES); - } - - const headResult = await git(["rev-parse", "HEAD"], src.path); - if (headResult.code !== 0) { - throw new WorktreeError("Failed to resolve HEAD", WorktreeErrorCode.COLLAPSE_FAILED); - } - const originalHead = headResult.stdout.trim(); - const tempBranch = `wt-collapse-${nanoid()}`; - - await requireGitSuccess(await git(["checkout", "-b", tempBranch], src.path), "Failed to create temp branch"); - - const commitResult = await git(["commit", "--allow-empty-message", "-m", ""], src.path); - if (commitResult.code !== 0) { - await git(["checkout", originalHead], src.path); - await git(["branch", "-D", tempBranch], src.path); - throw new WorktreeError("Failed to commit changes", WorktreeErrorCode.COLLAPSE_FAILED); - } - - const rebaseResult = await git(["rebase", dst.branch ?? "HEAD"], src.path); - if (rebaseResult.code !== 0) { - await git(["rebase", "--abort"], src.path); - await git(["checkout", originalHead], src.path); - await git(["branch", "-D", tempBranch], src.path); - throw new WorktreeError( - `Rebase conflicts:${rebaseResult.stderr ? `\n${rebaseResult.stderr.trim()}` : ""}`, - WorktreeErrorCode.REBASE_CONFLICTS, - ); - } - - const diffResult = await git(["diff", `${dst.branch ?? "HEAD"}..HEAD`], src.path); - - await git(["checkout", originalHead], src.path); - await git(["branch", "-D", tempBranch], src.path); - - return ensureHasChanges(diffResult); -} - -async function applyDiff(diff: string, targetPath: string): Promise { - let result = await gitWithInput(["apply"], diff, targetPath); - if (result.code === 0) return; - - result = await gitWithInput(["apply", "--3way"], diff, targetPath); - if (result.code === 0) return; - - throw new WorktreeError( - `Failed to apply diff:${result.stderr ? `\n${result.stderr.trim()}` : ""}`, - WorktreeErrorCode.APPLY_FAILED, - ); -} - -/** - * Collapse changes from source worktree into destination. - */ -export async function collapse( - source: string, - destination: string, - options?: CollapseOptions, -): Promise { - const src = await find(source); - const dst = await find(destination); - - if (src.path === dst.path) { - throw new WorktreeError("Source and destination are the same", WorktreeErrorCode.COLLAPSE_FAILED); - } - - if (!options?.keepSource && src.isMain) { - throw new WorktreeError("Cannot remove main worktree", WorktreeErrorCode.CANNOT_MODIFY_MAIN); - } - - const strategy = options?.strategy ?? "rebase"; - let diff: string; - - switch (strategy) { - case "simple": - diff = await collapseSimple(src); - break; - case "merge-base": - diff = await collapseMergeBase(src, dst); - break; - case "rebase": - diff = await collapseRebase(src, dst); - break; - default: - throw new WorktreeError(`Unknown strategy: ${strategy}`, WorktreeErrorCode.COLLAPSE_FAILED); - } - - const stats = diffStats(diff); - await applyDiff(diff, dst.path); - - if (!options?.keepSource) { - await remove(src.path, { force: true }); - } - - return stats; -} diff --git a/packages/coding-agent/src/lib/worktree/constants.ts b/packages/coding-agent/src/lib/worktree/constants.ts deleted file mode 100644 index 99d0a78fa..000000000 --- a/packages/coding-agent/src/lib/worktree/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { accessSync, constants } from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -function getWorktreeBase(): string { - try { - accessSync("/work", constants.W_OK); - return "/work/.tree"; - } catch { - return path.join(os.tmpdir(), ".tree"); - } -} - -export const WORKTREE_BASE = getWorktreeBase(); diff --git a/packages/coding-agent/src/lib/worktree/errors.ts b/packages/coding-agent/src/lib/worktree/errors.ts deleted file mode 100644 index cad53a2d7..000000000 --- a/packages/coding-agent/src/lib/worktree/errors.ts +++ /dev/null @@ -1,23 +0,0 @@ -export enum WorktreeErrorCode { - NOT_GIT_REPO = "NOT_GIT_REPO", - WORKTREE_NOT_FOUND = "WORKTREE_NOT_FOUND", - WORKTREE_EXISTS = "WORKTREE_EXISTS", - CANNOT_MODIFY_MAIN = "CANNOT_MODIFY_MAIN", - NO_CHANGES = "NO_CHANGES", - COLLAPSE_FAILED = "COLLAPSE_FAILED", - REBASE_CONFLICTS = "REBASE_CONFLICTS", - APPLY_FAILED = "APPLY_FAILED", - OVERLAPPING_SCOPES = "OVERLAPPING_SCOPES", -} - -export class WorktreeError extends Error { - readonly code: WorktreeErrorCode; - readonly cause?: Error; - - constructor(message: string, code: WorktreeErrorCode, cause?: Error) { - super(message); - this.name = "WorktreeError"; - this.code = code; - this.cause = cause; - } -} diff --git a/packages/coding-agent/src/lib/worktree/git.ts b/packages/coding-agent/src/lib/worktree/git.ts deleted file mode 100644 index cf5fe027c..000000000 --- a/packages/coding-agent/src/lib/worktree/git.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as path from "node:path"; -import { ptree } from "@oh-my-pi/pi-utils"; -import { execCommand } from "../../core/exec"; -import { WorktreeError, WorktreeErrorCode } from "./errors"; - -export interface GitResult { - code: number; - stdout: string; - stderr: string; -} - -/** - * Execute a git command. - * @param args - Command arguments (excluding 'git') - * @param cwd - Working directory (optional) - * @returns Promise - */ -export async function git(args: string[], cwd?: string): Promise { - const result = await execCommand("git", args, cwd ?? process.cwd()); - return { code: result.code, stdout: result.stdout, stderr: result.stderr }; -} - -/** - * Execute git command with stdin input. - * Used for piping diffs to `git apply`. - */ -export async function gitWithInput(args: string[], stdin: string, cwd?: string): Promise { - const proc = ptree.cspawn(["git", ...args], { - cwd: cwd ?? process.cwd(), - stdin: Buffer.from(stdin), - }); - - const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]); - - return { code: proc.exitCode ?? 0, stdout, stderr }; -} - -/** - * Get repository root directory. - * @throws Error if not in a git repository - */ -export async function getRepoRoot(cwd?: string): Promise { - const result = await git(["rev-parse", "--show-toplevel"], cwd ?? process.cwd()); - if (result.code !== 0) { - throw new WorktreeError("Not a git repository", WorktreeErrorCode.NOT_GIT_REPO); - } - const root = result.stdout.trim(); - if (!root) { - throw new WorktreeError("Not a git repository", WorktreeErrorCode.NOT_GIT_REPO); - } - return path.resolve(root); -} - -/** - * Get repository name (directory basename of repo root). - */ -export async function getRepoName(cwd?: string): Promise { - const root = await getRepoRoot(cwd); - return path.basename(root); -} diff --git a/packages/coding-agent/src/lib/worktree/index.ts b/packages/coding-agent/src/lib/worktree/index.ts deleted file mode 100644 index d188c08c1..000000000 --- a/packages/coding-agent/src/lib/worktree/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { type CollapseOptions, type CollapseResult, type CollapseStrategy, collapse } from "./collapse"; -export { WORKTREE_BASE } from "./constants"; -export { WorktreeError, WorktreeErrorCode } from "./errors"; -export { getRepoName, getRepoRoot, git, gitWithInput as gitWithStdin } from "./git"; -export { create, find, list, prune, remove, type Worktree, which } from "./operations"; -export { - cleanupSessions, - createSession, - getSession, - listSessions, - type SessionStatus, - updateSession, - type WorktreeSession, -} from "./session"; -export { formatStats, getStats, type WorktreeStats } from "./stats"; diff --git a/packages/coding-agent/src/lib/worktree/operations.ts b/packages/coding-agent/src/lib/worktree/operations.ts deleted file mode 100644 index d5aa2cd40..000000000 --- a/packages/coding-agent/src/lib/worktree/operations.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { mkdir } from "node:fs/promises"; -import * as path from "node:path"; -import { WORKTREE_BASE } from "./constants"; -import { WorktreeError, WorktreeErrorCode } from "./errors"; -import { getRepoName, getRepoRoot, git } from "./git"; - -export interface Worktree { - path: string; - branch: string | null; - head: string; - isMain: boolean; - isDetached: boolean; -} - -type WorktreePartial = Partial & { isDetached?: boolean }; - -function finalizeWorktree(entry: WorktreePartial, repoRoot: string): Worktree { - const wtPath = entry.path?.trim(); - if (!wtPath) { - throw new Error("Invalid worktree entry"); - } - const branch = entry.isDetached ? null : (entry.branch ?? null); - const isDetached = entry.isDetached ?? branch === null; - return { - path: wtPath, - branch, - head: entry.head ?? "", - isMain: path.resolve(wtPath) === path.resolve(repoRoot), - isDetached, - }; -} - -function parseWorktreeList(output: string, repoRoot: string): Worktree[] { - const worktrees: Worktree[] = []; - let current: WorktreePartial = {}; - - for (const line of output.split("\n")) { - if (line.startsWith("worktree ")) { - if (current.path) { - worktrees.push(finalizeWorktree(current, repoRoot)); - } - current = { path: line.slice(9) }; - continue; - } - - if (line.startsWith("HEAD ")) { - current.head = line.slice(5); - continue; - } - - if (line.startsWith("branch ")) { - const raw = line.slice(7); - current.branch = raw.startsWith("refs/heads/") ? raw.slice("refs/heads/".length) : raw; - continue; - } - - if (line === "detached") { - current.isDetached = true; - } - } - - if (current.path) { - worktrees.push(finalizeWorktree(current, repoRoot)); - } - - return worktrees; -} - -/** - * Create a new worktree. - */ -export async function create(branch: string, options?: { base?: string; path?: string }): Promise { - const repoRoot = await getRepoRoot(); - const repoName = await getRepoName(); - const targetPath = options?.path ?? path.join(WORKTREE_BASE, repoName, branch); - const resolvedTarget = path.resolve(targetPath); - - const existing = await list(); - const conflict = existing.find((wt) => wt.branch === branch || path.resolve(wt.path) === resolvedTarget); - if (conflict) { - throw new WorktreeError(`Worktree already exists: ${conflict.path}`, WorktreeErrorCode.WORKTREE_EXISTS); - } - - await mkdir(path.dirname(resolvedTarget), { recursive: true }); - - const branchExists = (await git(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot)).code === 0; - - const args = branchExists - ? ["worktree", "add", resolvedTarget, branch] - : ["worktree", "add", "-b", branch, resolvedTarget, options?.base ?? "HEAD"]; - - const result = await git(args, repoRoot); - if (result.code !== 0) { - const stderr = result.stderr.trim(); - if (stderr.includes("already exists") || stderr.includes("already checked out")) { - throw new WorktreeError(stderr || "Worktree already exists", WorktreeErrorCode.WORKTREE_EXISTS); - } - throw new Error(stderr || "Failed to create worktree"); - } - - const updated = await list(); - const created = updated.find((wt) => path.resolve(wt.path) === resolvedTarget); - if (!created) { - throw new Error("Worktree created but not found in list"); - } - - return created; -} - -/** - * List all worktrees for current repository. - */ -export async function list(): Promise { - const repoRoot = await getRepoRoot(); - const result = await git(["worktree", "list", "--porcelain"], repoRoot); - if (result.code !== 0) { - throw new Error(result.stderr.trim() || "Failed to list worktrees"); - } - return parseWorktreeList(result.stdout, repoRoot); -} - -/** - * Find a worktree by pattern. - */ -export async function find(pattern: string): Promise { - const worktrees = await list(); - - const exactBranch = worktrees.filter((wt) => wt.branch === pattern); - if (exactBranch.length === 1) return exactBranch[0]; - if (exactBranch.length > 1) { - throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); - } - - const exactDir = worktrees.filter((wt) => path.basename(wt.path) === pattern); - if (exactDir.length === 1) return exactDir[0]; - if (exactDir.length > 1) { - throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); - } - - const partialBranch = worktrees.filter((wt) => wt.branch?.includes(pattern)); - if (partialBranch.length === 1) return partialBranch[0]; - if (partialBranch.length > 1) { - throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); - } - - const partialPath = worktrees.filter((wt) => wt.path.includes(pattern)); - if (partialPath.length === 1) return partialPath[0]; - if (partialPath.length > 1) { - throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); - } - - throw new WorktreeError(`Worktree not found: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); -} - -/** - * Remove a worktree. - */ -export async function remove(nameOrPath: string, options?: { force?: boolean }): Promise { - const wt = await find(nameOrPath); - if (wt.isMain) { - throw new WorktreeError("Cannot remove main worktree", WorktreeErrorCode.CANNOT_MODIFY_MAIN); - } - - const repoRoot = await getRepoRoot(); - const args = ["worktree", "remove", wt.path]; - if (options?.force) args.push("--force"); - - const result = await git(args, repoRoot); - if (result.code !== 0) { - throw new Error(result.stderr.trim() || "Failed to remove worktree"); - } -} - -/** - * Remove worktrees for branches that no longer exist. - */ -export async function prune(): Promise { - const repoRoot = await getRepoRoot(); - const worktrees = await list(); - let removed = 0; - - for (const wt of worktrees) { - if (wt.isMain || !wt.branch) continue; - const existsResult = await git(["rev-parse", "--verify", `refs/heads/${wt.branch}`], repoRoot); - if (existsResult.code === 0) continue; - - const result = await git(["worktree", "remove", wt.path], repoRoot); - if (result.code !== 0) { - throw new Error(result.stderr.trim() || `Failed to remove worktree: ${wt.path}`); - } - removed += 1; - } - - return removed; -} - -/** - * Get the worktree containing the given path. - * Returns null if path is not in any worktree. - */ -export async function which(targetPath?: string): Promise { - const worktrees = await list(); - const resolved = path.resolve(targetPath ?? process.cwd()); - - let best: Worktree | null = null; - for (const wt of worktrees) { - const wtPath = path.resolve(wt.path); - if (resolved === wtPath || resolved.startsWith(wtPath + path.sep)) { - if (!best || wtPath.length > best.path.length) { - best = wt; - } - } - } - - return best; -} diff --git a/packages/coding-agent/src/lib/worktree/session.ts b/packages/coding-agent/src/lib/worktree/session.ts deleted file mode 100644 index 804a92884..000000000 --- a/packages/coding-agent/src/lib/worktree/session.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { mkdir } from "node:fs/promises"; -import * as path from "node:path"; -import { nanoid } from "nanoid"; -import { getRepoRoot, git } from "./git"; - -export interface WorktreeSession { - id: string; - branch: string; - path: string; - scope?: string[]; - agentId?: string; - task?: string; - status: SessionStatus; - createdAt: number; - completedAt?: number; -} - -export type SessionStatus = "creating" | "active" | "completed" | "merging" | "merged" | "failed" | "abandoned"; - -async function getSessionsFile(): Promise { - const repoRoot = await getRepoRoot(); - const result = await git(["rev-parse", "--git-common-dir"], repoRoot); - let gitDir = result.code === 0 ? result.stdout.trim() : ""; - if (!gitDir) { - gitDir = path.join(repoRoot, ".git"); - } - if (!path.isAbsolute(gitDir)) { - // Resolve relative git dir from repo root to keep sessions in the common dir. - gitDir = path.resolve(repoRoot, gitDir); - } - await mkdir(gitDir, { recursive: true }); - return path.join(gitDir, "worktree-sessions.json"); -} - -async function loadSessions(): Promise { - const filePath = await getSessionsFile(); - const file = Bun.file(filePath); - if (!(await file.exists())) return []; - try { - const data = await file.json(); - if (Array.isArray(data)) { - return data as WorktreeSession[]; - } - } catch { - return []; - } - return []; -} - -async function saveSessions(sessions: WorktreeSession[]): Promise { - const filePath = await getSessionsFile(); - await Bun.write(filePath, JSON.stringify(sessions, null, 2)); -} - -export async function createSession(params: { - branch: string; - path: string; - scope?: string[]; - task?: string; -}): Promise { - const sessions = await loadSessions(); - const session: WorktreeSession = { - id: nanoid(10), - branch: params.branch, - path: params.path, - scope: params.scope, - task: params.task, - status: "creating", - createdAt: Date.now(), - }; - - sessions.push(session); - await saveSessions(sessions); - return session; -} - -export async function updateSession(id: string, updates: Partial): Promise { - const sessions = await loadSessions(); - const idx = sessions.findIndex((s) => s.id === id); - if (idx === -1) return; - const current = sessions[idx]; - sessions[idx] = { ...current, ...updates, id: current.id }; - await saveSessions(sessions); -} - -export async function getSession(id: string): Promise { - const sessions = await loadSessions(); - return sessions.find((s) => s.id === id) ?? null; -} - -export async function listSessions(): Promise { - return loadSessions(); -} - -export async function cleanupSessions(): Promise { - const sessions = await loadSessions(); - let removed = 0; - - const remaining: WorktreeSession[] = []; - for (const session of sessions) { - const exists = await Bun.file(session.path).exists(); - if (!exists) { - removed += 1; - continue; - } - remaining.push(session); - } - - if (removed > 0) { - await saveSessions(remaining); - } - - return removed; -} diff --git a/packages/coding-agent/src/lib/worktree/stats.ts b/packages/coding-agent/src/lib/worktree/stats.ts deleted file mode 100644 index dc94cfc45..000000000 --- a/packages/coding-agent/src/lib/worktree/stats.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { git } from "./git"; - -export interface WorktreeStats { - additions: number; - deletions: number; - untracked: number; - modified: number; - staged: number; -} - -/** - * Get diff statistics for a worktree. - */ -export async function getStats(worktreePath: string): Promise { - const diffResult = await git(["diff", "HEAD", "--shortstat"], worktreePath); - - let additions = 0; - let deletions = 0; - - const statsLine = diffResult.stdout.trim(); - if (statsLine) { - const insertMatch = statsLine.match(/(\d+) insertion/); - const deleteMatch = statsLine.match(/(\d+) deletion/); - if (insertMatch) additions = parseInt(insertMatch[1], 10); - if (deleteMatch) deletions = parseInt(deleteMatch[1], 10); - } - - const untrackedResult = await git(["ls-files", "--others", "--exclude-standard"], worktreePath); - const untracked = untrackedResult.stdout.trim() ? untrackedResult.stdout.trim().split("\n").length : 0; - - const statusResult = await git(["status", "--porcelain"], worktreePath); - let modified = 0; - let staged = 0; - - for (const line of statusResult.stdout.split("\n")) { - if (!line) continue; - const index = line[0]; - const worktree = line[1]; - if (index !== " " && index !== "?") staged += 1; - if (worktree !== " " && worktree !== "?") modified += 1; - } - - return { additions, deletions, untracked, modified, staged }; -} - -/** - * Format stats for display. - * Returns "clean" or "+N -M ?U" format. - */ -export function formatStats(stats: WorktreeStats): string { - if ( - stats.additions === 0 && - stats.deletions === 0 && - stats.untracked === 0 && - stats.modified === 0 && - stats.staged === 0 - ) { - return "clean"; - } - - const parts: string[] = []; - if (stats.additions > 0) parts.push(`+${stats.additions}`); - if (stats.deletions > 0) parts.push(`-${stats.deletions}`); - if (stats.untracked > 0) parts.push(`?${stats.untracked}`); - - return parts.join(" ") || "clean"; -} diff --git a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts index 92618f40a..97ab8f50d 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts @@ -83,7 +83,6 @@ const THINKING_DESCRIPTIONS: Record = { * - behavior: Core agent behavior (compaction, modes, retries, notifications) * - tools: Tool-specific settings (bash, git, python, edit, MCP, skills) * - display: Visual/UI settings (theme, images, thinking) - * - voice: Voice mode and TTS settings * - ttsr: Time Traveling Stream Rules settings * - status: Status line configuration * - lsp: LSP integration settings @@ -250,15 +249,6 @@ export const SETTINGS_DEFS: SettingDef[] = [ get: (sm) => sm.getBashInterceptorSimpleLsEnabled(), set: (sm, v) => sm.setBashInterceptorSimpleLsEnabled(v), }, - { - id: "gitTool", - tab: "tools", - type: "boolean", - label: "Git tool", - description: "Enable structured Git tool", - get: (sm) => sm.getGitToolEnabled(), - set: (sm, v) => sm.setGitToolEnabled(v), - }, { id: "pythonToolMode", tab: "tools", @@ -494,66 +484,6 @@ export const SETTINGS_DEFS: SettingDef[] = [ set: (sm, v) => sm.setShowHardwareCursor(v), }, - // ═══════════════════════════════════════════════════════════════════════════ - // Voice tab - Voice mode and TTS settings - // ═══════════════════════════════════════════════════════════════════════════ - { - id: "voiceEnabled", - tab: "voice", - type: "boolean", - label: "Voice mode", - description: "Enable realtime voice input/output (Ctrl+Y toggle, auto-send on silence)", - get: (sm) => sm.getVoiceEnabled(), - set: (sm, v) => sm.setVoiceEnabled(v), - }, - { - id: "voiceTtsModel", - tab: "voice", - type: "submenu", - label: "TTS model", - description: "Text-to-speech model for voice output", - get: (sm) => sm.getVoiceTtsModel(), - set: (sm, v) => sm.setVoiceTtsModel(v), - getOptions: () => [ - { value: "gpt-4o-mini-tts", label: "GPT-4o Mini TTS", description: "Fast and efficient" }, - { value: "tts-1", label: "TTS-1", description: "Standard quality" }, - { value: "tts-1-hd", label: "TTS-1 HD", description: "Higher quality" }, - ], - }, - { - id: "voiceTtsVoice", - tab: "voice", - type: "submenu", - label: "TTS voice", - description: "Voice for text-to-speech output", - get: (sm) => sm.getVoiceTtsVoice(), - set: (sm, v) => sm.setVoiceTtsVoice(v), - getOptions: () => [ - { value: "alloy", label: "Alloy", description: "Neutral" }, - { value: "echo", label: "Echo", description: "Male" }, - { value: "fable", label: "Fable", description: "British" }, - { value: "onyx", label: "Onyx", description: "Deep male" }, - { value: "nova", label: "Nova", description: "Female" }, - { value: "shimmer", label: "Shimmer", description: "Female" }, - ], - }, - { - id: "voiceTtsFormat", - tab: "voice", - type: "submenu", - label: "TTS format", - description: "Audio format for voice output", - get: (sm) => sm.getVoiceTtsFormat(), - set: (sm, v) => sm.setVoiceTtsFormat(v as "wav" | "mp3" | "opus" | "aac" | "flac"), - getOptions: () => [ - { value: "wav", label: "WAV", description: "Uncompressed, best quality" }, - { value: "mp3", label: "MP3", description: "Compressed, widely compatible" }, - { value: "opus", label: "Opus", description: "Efficient compression" }, - { value: "aac", label: "AAC", description: "Apple-friendly" }, - { value: "flac", label: "FLAC", description: "Lossless compression" }, - ], - }, - // ═══════════════════════════════════════════════════════════════════════════ // TTSR tab - Time Traveling Stream Rules // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index d249356fa..9ce08b340 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -117,7 +117,6 @@ const SETTINGS_TABS: Tab[] = [ { id: "behavior", label: "Behavior" }, { id: "tools", label: "Tools" }, { id: "display", label: "Display" }, - { id: "voice", label: "Voice" }, { id: "ttsr", label: "TTSR" }, { id: "status", label: "Status" }, { id: "lsp", label: "LSP" }, diff --git a/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts index fb582f8dc..524ebbf21 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/event-controller.ts @@ -68,7 +68,6 @@ export class EventController { getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(this.ctx.loadingAnimation); - this.ctx.startVoiceProgressTimer(); this.ctx.ui.requestRender(); break; @@ -250,7 +249,6 @@ export class EventController { } case "agent_end": - this.ctx.stopVoiceProgressTimer(); if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; @@ -262,15 +260,6 @@ export class EventController { this.ctx.streamingMessage = undefined; } this.ctx.pendingTools.clear(); - if (this.ctx.settingsManager.getVoiceEnabled() && this.ctx.voiceAutoModeEnabled) { - const lastAssistant = this.ctx.findLastAssistantMessage(); - if (lastAssistant && lastAssistant.stopReason !== "aborted" && lastAssistant.stopReason !== "error") { - const text = this.ctx.extractAssistantText(lastAssistant); - if (text) { - this.ctx.voiceSupervisor.notifyResult(text); - } - } - } this.ctx.ui.requestRender(); this.sendCompletionNotification(); break; diff --git a/packages/coding-agent/src/modes/interactive/controllers/selector-controller.ts b/packages/coding-agent/src/modes/interactive/controllers/selector-controller.ts index aeda119b2..ba193d4fd 100644 --- a/packages/coding-agent/src/modes/interactive/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/interactive/controllers/selector-controller.ts @@ -199,15 +199,6 @@ export class SelectorController { this.ctx.ui.invalidate(); break; } - case "voiceEnabled": { - if (!value) { - this.ctx.voiceAutoModeEnabled = false; - this.ctx.stopVoiceProgressTimer(); - void this.ctx.voiceSupervisor.stop(); - this.ctx.setVoiceStatus(undefined); - } - break; - } case "statusLinePreset": case "statusLineSeparator": case "statusLineShowHooks": diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f7cbf110e..0d58d4bfd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -28,7 +28,6 @@ import { getRecentSessions } from "../../core/session-manager"; import type { SettingsManager } from "../../core/settings-manager"; import { loadSlashCommands } from "../../core/slash-commands"; import { setTerminalTitle } from "../../core/title-generator"; -import { VoiceSupervisor } from "../../core/voice-supervisor"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import { CustomEditor } from "./components/custom-editor"; @@ -48,7 +47,6 @@ import type { Theme } from "./theme/theme"; import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme"; import type { CompactionQueuedMessage, InteractiveModeContext, TodoItem } from "./types"; import { UiHelpers } from "./utils/ui-helpers"; -import { VoiceManager } from "./utils/voice-manager"; const TODO_FILE_NAME = "todos.json"; @@ -72,7 +70,6 @@ export class InteractiveMode implements InteractiveModeContext { public settingsManager: SettingsManager; public keybindings: KeybindingsManager; public agent: AgentSession["agent"]; - public voiceSupervisor: VoiceSupervisor; public historyStorage?: HistoryStorage; public ui: TUI; @@ -107,13 +104,8 @@ export class InteractiveMode implements InteractiveModeContext { public onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void; public lastSigintTime = 0; public lastEscapeTime = 0; - public lastVoiceInterruptAt = 0; - public voiceAutoModeEnabled = false; public shutdownRequested = false; private isShuttingDown = false; - public voiceProgressTimer: ReturnType | undefined = undefined; - public voiceProgressSpoken = false; - public voiceProgressLastLength = 0; public hookSelector: HookSelectorComponent | undefined = undefined; public hookInput: HookInputComponent | undefined = undefined; public hookEditor: HookEditorComponent | undefined = undefined; @@ -137,7 +129,6 @@ export class InteractiveMode implements InteractiveModeContext { private readonly inputController: InputController; private readonly selectorController: SelectorController; private readonly uiHelpers: UiHelpers; - private readonly voiceManager: VoiceManager; constructor( session: AgentSession, @@ -180,26 +171,6 @@ export class InteractiveMode implements InteractiveModeContext { this.editorContainer.addChild(this.editor); this.statusLine = new StatusLineComponent(session); this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled); - this.voiceSupervisor = new VoiceSupervisor(this.session.modelRegistry, { - onSendToAgent: async (text) => { - await this.submitVoiceText(text); - }, - onInterruptAgent: async (reason) => { - await this.handleVoiceInterrupt(reason); - }, - onStatus: (status) => { - this.setVoiceStatus(status); - }, - onError: (error) => { - this.showError(error.message); - this.voiceAutoModeEnabled = false; - void this.voiceSupervisor.stop(); - this.setVoiceStatus(undefined); - }, - onWarning: (message) => { - this.showWarning(message); - }, - }); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); @@ -255,7 +226,6 @@ export class InteractiveMode implements InteractiveModeContext { this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands, ...skillCommandList]; this.uiHelpers = new UiHelpers(this); - this.voiceManager = new VoiceManager(this); this.extensionUiController = new ExtensionUiController(this); this.eventController = new EventController(this); this.commandController = new CommandController(this); @@ -511,9 +481,6 @@ export class InteractiveMode implements InteractiveModeContext { if (this.isShuttingDown) return; this.isShuttingDown = true; - this.voiceAutoModeEnabled = false; - await this.voiceSupervisor.stop(); - // Flush pending session writes before shutdown await this.sessionManager.flush(); @@ -783,31 +750,6 @@ export class InteractiveMode implements InteractiveModeContext { this.inputController.registerExtensionShortcuts(); } - // Voice handling - setVoiceStatus(text: string | undefined): void { - this.voiceManager.setVoiceStatus(text); - } - - handleVoiceInterrupt(reason?: string): Promise { - return this.voiceManager.handleVoiceInterrupt(reason); - } - - startVoiceProgressTimer(): void { - this.voiceManager.startVoiceProgressTimer(); - } - - stopVoiceProgressTimer(): void { - this.voiceManager.stopVoiceProgressTimer(); - } - - maybeSpeakProgress(): Promise { - return this.voiceManager.maybeSpeakProgress(); - } - - submitVoiceText(text: string): Promise { - return this.voiceManager.submitVoiceText(text); - } - // Hook UI methods initHooksAndCustomTools(): Promise { return this.extensionUiController.initHooksAndCustomTools(); diff --git a/packages/coding-agent/src/modes/interactive/types.ts b/packages/coding-agent/src/modes/interactive/types.ts index 133f1bebf..496c1c5bf 100644 --- a/packages/coding-agent/src/modes/interactive/types.ts +++ b/packages/coding-agent/src/modes/interactive/types.ts @@ -9,7 +9,6 @@ import type { KeybindingsManager } from "../../core/keybindings"; import type { MCPManager } from "../../core/mcp/index"; import type { SessionContext, SessionManager } from "../../core/session-manager"; import type { SettingsManager } from "../../core/settings-manager"; -import type { VoiceSupervisor } from "../../core/voice-supervisor"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import type { CustomEditor } from "./components/custom-editor"; @@ -49,7 +48,6 @@ export interface InteractiveModeContext { settingsManager: SettingsManager; keybindings: KeybindingsManager; agent: AgentSession["agent"]; - voiceSupervisor: VoiceSupervisor; historyStorage?: HistoryStorage; mcpManager?: MCPManager; lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>; @@ -77,12 +75,7 @@ export interface InteractiveModeContext { onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void; lastSigintTime: number; lastEscapeTime: number; - lastVoiceInterruptAt: number; - voiceAutoModeEnabled: boolean; shutdownRequested: boolean; - voiceProgressTimer: ReturnType | undefined; - voiceProgressSpoken: boolean; - voiceProgressLastLength: number; hookSelector: HookSelectorComponent | undefined; hookInput: HookInputComponent | undefined; hookEditor: HookEditorComponent | undefined; @@ -174,14 +167,6 @@ export interface InteractiveModeContext { openExternalEditor(): void; registerExtensionShortcuts(): void; - // Voice handling - setVoiceStatus(text: string | undefined): void; - handleVoiceInterrupt(reason?: string): Promise; - startVoiceProgressTimer(): void; - stopVoiceProgressTimer(): void; - maybeSpeakProgress(): Promise; - submitVoiceText(text: string): Promise; - // Hook UI methods initHooksAndCustomTools(): Promise; emitCustomToolSessionEvent( diff --git a/packages/coding-agent/src/modes/interactive/utils/voice-manager.ts b/packages/coding-agent/src/modes/interactive/utils/voice-manager.ts deleted file mode 100644 index a0330a70e..000000000 --- a/packages/coding-agent/src/modes/interactive/utils/voice-manager.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { InteractiveModeContext } from "../types"; - -const VOICE_PROGRESS_DELAY_MS = 15000; -const VOICE_PROGRESS_MIN_CHARS = 160; -const VOICE_PROGRESS_DELTA_CHARS = 120; - -export class VoiceManager { - constructor(private ctx: InteractiveModeContext) {} - - setVoiceStatus(text: string | undefined): void { - this.ctx.statusLine.setHookStatus("voice", text); - this.ctx.ui.requestRender(); - } - - async handleVoiceInterrupt(reason?: string): Promise { - const now = Date.now(); - if (now - this.ctx.lastVoiceInterruptAt < 200) return; - this.ctx.lastVoiceInterruptAt = now; - if (this.ctx.session.isBashRunning) { - this.ctx.session.abortBash(); - } - if (this.ctx.session.isStreaming) { - await this.ctx.session.abort(); - } - if (reason) { - this.ctx.showStatus(reason); - } - } - - stopVoiceProgressTimer(): void { - if (this.ctx.voiceProgressTimer) { - clearTimeout(this.ctx.voiceProgressTimer); - this.ctx.voiceProgressTimer = undefined; - } - } - - startVoiceProgressTimer(): void { - this.stopVoiceProgressTimer(); - if (!this.ctx.settingsManager.getVoiceEnabled() || !this.ctx.voiceAutoModeEnabled) return; - this.ctx.voiceProgressSpoken = false; - this.ctx.voiceProgressLastLength = 0; - this.ctx.voiceProgressTimer = setTimeout(() => { - void this.maybeSpeakProgress(); - }, VOICE_PROGRESS_DELAY_MS); - } - - async maybeSpeakProgress(): Promise { - if (!this.ctx.session.isStreaming || this.ctx.voiceProgressSpoken || !this.ctx.voiceAutoModeEnabled) return; - const streaming = this.ctx.streamingMessage; - if (!streaming) return; - const text = this.ctx.extractAssistantText(streaming); - if (!text || text.length < VOICE_PROGRESS_MIN_CHARS) { - if (this.ctx.session.isStreaming) { - this.ctx.voiceProgressTimer = setTimeout(() => { - void this.maybeSpeakProgress(); - }, VOICE_PROGRESS_DELAY_MS); - } - return; - } - - const delta = text.length - this.ctx.voiceProgressLastLength; - if (delta < VOICE_PROGRESS_DELTA_CHARS) { - if (this.ctx.session.isStreaming) { - this.ctx.voiceProgressTimer = setTimeout(() => { - void this.maybeSpeakProgress(); - }, VOICE_PROGRESS_DELAY_MS); - } - return; - } - - this.ctx.voiceProgressLastLength = text.length; - this.ctx.voiceProgressSpoken = true; - this.ctx.voiceSupervisor.notifyProgress(text); - } - - async submitVoiceText(text: string): Promise { - const cleaned = text.trim(); - if (!cleaned) { - this.ctx.showWarning("No speech detected. Try again."); - return; - } - const toSend = cleaned; - this.ctx.editor.addToHistory(toSend); - - if (this.ctx.session.isStreaming) { - await this.ctx.session.abort(); - await this.ctx.session.steer(toSend); - this.ctx.updatePendingMessagesDisplay(); - return; - } - - if (this.ctx.onInputCallback) { - this.ctx.onInputCallback({ text: toSend }); - } - } -} diff --git a/packages/coding-agent/src/prompts/tools/git.md b/packages/coding-agent/src/prompts/tools/git.md deleted file mode 100644 index 0bb2972d8..000000000 --- a/packages/coding-agent/src/prompts/tools/git.md +++ /dev/null @@ -1,9 +0,0 @@ -Structured Git operations with safety guards and typed output. Use this tool instead of raw git commands. - -Operations: -- READ: status, diff, log, show, blame, branch -- WRITE: add, restore, commit, checkout, merge, rebase, stash, cherry-pick -- REMOTE: fetch, pull, push, tag -- GITHUB: pr, issue, ci, release - -Returns structured data plus a rendered summary for display. Safety checks may block or require confirmation for destructive actions. diff --git a/packages/coding-agent/src/prompts/voice-summary.md b/packages/coding-agent/src/prompts/voice-summary.md deleted file mode 100644 index b3cf298d2..000000000 --- a/packages/coding-agent/src/prompts/voice-summary.md +++ /dev/null @@ -1,12 +0,0 @@ -You are a voice summarizer for a coding agent. - -Summarize the assistant response for spoken playback. - -Rules: -- Output 1 to 3 sentences, maximum 50 words. -- No markdown, no code blocks, no inline code, no URLs. -- If the response already fits, return it unchanged. -- Preserve the most important question if one is asked. -- If a decision or missing info is required, ask one short question. - -Return only the spoken text. diff --git a/packages/coding-agent/test/core/python-prelude.test.ts b/packages/coding-agent/test/core/python-prelude.test.ts index 69e010ec7..18a5bcb6f 100644 --- a/packages/coding-agent/test/core/python-prelude.test.ts +++ b/packages/coding-agent/test/core/python-prelude.test.ts @@ -93,7 +93,6 @@ describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => { getLspDiagnosticsOnWrite: () => false, getLspDiagnosticsOnEdit: () => false, getEditFuzzyMatch: () => true, - getGitToolEnabled: () => true, getBashInterceptorEnabled: () => true, getBashInterceptorSimpleLsEnabled: () => true, getBashInterceptorRules: () => [], diff --git a/packages/coding-agent/test/python-tool-settings.test.ts b/packages/coding-agent/test/python-tool-settings.test.ts index a19cd9074..5348e65da 100644 --- a/packages/coding-agent/test/python-tool-settings.test.ts +++ b/packages/coding-agent/test/python-tool-settings.test.ts @@ -14,7 +14,6 @@ function createSettings(overrides?: Partial): ToolSessi getLspDiagnosticsOnWrite: () => true, getLspDiagnosticsOnEdit: () => false, getEditFuzzyMatch: () => true, - getGitToolEnabled: () => false, getBashInterceptorEnabled: () => false, getBashInterceptorSimpleLsEnabled: () => true, getBashInterceptorRules: () => [], diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index 9a04cf517..7ffc7309e 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -20,7 +20,6 @@ function createBaseSettings(overrides: Partial true, getLspDiagnosticsOnEdit: () => false, getEditFuzzyMatch: () => true, - getGitToolEnabled: () => true, getBashInterceptorEnabled: () => true, getBashInterceptorSimpleLsEnabled: () => true, getBashInterceptorRules: () => [], @@ -137,34 +136,6 @@ describe("createTools", () => { expect(names).toContain("ask"); }); - it("excludes git tool when disabled in settings", async () => { - const session = createTestSession({ - settings: createBaseSettings({ getGitToolEnabled: () => false }), - }); - const tools = await createTools(session); - const names = tools.map((t) => t.name); - - expect(names).not.toContain("git"); - }); - - it("includes git tool when enabled in settings", async () => { - const session = createTestSession({ - settings: createBaseSettings({ getGitToolEnabled: () => true }), - }); - const tools = await createTools(session); - const names = tools.map((t) => t.name); - - expect(names).toContain("git"); - }); - - it("includes git tool when no settings provided (default enabled)", async () => { - const session = createTestSession({ settings: undefined }); - const tools = await createTools(session); - const names = tools.map((t) => t.name); - - expect(names).toContain("git"); - }); - it("always includes output tool when task tool is present", async () => { const session = createTestSession(); const tools = await createTools(session); diff --git a/packages/coding-agent/test/tools/python-execution.test.ts b/packages/coding-agent/test/tools/python-execution.test.ts index 344602fe1..a61cdf72e 100644 --- a/packages/coding-agent/test/tools/python-execution.test.ts +++ b/packages/coding-agent/test/tools/python-execution.test.ts @@ -16,7 +16,6 @@ function createSession(cwd: string): ToolSession { getLspDiagnosticsOnWrite: () => true, getLspDiagnosticsOnEdit: () => false, getEditFuzzyMatch: () => true, - getGitToolEnabled: () => true, getBashInterceptorEnabled: () => true, getBashInterceptorSimpleLsEnabled: () => true, getBashInterceptorRules: () => [], diff --git a/packages/coding-agent/test/tools/python-fallback.test.ts b/packages/coding-agent/test/tools/python-fallback.test.ts index d0e5ad838..db2b533a2 100644 --- a/packages/coding-agent/test/tools/python-fallback.test.ts +++ b/packages/coding-agent/test/tools/python-fallback.test.ts @@ -20,7 +20,6 @@ function createBaseSettings(overrides: Partial true, getLspDiagnosticsOnEdit: () => false, getEditFuzzyMatch: () => true, - getGitToolEnabled: () => true, getBashInterceptorEnabled: () => true, getBashInterceptorSimpleLsEnabled: () => true, getBashInterceptorRules: () => [], diff --git a/packages/coding-agent/test/tools/python-tool-mode.test.ts b/packages/coding-agent/test/tools/python-tool-mode.test.ts index 3c23ea5b9..30c613317 100644 --- a/packages/coding-agent/test/tools/python-tool-mode.test.ts +++ b/packages/coding-agent/test/tools/python-tool-mode.test.ts @@ -13,7 +13,6 @@ function createSession(overrides: Partial = {}): ToolSession { getLspDiagnosticsOnWrite: () => true, getLspDiagnosticsOnEdit: () => false, getEditFuzzyMatch: () => true, - getGitToolEnabled: () => true, getBashInterceptorEnabled: () => true, getBashInterceptorSimpleLsEnabled: () => true, getBashInterceptorRules: () => [], diff --git a/packages/coding-agent/test/tools/python.test.ts b/packages/coding-agent/test/tools/python.test.ts index a9581b432..39e24a9ad 100644 --- a/packages/coding-agent/test/tools/python.test.ts +++ b/packages/coding-agent/test/tools/python.test.ts @@ -38,7 +38,6 @@ function createSettings(toolMode: "ipy-only" | "bash-only" | "both") { getLspDiagnosticsOnWrite: () => true, getLspDiagnosticsOnEdit: () => false, getEditFuzzyMatch: () => true, - getGitToolEnabled: () => true, getBashInterceptorEnabled: () => true, getBashInterceptorSimpleLsEnabled: () => true, getBashInterceptorRules: () => [], diff --git a/packages/coding-agent/test/worktree/collapse.test.ts b/packages/coding-agent/test/worktree/collapse.test.ts deleted file mode 100644 index e698c3c02..000000000 --- a/packages/coding-agent/test/worktree/collapse.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { rm } from "node:fs/promises"; -import * as path from "node:path"; -import { type AsyncTempDir, createTempDir } from "@oh-my-pi/pi-utils"; -import { collapse } from "../../src/lib/worktree/collapse"; -import { WORKTREE_BASE } from "../../src/lib/worktree/constants"; -import { getRepoRoot, git } from "../../src/lib/worktree/git"; -import { create } from "../../src/lib/worktree/operations"; - -let repoPath: AsyncTempDir; -let originalCwd: string; - -async function createTestRepo(): Promise { - const tempDir = await createTempDir("@wt-test-"); - await git(["init", "-b", "main"], tempDir.path); - await git(["config", "user.email", "test@example.com"], tempDir.path); - await git(["config", "user.name", "Test User"], tempDir.path); - await Bun.write(path.join(tempDir.path, "README.md"), "init"); - await git(["add", "README.md"], tempDir.path); - await git(["commit", "-m", "init"], tempDir.path); - return tempDir; -} - -async function cleanupRepo(repoRoot: AsyncTempDir): Promise { - const repoName = path.basename(repoRoot.path); - await rm(path.join(WORKTREE_BASE, repoName), { recursive: true, force: true }); - await repoRoot.remove(); -} - -describe("collapse strategies", () => { - beforeEach(async () => { - originalCwd = process.cwd(); - repoPath = await createTestRepo(); - process.chdir(repoPath.path); - }); - - afterEach(async () => { - process.chdir(originalCwd); - await cleanupRepo(repoPath); - }); - - test("simple strategy applies uncommitted changes", async () => { - const src = await create("source"); - - await Bun.write(path.join(src.path, "new-file.txt"), "content"); - - await collapse("source", "main", { strategy: "simple" }); - - const mainPath = await getRepoRoot(); - const content = await Bun.file(path.join(mainPath, "new-file.txt")).text(); - expect(content).toBe("content"); - }); - - test("rebase strategy handles divergent history", async () => { - const src = await create("source"); - - // Make a commit on main to create divergent history - const mainPath = await getRepoRoot(); - await Bun.write(path.join(mainPath, "main-change.txt"), "main"); - await git(["add", "main-change.txt"], mainPath); - await git(["commit", "-m", "main change"], mainPath); - - // Add uncommitted changes on source (collapseRebase stages and commits these) - await Bun.write(path.join(src.path, "feature.txt"), "feature"); - - await collapse("source", "main", { strategy: "rebase" }); - - expect(await Bun.file(path.join(mainPath, "feature.txt")).exists()).toBe(true); - expect(await Bun.file(path.join(mainPath, "main-change.txt")).exists()).toBe(true); - }); -}); diff --git a/packages/coding-agent/test/worktree/operations.test.ts b/packages/coding-agent/test/worktree/operations.test.ts deleted file mode 100644 index d49768666..000000000 --- a/packages/coding-agent/test/worktree/operations.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; -import * as path from "node:path"; -import { type AsyncTempDir, createTempDir } from "@oh-my-pi/pi-utils"; -import { WorktreeError } from "../../src/lib/worktree/errors"; -import { git } from "../../src/lib/worktree/git"; -import { create, find, list, remove } from "../../src/lib/worktree/operations"; - -let repoPath: AsyncTempDir; -let originalCwd: string; - -async function createTestRepo(): Promise { - const tempDir = await createTempDir("@omp-wt-test-"); - await git(["init", "-b", "main"], tempDir.path); - await git(["config", "user.email", "test@example.com"], tempDir.path); - await git(["config", "user.name", "Test User"], tempDir.path); - await Bun.write(path.join(tempDir.path, "README.md"), "init"); - await git(["add", "README.md"], tempDir.path); - await git(["commit", "-m", "init"], tempDir.path); - return tempDir; -} - -describe("worktree operations", () => { - beforeEach(async () => { - originalCwd = process.cwd(); - repoPath = await createTestRepo(); - process.chdir(repoPath.path); - }); - - afterEach(async () => { - process.chdir(originalCwd); - await repoPath.remove(); - }); - - test("create worktree with new branch", async () => { - const wt = await create("feature-test"); - expect(wt.branch).toBe("feature-test"); - expect(existsSync(wt.path)).toBe(true); - }); - - test("create worktree with existing branch", async () => { - await git(["branch", "existing-branch"], repoPath.path); - const wt = await create("existing-branch"); - expect(wt.branch).toBe("existing-branch"); - }); - - test("list worktrees", async () => { - await create("feature-1"); - await create("feature-2"); - const worktrees = await list(); - expect(worktrees.length).toBe(3); - }); - - test("find worktree by branch", async () => { - await create("my-feature"); - const wt = await find("my-feature"); - expect(wt.branch).toBe("my-feature"); - }); - - test("find worktree by partial match", async () => { - await create("feature-authentication"); - const wt = await find("auth"); - expect(wt.branch).toBe("feature-authentication"); - }); - - test("remove worktree", async () => { - const wt = await create("to-remove"); - await remove("to-remove"); - expect(existsSync(wt.path)).toBe(false); - }); - - test("cannot remove main worktree", async () => { - await expect(remove("main")).rejects.toThrow(WorktreeError); - }); -}); diff --git a/packages/git-tool/CHANGELOG.md b/packages/git-tool/CHANGELOG.md deleted file mode 100644 index 51a177f49..000000000 --- a/packages/git-tool/CHANGELOG.md +++ /dev/null @@ -1,17 +0,0 @@ -# Changelog - -## [Unreleased] - -## [3.20.0] - 2026-01-06 - -### Breaking Changes - -### Added - -- Added structured git tool with safety guards, caching, and GitHub operations - -### Changed - -### Fixed - -### Removed diff --git a/packages/git-tool/package.json b/packages/git-tool/package.json deleted file mode 100644 index 962d95140..000000000 --- a/packages/git-tool/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@oh-my-pi/pi-git-tool", - "version": "6.8.5", - "description": "Structured Git tool with safety guards and typed output", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" - } - }, - "files": [ - "src", - "CHANGELOG.md" - ], - "scripts": { - "check": "tsgo --noEmit", - "build": "tsgo -p tsconfig.build.json", - "test": "vitest --run" - }, - "dependencies": { - "@oh-my-pi/pi-utils": "workspace:*" - }, - "devDependencies": { - "@types/node": "^24.3.0", - "vitest": "^3.2.4" - }, - "keywords": [ - "git", - "tool", - "agent" - ], - "author": "Can Bölük", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/can1357/oh-my-pi.git", - "directory": "packages/git-tool" - }, - "engines": { - "bun": ">=1.0.0" - } -} diff --git a/packages/git-tool/src/cache/git-cache.ts b/packages/git-tool/src/cache/git-cache.ts deleted file mode 100644 index 0982791b0..000000000 --- a/packages/git-tool/src/cache/git-cache.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { BranchListResult, Commit, StatusResult } from "../types"; - -export interface CacheEntry { - value: T; - timestamp: number; - ttl: number; - cwd: string; -} - -export interface GitCache { - branch: CacheEntry | null; - status: CacheEntry | null; - remotes: CacheEntry> | null; - commits: Map>; -} - -export const DEFAULT_TTL = { - branch: 30_000, - status: 5_000, - remotes: 60_000, - commits: 300_000, -}; - -export function createCache(): GitCache { - return { - branch: null, - status: null, - remotes: null, - commits: new Map(), - }; -} - -export function isExpired(entry: CacheEntry): boolean { - return Date.now() - entry.timestamp > entry.ttl; -} diff --git a/packages/git-tool/src/errors.ts b/packages/git-tool/src/errors.ts deleted file mode 100644 index d94c7bad5..000000000 --- a/packages/git-tool/src/errors.ts +++ /dev/null @@ -1,84 +0,0 @@ -export enum GitErrorCode { - NOT_A_REPO = "NOT_A_REPO", - CONFLICT = "CONFLICT", - UNCOMMITTED_CHANGES = "UNCOMMITTED_CHANGES", - BRANCH_NOT_FOUND = "BRANCH_NOT_FOUND", - REF_NOT_FOUND = "REF_NOT_FOUND", - REMOTE_REJECTED = "REMOTE_REJECTED", - AUTH_FAILED = "AUTH_FAILED", - MERGE_CONFLICT = "MERGE_CONFLICT", - REBASE_CONFLICT = "REBASE_CONFLICT", - NOTHING_TO_COMMIT = "NOTHING_TO_COMMIT", - DETACHED_HEAD = "DETACHED_HEAD", - HOOK_FAILED = "HOOK_FAILED", - GH_NOT_INSTALLED = "GH_NOT_INSTALLED", - GH_AUTH_REQUIRED = "GH_AUTH_REQUIRED", - UNKNOWN = "UNKNOWN", -} - -export class GitError extends Error { - code: GitErrorCode; - details?: Record; - - constructor(message: string, code: GitErrorCode, details?: Record) { - super(message); - this.name = "GitError"; - this.code = code; - this.details = details; - } -} - -export function detectGitError(stderr: string, exitCode: number): GitError | null { - if (exitCode === 0) return null; - - const normalized = stderr.toLowerCase(); - if (normalized.includes("not a git repository")) { - return new GitError("Not a git repository", GitErrorCode.NOT_A_REPO); - } - if (normalized.includes("authentication failed") || normalized.includes("fatal: authentication")) { - return new GitError("Authentication failed", GitErrorCode.AUTH_FAILED); - } - if (normalized.includes("permission denied") || normalized.includes("access denied")) { - return new GitError("Authentication failed", GitErrorCode.AUTH_FAILED); - } - if (normalized.includes("nothing to commit")) { - return new GitError("Nothing to commit", GitErrorCode.NOTHING_TO_COMMIT); - } - if (normalized.includes("detached head")) { - return new GitError("Detached HEAD", GitErrorCode.DETACHED_HEAD); - } - if (normalized.includes("merge conflict") || normalized.includes("conflict")) { - return new GitError("Merge conflict", GitErrorCode.MERGE_CONFLICT); - } - if (normalized.includes("rebase")) { - return new GitError("Rebase conflict", GitErrorCode.REBASE_CONFLICT); - } - if (normalized.includes("unknown revision") || normalized.includes("bad revision")) { - return new GitError("Ref not found", GitErrorCode.REF_NOT_FOUND); - } - if (normalized.includes("pathspec") && normalized.includes("did not match")) { - return new GitError("Ref not found", GitErrorCode.REF_NOT_FOUND); - } - if (normalized.includes("hook") && normalized.includes("failed")) { - return new GitError("Hook failed", GitErrorCode.HOOK_FAILED); - } - if (normalized.includes("remote rejected") || normalized.includes("rejected")) { - return new GitError("Remote rejected", GitErrorCode.REMOTE_REJECTED); - } - - return new GitError(stderr.trim() || "Unknown git error", GitErrorCode.UNKNOWN); -} - -export function detectGhError(stderr: string, exitCode: number): GitError | null { - if (exitCode === 0) return null; - - const normalized = stderr.toLowerCase(); - if (normalized.includes("not logged") || normalized.includes("authentication required")) { - return new GitError("GitHub CLI authentication required", GitErrorCode.GH_AUTH_REQUIRED); - } - if (normalized.includes("gh: not found") || normalized.includes("gh: command not found")) { - return new GitError("GitHub CLI is not installed", GitErrorCode.GH_NOT_INSTALLED); - } - - return new GitError(stderr.trim() || "GitHub CLI error", GitErrorCode.UNKNOWN); -} diff --git a/packages/git-tool/src/git-tool.ts b/packages/git-tool/src/git-tool.ts deleted file mode 100644 index f02a455ea..000000000 --- a/packages/git-tool/src/git-tool.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { createCache, DEFAULT_TTL, isExpired } from "./cache/git-cache"; -import { add } from "./operations/add"; -import { blame } from "./operations/blame"; -import { branch } from "./operations/branch"; -import { checkout } from "./operations/checkout"; -import { cherryPick } from "./operations/cherry-pick"; -import { commit } from "./operations/commit"; -import { diff } from "./operations/diff"; -import { fetch } from "./operations/fetch"; -import { ci } from "./operations/github/ci"; -import { issue } from "./operations/github/issue"; -import { pr } from "./operations/github/pr"; -import { release } from "./operations/github/release"; -import { log } from "./operations/log"; -import { merge } from "./operations/merge"; -import { pull } from "./operations/pull"; -import { push } from "./operations/push"; -import { rebase } from "./operations/rebase"; -import { restore } from "./operations/restore"; -import { show } from "./operations/show"; -import { stash } from "./operations/stash"; -import { status } from "./operations/status"; -import { tag } from "./operations/tag"; -import { renderBranchList, renderStatus } from "./render"; -import { checkSafety } from "./safety/guards"; -import type { BranchListResult, GitParams, Operation, StatusResult, ToolResponse, ToolResult } from "./types"; -import { isTruthy } from "./utils"; - -const cache = createCache(); - -type OperationHandler = (params: GitParams, cwd?: string) => Promise>; - -const operations: Record = { - status: status as OperationHandler, - diff: diff as OperationHandler, - log: log as OperationHandler, - show: show as OperationHandler, - blame: blame as OperationHandler, - branch: branch as OperationHandler, - add: add as OperationHandler, - restore: restore as OperationHandler, - commit: commit as OperationHandler, - checkout: checkout as OperationHandler, - merge: merge as OperationHandler, - rebase: rebase as OperationHandler, - stash: stash as OperationHandler, - "cherry-pick": cherryPick as OperationHandler, - fetch: fetch as OperationHandler, - pull: pull as OperationHandler, - push: push as OperationHandler, - tag: tag as OperationHandler, - pr: pr as OperationHandler, - issue: issue as OperationHandler, - ci: ci as OperationHandler, - release: release as OperationHandler, -}; - -const READ_OPERATIONS: Operation[] = ["status", "diff", "log", "show", "blame", "branch"]; -const WRITE_OPERATIONS: Operation[] = [ - "add", - "restore", - "commit", - "checkout", - "merge", - "rebase", - "stash", - "cherry-pick", - "pull", - "fetch", -]; - -function invalidateOnWrite(operation: Operation): void { - if (WRITE_OPERATIONS.includes(operation)) { - cache.status = null; - if (["checkout", "merge", "rebase"].includes(operation)) { - cache.branch = null; - } - } -} - -function cacheStatus(result: ToolResult, cwd: string): void { - cache.status = { value: result.data, timestamp: Date.now(), ttl: DEFAULT_TTL.status, cwd }; -} - -function cacheBranch(result: ToolResult, cwd: string): void { - cache.branch = { value: result.data, timestamp: Date.now(), ttl: DEFAULT_TTL.branch, cwd }; -} - -function getCachedStatus(cwd: string): ToolResult | null { - if (!cache.status) return null; - if (isExpired(cache.status)) { - cache.status = null; - return null; - } - if (cache.status.cwd !== cwd) return null; - return { - data: cache.status.value, - _rendered: renderStatus(cache.status.value), - }; -} - -function getCachedBranch(cwd: string): ToolResult | null { - if (!cache.branch) return null; - if (isExpired(cache.branch)) { - cache.branch = null; - return null; - } - if (cache.branch.cwd !== cwd) return null; - return { - data: cache.branch.value, - _rendered: renderBranchList(cache.branch.value), - }; -} - -export async function gitTool(params: GitParams, cwd?: string): Promise> { - const resolvedCwd = cwd ?? process.cwd(); - const operation = params.operation as Operation; - const handler = operations[operation]; - if (!handler) { - return { error: `Unknown operation: ${operation}` }; - } - - const paramRecord = params as unknown as Record; - const safety = await checkSafety(operation, paramRecord, resolvedCwd); - if (safety.blocked) { - const overrideValue = safety.override ? paramRecord[safety.override] : undefined; - if (!safety.override || !isTruthy(String(overrideValue ?? ""))) { - return { error: safety.message ?? "Operation blocked", suggestion: safety.suggestion, code: "SAFETY_BLOCK" }; - } - } - if (safety.confirm) { - const overrideValue = safety.override ? paramRecord[safety.override] : undefined; - if (!safety.override || !isTruthy(String(overrideValue ?? ""))) { - return { - confirm: safety.message ?? "Confirmation required", - override: safety.override ?? "confirm", - _rendered: safety.message, - }; - } - } - - if (READ_OPERATIONS.includes(operation)) { - if (operation === "status") { - const cached = getCachedStatus(resolvedCwd); - const statusParams = params as { only?: string; ignored?: boolean }; - if (cached && !statusParams.only && !statusParams.ignored) return cached; - } - if (operation === "branch") { - const cached = getCachedBranch(resolvedCwd); - const branchParams = params as { action?: string; remotes?: boolean }; - if (cached && (!branchParams.action || branchParams.action === "list") && !branchParams.remotes) return cached; - } - } - - const result = await handler(params, resolvedCwd); - - invalidateOnWrite(operation); - if ("data" in result && READ_OPERATIONS.includes(operation)) { - if (operation === "status") cacheStatus(result as ToolResult, resolvedCwd); - if (operation === "branch") cacheBranch(result as ToolResult, resolvedCwd); - } - - if ("data" in result && safety.warnings.length > 0) { - const suffix = `\n\nWarnings:\n${safety.warnings.map((warn) => `- ${warn}`).join("\n")}`; - result._rendered = `${result._rendered ?? ""}${suffix}`; - } - - return result; -} diff --git a/packages/git-tool/src/index.ts b/packages/git-tool/src/index.ts deleted file mode 100644 index 10b57a2b7..000000000 --- a/packages/git-tool/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./errors"; -export { gitTool } from "./git-tool"; -export * from "./types"; diff --git a/packages/git-tool/src/operations/add.ts b/packages/git-tool/src/operations/add.ts deleted file mode 100644 index d26ad3ef5..000000000 --- a/packages/git-tool/src/operations/add.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { renderAdd } from "../render"; -import type { AddParams, AddResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -function parseDryRun(output: string): string[] { - const files: string[] = []; - for (const line of output.split("\n")) { - const match = line.match(/add ['"]?(.*?)['"]?$/i); - if (match) { - files.push(match[1]); - } - } - return files; -} - -export async function add(params: AddParams, cwd?: string): Promise | ToolError> { - const args = ["add"]; - if (params.dry_run) args.push("--dry-run"); - if (params.update) args.push("-u"); - if (params.all) args.push("-A"); - if (params.paths && params.paths.length > 0) { - args.push("--", ...params.paths); - } - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - let staged: string[] = []; - if (params.dry_run) { - staged = parseDryRun(result.stdout); - } else { - const stagedResult = await git(["diff", "--name-only", "--cached"], { cwd }); - if (!stagedResult.error) { - staged = stagedResult.stdout.split("\n").filter(Boolean); - } - } - - const data: AddResult = { staged }; - return { data, _rendered: renderAdd(data) }; -} diff --git a/packages/git-tool/src/operations/blame.ts b/packages/git-tool/src/operations/blame.ts deleted file mode 100644 index af93f0fdb..000000000 --- a/packages/git-tool/src/operations/blame.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { parseBlame } from "../parsers/blame-parser"; -import { renderBlame } from "../render"; -import type { BlameParams, BlameResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -export async function blame(params: BlameParams, cwd?: string): Promise | ToolError> { - const args = ["blame", "--porcelain"]; - if (params.root) args.push("--root"); - if (params.ignore_whitespace) args.push("-w"); - if (params.lines) { - args.push("-L", `${params.lines.start},${params.lines.end}`); - } - args.push(params.path); - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - const parsed = parseBlame(result.stdout); - const data: BlameResult = { lines: parsed }; - return { data, _rendered: renderBlame(data) }; -} diff --git a/packages/git-tool/src/operations/branch.ts b/packages/git-tool/src/operations/branch.ts deleted file mode 100644 index 4c93cebbd..000000000 --- a/packages/git-tool/src/operations/branch.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { renderBranchList } from "../render"; -import type { BranchInfo, BranchListResult, BranchParams, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -function parseTrack(track: string): { ahead?: number; behind?: number; gone?: boolean } { - const result: { ahead?: number; behind?: number; gone?: boolean } = {}; - if (!track) return result; - if (track.includes("gone")) result.gone = true; - const aheadMatch = track.match(/ahead (\d+)/); - const behindMatch = track.match(/behind (\d+)/); - if (aheadMatch) result.ahead = Number.parseInt(aheadMatch[1], 10); - if (behindMatch) result.behind = Number.parseInt(behindMatch[1], 10); - return result; -} - -function parseBranchLines(output: string): BranchInfo[] { - const branches: BranchInfo[] = []; - for (const line of output.split("\n")) { - if (!line) continue; - const parts = line.split("\x00"); - if (parts.length < 2) continue; - const name = parts[0]; - const sha = parts[1]; - const upstream = parts[2] || undefined; - const track = parts[3] || ""; - const { ahead, behind, gone } = parseTrack(track); - branches.push({ name, sha, upstream, ahead, behind, gone }); - } - return branches; -} - -export async function branch(params: BranchParams, cwd?: string): Promise | ToolError> { - const action = params.action ?? "list"; - - if (action === "current") { - const currentResult = await git(["branch", "--show-current"], { cwd }); - if (currentResult.error) { - return { error: currentResult.error.message, code: currentResult.error.code }; - } - const data: BranchListResult = { current: currentResult.stdout.trim(), local: [] }; - return { data, _rendered: renderBranchList(data) }; - } - - if (action === "create") { - if (!params.name) { - return { error: "Branch name required" }; - } - const args = ["branch", params.name]; - if (params.startPoint) args.push(params.startPoint); - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - } - - if (action === "delete") { - if (!params.name) { - return { error: "Branch name required" }; - } - const args = ["branch", params.force ? "-D" : "-d", params.name]; - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - } - - if (action === "rename") { - if (!params.name || !params.newName) { - return { error: "Branch name and newName required" }; - } - const result = await git(["branch", "-m", params.name, params.newName], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - } - - const currentResult = await git(["branch", "--show-current"], { cwd }); - if (currentResult.error) { - return { error: currentResult.error.message, code: currentResult.error.code }; - } - const current = currentResult.stdout.trim(); - - const listResult = await git( - [ - "branch", - "-vv", - "--format=%(refname:short)%x00%(objectname:short)%x00%(upstream:short)%x00%(upstream:track,nobracket)", - ], - { cwd }, - ); - if (listResult.error) { - return { error: listResult.error.message, code: listResult.error.code }; - } - const local = parseBranchLines(listResult.stdout); - - let remote: BranchInfo[] | undefined; - if (params.remotes) { - const remoteResult = await git( - [ - "branch", - "-r", - "-vv", - "--format=%(refname:short)%x00%(objectname:short)%x00%(upstream:short)%x00%(upstream:track,nobracket)", - ], - { cwd }, - ); - if (remoteResult.error) { - return { error: remoteResult.error.message, code: remoteResult.error.code }; - } - remote = parseBranchLines(remoteResult.stdout); - } - - const data: BranchListResult = { current, local, ...(remote ? { remote } : {}) }; - return { data, _rendered: renderBranchList(data) }; -} diff --git a/packages/git-tool/src/operations/checkout.ts b/packages/git-tool/src/operations/checkout.ts deleted file mode 100644 index 375371310..000000000 --- a/packages/git-tool/src/operations/checkout.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CheckoutParams, CheckoutResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -export async function checkout(params: CheckoutParams, cwd?: string): Promise | ToolError> { - const currentResult = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }); - const previous = currentResult.error ? undefined : currentResult.stdout.trim(); - - if (params.paths && params.paths.length > 0) { - const args = ["checkout"]; - if (params.ref) args.push(params.ref); - if (params.force) args.push("--force"); - args.push("--", ...params.paths); - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: CheckoutResult = { previous, restoredFiles: params.paths }; - return { data, _rendered: `Restored ${params.paths.length} files` }; - } - - if (!params.ref) { - return { error: "Ref is required for checkout" }; - } - - const args = ["checkout"]; - if (params.create) args.push("-b"); - if (params.force) args.push("--force"); - args.push(params.ref); - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - const data: CheckoutResult = { branch: params.ref, previous }; - return { data, _rendered: `Checked out ${params.ref}` }; -} diff --git a/packages/git-tool/src/operations/cherry-pick.ts b/packages/git-tool/src/operations/cherry-pick.ts deleted file mode 100644 index e0d4c584b..000000000 --- a/packages/git-tool/src/operations/cherry-pick.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { GitErrorCode } from "../errors"; -import { renderCherryPick } from "../render"; -import type { CherryPickParams, CherryPickResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -async function getConflicts(cwd?: string): Promise { - const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); - if (result.error) return []; - return result.stdout.split("\n").filter(Boolean); -} - -export async function cherryPick( - params: CherryPickParams, - cwd?: string, -): Promise | ToolError> { - const args = ["cherry-pick"]; - if (params.abort) args.push("--abort"); - if (params.continue) args.push("--continue"); - if (params.no_commit) args.push("--no-commit"); - if (!params.abort && !params.continue) { - if (!params.commits || params.commits.length === 0) { - return { error: "Commits are required for cherry-pick" }; - } - args.push(...params.commits); - } - - const result = await git(args, { cwd }); - if (result.error) { - if (result.error.code === GitErrorCode.MERGE_CONFLICT) { - const conflicts = await getConflicts(cwd); - const data: CherryPickResult = { status: "conflict", conflicts }; - return { data, _rendered: renderCherryPick(data) }; - } - return { error: result.error.message, code: result.error.code }; - } - - const data: CherryPickResult = { status: "success", appliedCommits: params.commits }; - return { data, _rendered: renderCherryPick(data) }; -} diff --git a/packages/git-tool/src/operations/commit.ts b/packages/git-tool/src/operations/commit.ts deleted file mode 100644 index d2391204f..000000000 --- a/packages/git-tool/src/operations/commit.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { renderCommit } from "../render"; -import { markCommitCreated } from "../safety/guards"; -import type { CommitParams, CommitResult, ToolError, ToolResult } from "../types"; -import { git, parseShortstat } from "../utils"; - -export async function commit(params: CommitParams, cwd?: string): Promise | ToolError> { - if (!params.message || params.message.trim().length === 0) { - return { error: "Commit message is required" }; - } - const args = ["commit", "-m", params.message]; - if (params.all) args.push("--all"); - if (params.allow_empty) args.push("--allow-empty"); - if (params.sign) args.push("-S"); - if (params.no_verify) args.push("--no-verify"); - if (params.amend) args.push("--amend"); - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - const shaResult = await git(["rev-parse", "HEAD"], { cwd }); - if (shaResult.error) { - return { error: shaResult.error.message, code: shaResult.error.code }; - } - const sha = shaResult.stdout.trim(); - markCommitCreated(sha); - - const subjectResult = await git(["show", "-s", "--format=%s", "HEAD"], { cwd }); - if (subjectResult.error) { - return { error: subjectResult.error.message, code: subjectResult.error.code }; - } - const subject = subjectResult.stdout.trim(); - - const statResult = await git(["show", "-s", "--shortstat", "HEAD"], { cwd }); - let stats = { additions: 0, deletions: 0, files: 0 }; - if (!statResult.error) { - const line = statResult.stdout.split("\n").find((statLine) => statLine.includes("files changed")); - const parsed = line ? parseShortstat(line) : null; - if (parsed) { - stats = { files: parsed.files, additions: parsed.additions, deletions: parsed.deletions }; - } - } - - const data: CommitResult = { - sha, - shortSha: sha.slice(0, 7), - subject, - stats, - }; - return { data, _rendered: renderCommit(data) }; -} diff --git a/packages/git-tool/src/operations/diff.ts b/packages/git-tool/src/operations/diff.ts deleted file mode 100644 index a25169126..000000000 --- a/packages/git-tool/src/operations/diff.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { parseDiff } from "../parsers/diff-parser"; -import { renderDiff } from "../render"; -import type { DiffParams, DiffResult, FileDiff, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -interface StatusInfo { - status: FileDiff["status"]; - oldPath?: string; -} - -function parseNameStatus(output: string): Map { - const map = new Map(); - for (const line of output.split("\n")) { - if (!line) continue; - const parts = line.split("\t"); - const code = parts[0]; - if (!code) continue; - const status = code[0]; - if (status === "R" || status === "C") { - const oldPath = parts[1]; - const newPath = parts[2]; - if (newPath) { - map.set(newPath, { - status: status === "R" ? "renamed" : "copied", - oldPath, - }); - } - continue; - } - const path = parts[1]; - if (!path) continue; - const statusMap: Record = { - A: "added", - M: "modified", - D: "deleted", - }; - map.set(path, { status: statusMap[status] ?? "modified" }); - } - return map; -} - -function parseRenamePath(path: string): { oldPath: string; newPath: string } | null { - if (!path.includes("=>")) return null; - const braceMatch = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/); - if (braceMatch) { - const prefix = braceMatch[1]; - const oldMid = braceMatch[2]; - const newMid = braceMatch[3]; - const suffix = braceMatch[4]; - return { - oldPath: `${prefix}${oldMid}${suffix}`, - newPath: `${prefix}${newMid}${suffix}`, - }; - } - const parts = path.split("=>").map((part) => part.trim()); - if (parts.length !== 2) return null; - return { oldPath: parts[0], newPath: parts[1] }; -} - -function parseNumstat( - output: string, -): Map { - const map = new Map(); - for (const line of output.split("\n")) { - if (!line) continue; - const parts = line.split("\t"); - if (parts.length < 3) continue; - const additionsRaw = parts[0]; - const deletionsRaw = parts[1]; - const pathRaw = parts.slice(2).join("\t"); - const additions = additionsRaw === "-" ? 0 : Number.parseInt(additionsRaw, 10); - const deletions = deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10); - const binary = additionsRaw === "-" || deletionsRaw === "-"; - const rename = parseRenamePath(pathRaw); - if (rename) { - map.set(rename.newPath, { additions, deletions, binary, oldPath: rename.oldPath }); - continue; - } - map.set(pathRaw, { additions, deletions, binary }); - } - return map; -} - -function buildDiffArgs(params: DiffParams): string[] { - const args: string[] = ["diff"]; - if (params.target === "staged") { - args.push("--cached"); - } else if (params.target === "head") { - args.push("HEAD"); - } else if (params.target && typeof params.target === "object") { - // Ambiguity: when `to` is omitted, default to diffing against HEAD. - const toRef = params.target.to ?? "HEAD"; - args.push(`${params.target.from}..${toRef}`); - } - - if (params.ignore_whitespace) { - args.push("--ignore-all-space"); - } - return args; -} - -export async function diff(params: DiffParams, cwd?: string): Promise | ToolError> { - const baseArgs = buildDiffArgs(params); - const paths = params.paths ?? []; - - const nameStatusArgs = [...baseArgs, "--name-status"]; - if (paths.length > 0) nameStatusArgs.push("--", ...paths); - const nameStatusResult = await git(nameStatusArgs, { cwd }); - if (nameStatusResult.error) { - return { error: nameStatusResult.error.message, code: nameStatusResult.error.code }; - } - const statusMap = parseNameStatus(nameStatusResult.stdout); - - if (params.name_only || params.stat_only) { - const numstatArgs = [...baseArgs, "--numstat"]; - if (paths.length > 0) numstatArgs.push("--", ...paths); - const numstatResult = await git(numstatArgs, { cwd }); - if (numstatResult.error) { - return { error: numstatResult.error.message, code: numstatResult.error.code }; - } - const statsMap = parseNumstat(numstatResult.stdout); - const files: FileDiff[] = []; - const seen = new Set(); - - for (const [path, info] of statusMap.entries()) { - const stats = statsMap.get(path); - files.push({ - path, - oldPath: info.oldPath ?? stats?.oldPath, - status: info.status, - binary: stats?.binary ?? false, - additions: stats?.additions ?? 0, - deletions: stats?.deletions ?? 0, - }); - seen.add(path); - } - - for (const [path, stats] of statsMap.entries()) { - if (seen.has(path)) continue; - files.push({ - path, - oldPath: stats.oldPath, - status: "modified", - binary: stats.binary, - additions: stats.additions, - deletions: stats.deletions, - }); - } - - const summary = files.reduce( - (acc, file) => { - acc.filesChanged += 1; - acc.insertions += file.additions; - acc.deletions += file.deletions; - return acc; - }, - { filesChanged: 0, insertions: 0, deletions: 0 }, - ); - - const result: DiffResult = { - files, - stats: summary, - truncated: false, - }; - return { data: result, _rendered: renderDiff(result) }; - } - - const diffArgs = [...baseArgs]; - if (params.context !== undefined) { - diffArgs.push(`--unified=${params.context}`); - } - if (paths.length > 0) diffArgs.push("--", ...paths); - - const diffResult = await git(diffArgs, { cwd }); - if (diffResult.error) { - return { error: diffResult.error.message, code: diffResult.error.code }; - } - - const parsed = parseDiff(diffResult.stdout, { maxLines: params.max_lines }); - for (const file of parsed.files) { - const info = statusMap.get(file.path); - if (info) { - file.status = info.status; - file.oldPath = info.oldPath ?? file.oldPath; - } - } - - const summary = parsed.files.reduce( - (acc, file) => { - acc.filesChanged += 1; - acc.insertions += file.additions; - acc.deletions += file.deletions; - return acc; - }, - { filesChanged: 0, insertions: 0, deletions: 0 }, - ); - - const result: DiffResult = { - files: parsed.files, - stats: summary, - truncated: parsed.truncated, - truncatedFiles: parsed.truncatedFiles.length > 0 ? parsed.truncatedFiles : undefined, - }; - - return { data: result, _rendered: renderDiff(result) }; -} diff --git a/packages/git-tool/src/operations/fetch.ts b/packages/git-tool/src/operations/fetch.ts deleted file mode 100644 index 4e8b176dc..000000000 --- a/packages/git-tool/src/operations/fetch.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { renderFetch } from "../render"; -import type { FetchParams, FetchResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -async function getRemoteRefs(remote: string, branch?: string, cwd?: string): Promise> { - const refPrefix = branch ? `refs/remotes/${remote}/${branch}` : `refs/remotes/${remote}`; - const result = await git(["for-each-ref", refPrefix, "--format=%(refname)\t%(objectname)"], { cwd }); - if (result.error) return new Map(); - const map = new Map(); - for (const line of result.stdout.split("\n")) { - if (!line) continue; - const [ref, sha] = line.split("\t"); - if (ref && sha) map.set(ref, sha); - } - return map; -} - -export async function fetch(params: FetchParams, cwd?: string): Promise | ToolError> { - const remote = params.remote ?? "origin"; - const before = await getRemoteRefs(remote, params.branch, cwd); - - const args = ["fetch"]; - if (params.all) { - args.push("--all"); - } else { - args.push(remote); - if (params.branch) args.push(params.branch); - } - if (params.prune) args.push("--prune"); - if (params.tags) args.push("--tags"); - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - const after = await getRemoteRefs(remote, params.branch, cwd); - const updated: FetchResult["updated"] = []; - for (const [ref, newSha] of after.entries()) { - const oldSha = before.get(ref); - if (!oldSha || oldSha !== newSha) { - updated.push({ ref, oldSha: oldSha ?? "", newSha }); - } - } - const pruned = params.prune ? Array.from(before.keys()).filter((ref) => !after.has(ref)) : undefined; - - const data: FetchResult = { updated, ...(pruned && pruned.length > 0 ? { pruned } : {}) }; - return { data, _rendered: renderFetch(data) }; -} diff --git a/packages/git-tool/src/operations/github/ci.ts b/packages/git-tool/src/operations/github/ci.ts deleted file mode 100644 index ae08d8ba8..000000000 --- a/packages/git-tool/src/operations/github/ci.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { - CIActionResult, - CIParams, - CIResult, - JobInfo, - RunInfo, - RunListResult, - RunViewResult, - ToolError, - ToolResult, -} from "../../types"; -import { gh } from "../../utils"; - -type GhRun = { - databaseId?: number; - id?: number; - displayTitle?: string; - name?: string; - status: RunInfo["status"]; - conclusion?: RunInfo["conclusion"]; - headBranch?: string; - branch?: string; - headSha?: string; - sha?: string; - url: string; - createdAt: string; - updatedAt: string; - jobs?: GhJob[]; -}; - -type GhJob = { - databaseId?: number; - id?: number; - name: string; - status: string; - conclusion?: string; - steps?: Array<{ name: string; status: string; conclusion?: string }>; -}; - -function mapRunInfo(raw: GhRun): RunInfo { - return { - id: raw.databaseId ?? raw.id ?? 0, - name: raw.displayTitle ?? raw.name ?? "", - status: raw.status, - conclusion: raw.conclusion ?? undefined, - branch: raw.headBranch ?? raw.branch ?? "", - sha: raw.headSha ?? raw.sha ?? "", - url: raw.url, - createdAt: raw.createdAt, - updatedAt: raw.updatedAt, - }; -} - -function mapJobInfo(raw: GhJob): JobInfo { - return { - id: raw.databaseId ?? raw.id ?? 0, - name: raw.name, - status: raw.status, - conclusion: raw.conclusion ?? undefined, - steps: (raw.steps ?? []).map((step) => ({ - name: step.name, - status: step.status, - conclusion: step.conclusion ?? undefined, - })), - }; -} - -export async function ci(params: CIParams, cwd?: string): Promise | ToolError> { - if (params.action === "list") { - const args = [ - "run", - "list", - "--json", - "databaseId,displayTitle,status,conclusion,headBranch,headSha,url,createdAt,updatedAt", - ]; - if (params.limit) args.push("--limit", String(params.limit)); - if (params.branch) args.push("--branch", params.branch); - if (params.workflow) args.push("--workflow", params.workflow); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const raw = JSON.parse(result.stdout) as GhRun[]; - const runs = raw.map((item) => mapRunInfo(item)); - const data: RunListResult = { runs }; - return { data, _rendered: `Runs: ${runs.length}` }; - } - - if (params.action === "view") { - if (!params.run_id) { - return { error: "run_id required" }; - } - const result = await gh( - [ - "run", - "view", - String(params.run_id), - "--json", - "databaseId,name,status,conclusion,headBranch,headSha,url,createdAt,updatedAt,jobs", - ], - { cwd }, - ); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const raw = JSON.parse(result.stdout) as GhRun; - const run = mapRunInfo(raw); - const jobs: JobInfo[] = (raw.jobs ?? []).map((job) => mapJobInfo(job)); - let logs: string | undefined; - if (params.logs_failed) { - const logsResult = await gh(["run", "view", String(params.run_id), "--log-failed"], { cwd }); - if (!logsResult.error) logs = logsResult.stdout; - } - const data: RunViewResult = { run, jobs, ...(logs ? { logs } : {}) }; - return { data, _rendered: `Run ${run.id} ${run.status}` }; - } - - if (params.action === "watch") { - if (!params.run_id) return { error: "run_id required" }; - const result = await gh(["run", "watch", String(params.run_id), "--exit-status"], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: CIActionResult = { status: "success" }; - return { data, _rendered: `Watched run ${params.run_id}` }; - } - - if (params.action === "run") { - if (!params.workflow) return { error: "workflow required" }; - const args = ["workflow", "run", params.workflow]; - if (params.inputs) { - for (const [key, value] of Object.entries(params.inputs)) { - args.push("--field", `${key}=${value}`); - } - } - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: CIActionResult = { status: "success" }; - return { data, _rendered: `Triggered workflow ${params.workflow}` }; - } - - if (params.action === "cancel" || params.action === "rerun") { - if (!params.run_id) return { error: "run_id required" }; - const result = await gh(["run", params.action, String(params.run_id)], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: CIActionResult = { status: "success" }; - return { data, _rendered: `${params.action} run ${params.run_id}` }; - } - - return { error: `Unknown CI action: ${params.action}` }; -} diff --git a/packages/git-tool/src/operations/github/issue.ts b/packages/git-tool/src/operations/github/issue.ts deleted file mode 100644 index f0a8bf18e..000000000 --- a/packages/git-tool/src/operations/github/issue.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { - IssueCreateResult, - IssueInfo, - IssueListResult, - IssueParams, - IssueResult, - ToolError, - ToolResult, -} from "../../types"; -import { gh } from "../../utils"; - -type GhIssue = { - number: number; - title: string; - state: string; - author?: { login?: string } | string; - body?: string | null; - labels?: Array<{ name?: string } | string>; - assignees?: Array<{ login?: string } | string>; - url: string; - createdAt: string; - comments?: number; -}; - -function mapIssueInfo(raw: GhIssue): IssueInfo { - const author = typeof raw.author === "string" ? raw.author : (raw.author?.login ?? ""); - return { - number: raw.number, - title: raw.title, - state: raw.state, - author, - body: raw.body ?? "", - labels: (raw.labels ?? []) - .map((label) => (typeof label === "string" ? label : (label.name ?? ""))) - .filter(Boolean), - assignees: (raw.assignees ?? []) - .map((assignee) => (typeof assignee === "string" ? assignee : (assignee.login ?? ""))) - .filter(Boolean), - url: raw.url, - createdAt: raw.createdAt, - comments: raw.comments ?? 0, - }; -} - -export async function issue(params: IssueParams, cwd?: string): Promise | ToolError> { - if (params.action === "list") { - const args = [ - "issue", - "list", - "--json", - "number,title,state,author,body,labels,assignees,url,createdAt,comments", - ]; - if (params.state) args.push("--state", params.state); - if (params.labels && params.labels.length > 0) args.push("--label", params.labels.join(",")); - if (params.assignee) args.push("--assignee", params.assignee); - if (params.limit) args.push("--limit", String(params.limit)); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const raw = JSON.parse(result.stdout) as GhIssue[]; - const issues = raw.map((item) => mapIssueInfo(item)); - const data: IssueListResult = { issues }; - return { data, _rendered: `Issues: ${issues.length}` }; - } - - if (params.action === "view") { - if (!params.number) { - return { error: "Issue number required" }; - } - const result = await gh( - [ - "issue", - "view", - String(params.number), - "--json", - "number,title,state,author,body,labels,assignees,url,createdAt,comments", - ], - { cwd }, - ); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const issueInfo = mapIssueInfo(JSON.parse(result.stdout) as GhIssue); - return { data: { issue: issueInfo }, _rendered: `Issue #${issueInfo.number}: ${issueInfo.title}` }; - } - - if (params.action === "create") { - const args = ["issue", "create"]; - if (params.title) args.push("--title", params.title); - if (params.body) args.push("--body", params.body); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - let number = 0; - let url = ""; - const match = result.stdout.match(/https?:\/\/\S+/); - if (match) { - url = match[0]; - const numMatch = url.match(/issues\/(\d+)/); - if (numMatch) number = Number.parseInt(numMatch[1], 10); - } - const data: IssueCreateResult = { number, url }; - return { data, _rendered: url ? `Created issue ${url}` : "Created issue" }; - } - - if (params.action === "close" || params.action === "reopen") { - if (!params.number) { - return { error: "Issue number required" }; - } - const result = await gh(["issue", params.action, String(params.number)], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const verb = params.action === "close" ? "Closed" : "Reopened"; - return { data: { status: "success" }, _rendered: `${verb} issue #${params.number}` }; - } - - if (params.action === "comment") { - if (!params.number || !params.comment_body) { - return { error: "Issue number and comment_body required" }; - } - const result = await gh(["issue", "comment", String(params.number), "--body", params.comment_body], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - return { data: { status: "success" }, _rendered: `Commented on issue #${params.number}` }; - } - - return { error: `Unknown issue action: ${params.action}` }; -} diff --git a/packages/git-tool/src/operations/github/pr.ts b/packages/git-tool/src/operations/github/pr.ts deleted file mode 100644 index 4a9ef09b0..000000000 --- a/packages/git-tool/src/operations/github/pr.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - PRActionResult, - PRCreateResult, - PRInfo, - PRListResult, - PRParams, - PRResult, - ToolError, - ToolResult, -} from "../../types"; -import { gh } from "../../utils"; - -function parseChecks(raw: unknown): { passing: number; failing: number; pending: number } | undefined { - if (!raw) return undefined; - if (Array.isArray(raw)) { - let passing = 0; - let failing = 0; - let pending = 0; - for (const check of raw) { - const status = - (check as { state?: string; conclusion?: string }).conclusion ?? (check as { state?: string }).state ?? ""; - switch (status) { - case "SUCCESS": - case "success": - passing += 1; - break; - case "FAILURE": - case "failure": - case "ERROR": - case "error": - failing += 1; - break; - default: - pending += 1; - } - } - return { passing, failing, pending }; - } - return undefined; -} - -type GhPr = { - number: number; - title: string; - state: string; - author?: { login?: string } | string; - headRefName?: string; - baseRefName?: string; - branch?: string; - base?: string; - url: string; - createdAt: string; - updatedAt: string; - additions?: number; - deletions?: number; - commits?: number; - reviewDecision?: string | null; - checks?: unknown; - statusCheckRollup?: unknown; -}; - -function mapPrInfo(raw: GhPr): PRInfo { - const author = typeof raw.author === "string" ? raw.author : (raw.author?.login ?? ""); - return { - number: raw.number, - title: raw.title, - state: raw.state, - author, - branch: raw.headRefName ?? raw.branch ?? "", - base: raw.baseRefName ?? raw.base ?? "", - url: raw.url, - createdAt: raw.createdAt, - updatedAt: raw.updatedAt, - additions: raw.additions ?? 0, - deletions: raw.deletions ?? 0, - commits: raw.commits ?? 0, - reviewDecision: raw.reviewDecision ?? undefined, - checks: parseChecks(raw.checks ?? raw.statusCheckRollup), - }; -} - -export async function pr(params: PRParams, cwd?: string): Promise | ToolError> { - if (params.action === "list") { - const args = [ - "pr", - "list", - "--json", - "number,title,state,author,headRefName,baseRefName,url,createdAt,updatedAt,additions,deletions,commits,reviewDecision,checks", - ]; - if (params.limit) args.push("--limit", String(params.limit)); - if (params.state) args.push("--state", params.state); - if (params.author) args.push("--author", params.author); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const raw = JSON.parse(result.stdout) as GhPr[]; - const prs = raw.map((item) => mapPrInfo(item)); - const data: PRListResult = { prs }; - return { data, _rendered: `PRs: ${prs.length}` }; - } - - if (params.action === "view") { - if (!params.number) { - return { error: "PR number required" }; - } - const result = await gh( - [ - "pr", - "view", - String(params.number), - "--json", - "number,title,state,author,headRefName,baseRefName,url,createdAt,updatedAt,additions,deletions,commits,reviewDecision,checks", - ], - { cwd }, - ); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const raw = JSON.parse(result.stdout) as GhPr; - const prInfo = mapPrInfo(raw); - return { data: { pr: prInfo }, _rendered: `PR #${prInfo.number}: ${prInfo.title}` }; - } - - if (params.action === "create") { - const args = ["pr", "create"]; - if (params.title) args.push("--title", params.title); - if (params.body) args.push("--body", params.body); - if (params.base) args.push("--base", params.base); - if (params.head) args.push("--head", params.head); - if (params.draft) args.push("--draft"); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - let number = 0; - let url = ""; - const viewArgs = ["pr", "view", "--json", "number,url"]; - if (params.head) viewArgs.push("--head", params.head); - const viewResult = await gh(viewArgs, { cwd }); - if (!viewResult.error && viewResult.stdout.trim().length > 0) { - const raw = JSON.parse(viewResult.stdout) as { number: number; url: string }; - number = raw.number; - url = raw.url; - } else { - const match = result.stdout.match(/https?:\/\/\S+/); - if (match) { - url = match[0]; - const numMatch = url.match(/pull\/(\d+)/); - if (numMatch) number = Number.parseInt(numMatch[1], 10); - } - } - - const data: PRCreateResult = { number, url }; - return { data, _rendered: url ? `Created PR ${url}` : "Created PR" }; - } - - if (params.action === "diff") { - if (!params.number) { - return { error: "PR number required" }; - } - const result = await gh(["pr", "diff", String(params.number)], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: PRActionResult = { status: "success", diff: result.stdout }; - return { data, _rendered: "PR diff" }; - } - - if (params.action === "checkout") { - if (!params.number) { - return { error: "PR number required" }; - } - const result = await gh(["pr", "checkout", String(params.number)], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: PRActionResult = { status: "success" }; - return { data, _rendered: `Checked out PR #${params.number}` }; - } - - if (params.action === "merge") { - if (!params.number) { - return { error: "PR number required" }; - } - const args = ["pr", "merge", String(params.number)]; - if (params.merge_method === "merge") args.push("--merge"); - if (params.merge_method === "squash") args.push("--squash"); - if (params.merge_method === "rebase") args.push("--rebase"); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: PRActionResult = { status: "success" }; - return { data, _rendered: `Merged PR #${params.number}` }; - } - - if (params.action === "close") { - if (!params.number) { - return { error: "PR number required" }; - } - const result = await gh(["pr", "close", String(params.number)], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: PRActionResult = { status: "success" }; - return { data, _rendered: `Closed PR #${params.number}` }; - } - - if (params.action === "ready") { - if (!params.number) { - return { error: "PR number required" }; - } - const result = await gh(["pr", "ready", String(params.number)], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: PRActionResult = { status: "success" }; - return { data, _rendered: `Marked PR #${params.number} ready` }; - } - - if (params.action === "review") { - if (!params.number) { - return { error: "PR number required" }; - } - if (!params.review_action) { - return { error: "review_action required" }; - } - if (params.review_action === "comment" && !params.review_body) { - return { error: "review_body required for comment review" }; - } - const args = ["pr", "review", String(params.number)]; - if (params.review_action === "approve") args.push("--approve"); - if (params.review_action === "request-changes") args.push("--request-changes"); - if (params.review_action === "comment") args.push("--comment"); - if (params.review_body) args.push("--body", params.review_body); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: PRActionResult = { status: "success" }; - return { data, _rendered: `Reviewed PR #${params.number}` }; - } - - return { error: `Unknown PR action: ${params.action}` }; -} diff --git a/packages/git-tool/src/operations/github/release.ts b/packages/git-tool/src/operations/github/release.ts deleted file mode 100644 index 40a6522c7..000000000 --- a/packages/git-tool/src/operations/github/release.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { ReleaseInfo, ReleaseListResult, ReleaseParams, ReleaseResult, ToolError, ToolResult } from "../../types"; -import { gh } from "../../utils"; - -type GhRelease = { - tagName?: string; - tag?: string; - name?: string; - body?: string; - isDraft?: boolean; - draft?: boolean; - isPrerelease?: boolean; - prerelease?: boolean; - createdAt: string; - publishedAt?: string; - url: string; - assets?: Array<{ name: string; size: number; downloadCount?: number; download_count?: number }>; -}; - -function mapReleaseInfo(raw: GhRelease): ReleaseInfo { - return { - tag: raw.tagName ?? raw.tag ?? "", - name: raw.name ?? "", - body: raw.body ?? "", - draft: raw.isDraft ?? raw.draft ?? false, - prerelease: raw.isPrerelease ?? raw.prerelease ?? false, - createdAt: raw.createdAt, - publishedAt: raw.publishedAt ?? "", - url: raw.url, - assets: (raw.assets ?? []).map((asset) => ({ - name: asset.name, - size: asset.size, - downloadCount: asset.downloadCount ?? asset.download_count ?? 0, - })), - }; -} - -export async function release(params: ReleaseParams, cwd?: string): Promise | ToolError> { - if (params.action === "list") { - const args = ["release", "list", "--json", "name,tagName,createdAt,publishedAt,isDraft,isPrerelease,url,assets"]; - if (params.limit) args.push("--limit", String(params.limit)); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const raw = JSON.parse(result.stdout) as GhRelease[]; - const releases = raw.map((item) => mapReleaseInfo(item)); - const data: ReleaseListResult = { releases }; - return { data, _rendered: `Releases: ${releases.length}` }; - } - - if (params.action === "view") { - if (!params.tag) return { error: "tag required" }; - const result = await gh( - [ - "release", - "view", - params.tag, - "--json", - "name,tagName,body,createdAt,publishedAt,isDraft,isPrerelease,url,assets", - ], - { cwd }, - ); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const info = mapReleaseInfo(JSON.parse(result.stdout) as GhRelease); - return { data: info, _rendered: `Release ${info.tag}` }; - } - - if (params.action === "create") { - if (!params.tag) return { error: "tag required" }; - const args = ["release", "create", params.tag]; - if (params.title) args.push("--title", params.title); - if (params.notes) args.push("--notes", params.notes); - if (params.generate_notes) args.push("--generate-notes"); - if (params.draft) args.push("--draft"); - if (params.prerelease) args.push("--prerelease"); - if (params.target) args.push("--target", params.target); - const result = await gh(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const urlMatch = result.stdout.match(/https?:\/\/\S+/); - const url = urlMatch ? urlMatch[0] : undefined; - return { data: { status: "success", url }, _rendered: url ? `Created release ${url}` : "Created release" }; - } - - if (params.action === "delete") { - if (!params.tag) return { error: "tag required" }; - const result = await gh(["release", "delete", params.tag, "--yes"], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - return { data: { status: "success" }, _rendered: `Deleted release ${params.tag}` }; - } - - if (params.action === "upload") { - if (!params.tag || !params.assets || params.assets.length === 0) { - return { error: "tag and assets required" }; - } - const result = await gh(["release", "upload", params.tag, ...params.assets], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - return { data: { status: "success" }, _rendered: `Uploaded assets to ${params.tag}` }; - } - - return { error: `Unknown release action: ${params.action}` }; -} diff --git a/packages/git-tool/src/operations/log.ts b/packages/git-tool/src/operations/log.ts deleted file mode 100644 index 61dcaf195..000000000 --- a/packages/git-tool/src/operations/log.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { parseLog } from "../parsers/log-parser"; -import { renderLog } from "../render"; -import type { Commit, LogParams, LogResult, ToolError, ToolResult } from "../types"; -import { git, parseShortstat } from "../utils"; - -const LOG_FORMAT = "%H%x00%h%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%P%x00%s%x00%b%x1e"; - -async function enrichStats(commits: Commit[], cwd?: string): Promise { - for (const commit of commits) { - const result = await git(["show", "-s", "--shortstat", commit.sha], { cwd }); - if (result.error) continue; - const statLine = result.stdout.split("\n").find((line) => line.includes("files changed")); - if (!statLine) continue; - const stats = parseShortstat(statLine); - if (stats) { - commit.stats = { - files: stats.files, - additions: stats.additions, - deletions: stats.deletions, - }; - } - } -} - -export async function log(params: LogParams, cwd?: string): Promise | ToolError> { - const limit = params.limit ?? 10; - const fetchLimit = limit + 1; - - const args = ["log", `--format=${LOG_FORMAT}`, "-n", String(fetchLimit)]; - if (params.ref) args.push(params.ref); - if (params.author) args.push(`--author=${params.author}`); - if (params.since) args.push(`--since=${params.since}`); - if (params.until) args.push(`--until=${params.until}`); - if (params.grep) args.push(`--grep=${params.grep}`); - if (params.merges === true) args.push("--merges"); - if (params.merges === false) args.push("--no-merges"); - if (params.first_parent) args.push("--first-parent"); - if (params.paths && params.paths.length > 0) { - args.push("--", ...params.paths); - } - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - let commits = parseLog(result.stdout); - const hasMore = commits.length > limit; - if (hasMore) commits = commits.slice(0, limit); - - if (params.format && params.format !== "full") { - for (const commit of commits) { - commit.message = commit.subject; - } - } - - if (params.stat) { - await enrichStats(commits, cwd); - } - - const finalResult: LogResult = { commits, hasMore }; - return { data: finalResult, _rendered: renderLog(finalResult) }; -} diff --git a/packages/git-tool/src/operations/merge.ts b/packages/git-tool/src/operations/merge.ts deleted file mode 100644 index 578d77106..000000000 --- a/packages/git-tool/src/operations/merge.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { GitErrorCode } from "../errors"; -import { renderMerge } from "../render"; -import type { MergeParams, MergeResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -async function getConflicts(cwd?: string): Promise { - const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); - if (result.error) return []; - return result.stdout.split("\n").filter(Boolean); -} - -export async function merge(params: MergeParams, cwd?: string): Promise | ToolError> { - if (params.abort) { - const result = await git(["merge", "--abort"], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: MergeResult = { status: "success" }; - return { data, _rendered: renderMerge(data) }; - } - - if (params.continue) { - const result = await git(["merge", "--continue"], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const shaResult = await git(["rev-parse", "HEAD"], { cwd }); - const data: MergeResult = { status: "success", sha: shaResult.error ? undefined : shaResult.stdout.trim() }; - return { data, _rendered: renderMerge(data) }; - } - - const args = ["merge"]; - if (params.no_ff) args.push("--no-ff"); - if (params.ff_only) args.push("--ff-only"); - if (params.squash) args.push("--squash"); - if (params.message) args.push("-m", params.message); - if (!params.ref) { - return { error: "Merge ref is required" }; - } - args.push(params.ref); - - const result = await git(args, { cwd }); - if (result.error) { - if (result.error.code === GitErrorCode.MERGE_CONFLICT) { - const conflicts = await getConflicts(cwd); - const data: MergeResult = { status: "conflict", conflicts }; - return { data, _rendered: renderMerge(data) }; - } - return { error: result.error.message, code: result.error.code }; - } - - const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); - let status: MergeResult["status"] = "success"; - if (output.includes("already up to date")) { - status = "up-to-date"; - } else if (output.includes("fast-forward")) { - status = "fast-forward"; - } - const shaResult = await git(["rev-parse", "HEAD"], { cwd }); - const data: MergeResult = { status, sha: shaResult.error ? undefined : shaResult.stdout.trim() }; - return { data, _rendered: renderMerge(data) }; -} diff --git a/packages/git-tool/src/operations/pull.ts b/packages/git-tool/src/operations/pull.ts deleted file mode 100644 index 224cd7a52..000000000 --- a/packages/git-tool/src/operations/pull.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { GitErrorCode } from "../errors"; -import { renderPull } from "../render"; -import type { PullParams, PullResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -async function getConflicts(cwd?: string): Promise { - const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); - if (result.error) return []; - return result.stdout.split("\n").filter(Boolean); -} - -async function countPulledCommits(cwd?: string): Promise { - const origResult = await git(["rev-parse", "-q", "--verify", "ORIG_HEAD"], { cwd }); - if (origResult.error) return 0; - const orig = origResult.stdout.trim(); - const countResult = await git(["rev-list", "--count", `${orig}..HEAD`], { cwd }); - if (countResult.error) return 0; - return Number.parseInt(countResult.stdout.trim(), 10); -} - -export async function pull(params: PullParams, cwd?: string): Promise | ToolError> { - const args = ["pull"]; - if (params.rebase) args.push("--rebase"); - if (params.ff_only) args.push("--ff-only"); - if (params.remote) args.push(params.remote); - if (params.branch) args.push(params.branch); - - const result = await git(args, { cwd }); - if (result.error) { - if (result.error.code === GitErrorCode.MERGE_CONFLICT || result.error.code === GitErrorCode.REBASE_CONFLICT) { - const conflicts = await getConflicts(cwd); - const data: PullResult = { status: "conflict", conflicts }; - return { data, _rendered: renderPull(data) }; - } - return { error: result.error.message, code: result.error.code }; - } - - const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); - if (output.includes("already up to date")) { - const data: PullResult = { status: "up-to-date" }; - return { data, _rendered: renderPull(data) }; - } - - const commits = await countPulledCommits(cwd); - const data: PullResult = { status: "success", commits }; - return { data, _rendered: renderPull(data) }; -} diff --git a/packages/git-tool/src/operations/push.ts b/packages/git-tool/src/operations/push.ts deleted file mode 100644 index 2ff1f53a4..000000000 --- a/packages/git-tool/src/operations/push.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { renderPush } from "../render"; -import type { PushParams, PushResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -async function getCurrentBranch(cwd?: string): Promise { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }); - if (result.error) return ""; - return result.stdout.trim(); -} - -async function hasRemoteRef(remote: string, branch: string, cwd?: string): Promise { - const result = await git(["rev-parse", "--verify", `refs/remotes/${remote}/${branch}`], { cwd }); - return !result.error; -} - -async function countCommitsToPush(remote: string, branch: string, hasRemote: boolean, cwd?: string): Promise { - if (hasRemote) { - const result = await git(["rev-list", "--count", `refs/remotes/${remote}/${branch}..${branch}`], { cwd }); - if (result.error) return 0; - return Number.parseInt(result.stdout.trim(), 10); - } - const result = await git(["rev-list", "--count", branch], { cwd }); - if (result.error) return 0; - return Number.parseInt(result.stdout.trim(), 10); -} - -export async function push(params: PushParams, cwd?: string): Promise | ToolError> { - const remote = params.remote ?? "origin"; - const branch = params.branch ?? (await getCurrentBranch(cwd)); - if (!branch) { - return { error: "Branch not found", code: "BRANCH_NOT_FOUND" }; - } - - if (params.delete) { - const result = await git(["push", remote, "--delete", branch], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: PushResult = { remote, branch, commits: 0, newBranch: false }; - return { data, _rendered: renderPush(data) }; - } - - const hasRemote = await hasRemoteRef(remote, branch, cwd); - const commits = await countCommitsToPush(remote, branch, hasRemote, cwd); - - const args = ["push", remote, branch]; - if (params.set_upstream) args.push("--set-upstream"); - if (params.tags) args.push("--tags"); - if (params.force_with_lease) args.push("--force-with-lease"); - if (params.force) args.push("--force"); - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - const data: PushResult = { remote, branch, commits, newBranch: !hasRemote }; - return { data, _rendered: renderPush(data) }; -} diff --git a/packages/git-tool/src/operations/rebase.ts b/packages/git-tool/src/operations/rebase.ts deleted file mode 100644 index 53c7cab1f..000000000 --- a/packages/git-tool/src/operations/rebase.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { GitErrorCode } from "../errors"; -import { renderRebase } from "../render"; -import type { RebaseParams, RebaseResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -async function getConflicts(cwd?: string): Promise { - const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); - if (result.error) return []; - return result.stdout.split("\n").filter(Boolean); -} - -export async function rebase(params: RebaseParams, cwd?: string): Promise | ToolError> { - const args = ["rebase"]; - if (params.abort) args.push("--abort"); - if (params.continue) args.push("--continue"); - if (params.skip) args.push("--skip"); - if (!params.abort && !params.continue && !params.skip) { - if (params.onto) args.push("--onto", params.onto); - if (params.upstream) args.push(params.upstream); - } - - const result = await git(args, { cwd }); - if (result.error) { - if (result.error.code === GitErrorCode.REBASE_CONFLICT || result.error.code === GitErrorCode.MERGE_CONFLICT) { - const conflicts = await getConflicts(cwd); - const data: RebaseResult = { status: "conflict", conflicts }; - return { data, _rendered: renderRebase(data) }; - } - return { error: result.error.message, code: result.error.code }; - } - - const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); - const status: RebaseResult["status"] = output.includes("up to date") ? "up-to-date" : "success"; - const data: RebaseResult = { status }; - return { data, _rendered: renderRebase(data) }; -} diff --git a/packages/git-tool/src/operations/restore.ts b/packages/git-tool/src/operations/restore.ts deleted file mode 100644 index 4dc6f4a99..000000000 --- a/packages/git-tool/src/operations/restore.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { renderRestore } from "../render"; -import type { RestoreParams, RestoreResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -export async function restore(params: RestoreParams, cwd?: string): Promise | ToolError> { - const args = ["restore"]; - if (params.staged) args.push("--staged"); - if (params.worktree) args.push("--worktree"); - if (params.source) args.push(`--source=${params.source}`); - args.push("--", ...params.paths); - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - const data: RestoreResult = { restored: params.paths }; - return { data, _rendered: renderRestore(data) }; -} diff --git a/packages/git-tool/src/operations/show.ts b/packages/git-tool/src/operations/show.ts deleted file mode 100644 index 5f59e1ee3..000000000 --- a/packages/git-tool/src/operations/show.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { parseDiff } from "../parsers/diff-parser"; -import { parseLog } from "../parsers/log-parser"; -import { renderShowCommit, renderShowFile } from "../render"; -import type { ShowCommitResult, ShowFileResult, ShowParams, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -const LOG_FORMAT = "%H%x00%h%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%P%x00%s%x00%b%x1e"; - -const MAX_SHOW_LINES = 2000; -const MAX_SHOW_BYTES = 200_000; - -export async function show( - params: ShowParams, - cwd?: string, -): Promise | ToolError> { - if (params.path) { - const result = await git(["show", `${params.ref}:${params.path}`], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - let content = result.stdout; - let truncated = false; - - // Ambiguity: no truncation limits specified for show file; defaulting to 2000 lines or 200KB. - if (content.length > MAX_SHOW_BYTES) { - content = content.slice(0, MAX_SHOW_BYTES); - truncated = true; - } - - let lines = content.split("\n"); - if (lines.length > MAX_SHOW_LINES) { - lines = lines.slice(0, MAX_SHOW_LINES); - content = lines.join("\n"); - truncated = true; - } - - if (params.lines) { - const start = Math.max(1, params.lines.start); - const end = Math.max(start, params.lines.end); - const slice = lines.slice(start - 1, end); - content = slice.join("\n"); - truncated = false; - } - - const data: ShowFileResult = { - path: params.path, - ref: params.ref, - content, - truncated, - }; - return { data, _rendered: renderShowFile(data) }; - } - - const commitResult = await git(["show", "-s", `--format=${LOG_FORMAT}`, params.ref], { cwd }); - if (commitResult.error) { - return { error: commitResult.error.message, code: commitResult.error.code }; - } - const commits = parseLog(commitResult.stdout); - const commit = commits[0]; - if (!commit) { - return { error: "Commit not found", code: "REF_NOT_FOUND" }; - } - - let diffData: ShowCommitResult["diff"] | undefined; - if (params.diff || params.stat) { - const diffResult = await git(["show", params.ref, "--format="], { cwd }); - if (diffResult.error) { - return { error: diffResult.error.message, code: diffResult.error.code }; - } - const parsed = parseDiff(diffResult.stdout); - const files = parsed.files.map((file) => { - if (!params.diff) { - delete file.hunks; - } - return file; - }); - const stats = files.reduce( - (acc, file) => { - acc.filesChanged += 1; - acc.insertions += file.additions; - acc.deletions += file.deletions; - return acc; - }, - { filesChanged: 0, insertions: 0, deletions: 0 }, - ); - const diff: ShowCommitResult["diff"] = { - files, - stats, - truncated: parsed.truncated, - truncatedFiles: parsed.truncatedFiles.length > 0 ? parsed.truncatedFiles : undefined, - }; - diffData = diff; - } - - const data: ShowCommitResult = { - commit, - ...(diffData ? { diff: diffData } : {}), - }; - - return { data, _rendered: renderShowCommit(data) }; -} diff --git a/packages/git-tool/src/operations/stash.ts b/packages/git-tool/src/operations/stash.ts deleted file mode 100644 index 0bda5e235..000000000 --- a/packages/git-tool/src/operations/stash.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { StashEntry, StashParams, StashResult, ToolError, ToolResult } from "../types"; -import { git, parseShortstat } from "../utils"; - -function parseStashIndex(ref: string): number { - const match = ref.match(/stash@\{(\d+)\}/); - return match ? Number.parseInt(match[1], 10) : -1; -} - -function parseBranch(subject: string): string { - const match = subject.match(/on ([^:]+):/i); - return match ? match[1] : ""; -} - -export async function stash(params: StashParams, cwd?: string): Promise | ToolError> { - const action = params.action ?? "list"; - - if (action === "list") { - const result = await git(["stash", "list", "--date=iso-strict", "--format=%gd%x00%gs%x00%cd"], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const stashes: StashEntry[] = []; - for (const line of result.stdout.split("\n")) { - if (!line) continue; - const parts = line.split("\x00"); - if (parts.length < 3) continue; - const ref = parts[0]; - const message = parts[1]; - const date = parts[2]; - stashes.push({ - index: parseStashIndex(ref), - message, - branch: parseBranch(message), - date, - }); - } - return { data: { stashes }, _rendered: `Stashes: ${stashes.length}` }; - } - - if (action === "show") { - const index = params.index ?? 0; - const ref = `stash@{${index}}`; - const statResult = await git(["stash", "show", "--shortstat", ref], { cwd }); - if (statResult.error) { - return { error: statResult.error.message, code: statResult.error.code }; - } - const statLine = statResult.stdout.split("\n").find((line) => line.includes("files changed")); - const statsParsed = statLine ? parseShortstat(statLine) : null; - const stats = { - files: statsParsed?.files ?? 0, - additions: statsParsed?.additions ?? 0, - deletions: statsParsed?.deletions ?? 0, - }; - const filesResult = await git(["stash", "show", "--name-only", ref], { cwd }); - if (filesResult.error) { - return { error: filesResult.error.message, code: filesResult.error.code }; - } - const files = filesResult.stdout.split("\n").filter(Boolean); - return { data: { stats, files }, _rendered: `Stash ${index} (${files.length} files)` }; - } - - if (action === "push") { - const args = ["stash", "push"]; - if (params.message) args.push("-m", params.message); - if (params.include_untracked) args.push("-u"); - if (params.keep_index) args.push("--keep-index"); - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - return { data: { status: "success" }, _rendered: "Stash saved" }; - } - - if (action === "pop" || action === "apply" || action === "drop") { - const index = params.index ?? 0; - const ref = `stash@{${index}}`; - const args = ["stash", action, ref]; - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - return { data: { status: "success" }, _rendered: `Stash ${action} ${index}` }; - } - - return { error: `Unknown stash action: ${action}` }; -} diff --git a/packages/git-tool/src/operations/status.ts b/packages/git-tool/src/operations/status.ts deleted file mode 100644 index 8a12a2c2e..000000000 --- a/packages/git-tool/src/operations/status.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { parseStatus } from "../parsers/status-parser"; -import { renderStatus } from "../render"; -import type { StatusParams, StatusResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -export async function status(params: StatusParams, cwd?: string): Promise | ToolError> { - const args = ["status", "--porcelain=v2", "--branch", "--ahead-behind"]; - if (params.ignored) args.push("--ignored"); - - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - - const parsed = parseStatus(result.stdout, Boolean(params.ignored)); - let finalResult = parsed; - - if (params.only) { - const base: StatusResult = { - branch: parsed.branch, - upstream: parsed.upstream, - ahead: parsed.ahead, - behind: parsed.behind, - staged: [], - modified: [], - untracked: [], - conflicts: [], - }; - switch (params.only) { - case "branch": - finalResult = base; - break; - case "modified": - finalResult = { ...base, modified: parsed.modified }; - break; - case "staged": - finalResult = { ...base, staged: parsed.staged }; - break; - case "untracked": - finalResult = { ...base, untracked: parsed.untracked }; - break; - case "conflicts": - finalResult = { ...base, conflicts: parsed.conflicts }; - break; - case "sync": - finalResult = base; - break; - default: - finalResult = parsed; - } - } - - return { data: finalResult, _rendered: renderStatus(finalResult) }; -} diff --git a/packages/git-tool/src/operations/tag.ts b/packages/git-tool/src/operations/tag.ts deleted file mode 100644 index ba7bfd247..000000000 --- a/packages/git-tool/src/operations/tag.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { renderTag } from "../render"; -import type { TagInfo, TagParams, TagResult, ToolError, ToolResult } from "../types"; -import { git } from "../utils"; - -export async function tag(params: TagParams, cwd?: string): Promise | ToolError> { - const action = params.action ?? "list"; - - if (action === "list") { - const result = await git( - [ - "for-each-ref", - "refs/tags", - "--format=%(refname:short)%x00%(objectname)%x00%(taggername)%x00%(taggerdate:iso-strict)%x00%(subject)%x00%(objecttype)", - ], - { cwd }, - ); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const tags: TagInfo[] = []; - for (const line of result.stdout.split("\n")) { - if (!line) continue; - const parts = line.split("\x00"); - if (parts.length < 6) continue; - const [name, sha, taggerName, taggerDate, subject, objectType] = parts; - const annotated = objectType === "tag"; - const tagger = taggerName && taggerDate ? { name: taggerName, date: taggerDate } : undefined; - tags.push({ name, sha, message: subject || undefined, tagger, annotated }); - } - const data: TagResult = { tags }; - return { data, _rendered: renderTag(data) }; - } - - if (action === "create") { - if (!params.name) { - return { error: "Tag name required" }; - } - const args = ["tag"]; - if (params.force) args.push("-f"); - if (params.sign) args.push("-s"); - if (params.message) { - args.push("-a", "-m", params.message); - } - args.push(params.name); - if (params.ref) args.push(params.ref); - const result = await git(args, { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: TagResult = { status: "success" }; - return { data, _rendered: renderTag(data) }; - } - - if (action === "delete") { - if (!params.name) { - return { error: "Tag name required" }; - } - const result = await git(["tag", "-d", params.name], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: TagResult = { status: "success" }; - return { data, _rendered: renderTag(data) }; - } - - if (action === "push") { - if (!params.name) { - return { error: "Tag name required" }; - } - const result = await git(["push", "origin", params.name], { cwd }); - if (result.error) { - return { error: result.error.message, code: result.error.code }; - } - const data: TagResult = { status: "success" }; - return { data, _rendered: renderTag(data) }; - } - - return { error: `Unknown tag action: ${action}` }; -} diff --git a/packages/git-tool/src/parsers/blame-parser.ts b/packages/git-tool/src/parsers/blame-parser.ts deleted file mode 100644 index 08f6db405..000000000 --- a/packages/git-tool/src/parsers/blame-parser.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { BlameLine } from "../types"; - -interface BlameMeta { - sha: string; - shortSha: string; - author: string; - date: string; - lineNo: number; - original?: { sha: string; path: string; lineNo: number }; -} - -function formatDate(timestamp: string): string { - const seconds = Number.parseInt(timestamp, 10); - if (!Number.isFinite(seconds)) return ""; - return new Date(seconds * 1000).toISOString(); -} - -export function parseBlame(output: string): BlameLine[] { - const lines = output.split("\n"); - const result: BlameLine[] = []; - let current: BlameMeta | null = null; - let remaining = 0; - let currentFilename = ""; - - for (const line of lines) { - if (!line) continue; - if (line.startsWith("\t")) { - if (!current) continue; - result.push({ - lineNo: current.lineNo, - sha: current.sha, - shortSha: current.shortSha, - author: current.author, - date: current.date, - content: line.slice(1), - original: current.original, - }); - current.lineNo += 1; - remaining -= 1; - if (remaining <= 0) { - current = null; - } - continue; - } - - const headerMatch = line.match(/^([0-9a-f]{40}) (\d+) (\d+) (\d+)/); - if (headerMatch) { - const sha = headerMatch[1]; - const finalLine = Number.parseInt(headerMatch[3], 10); - remaining = Number.parseInt(headerMatch[4], 10); - current = { - sha, - shortSha: sha.slice(0, 7), - author: "", - date: "", - lineNo: finalLine, - }; - continue; - } - - if (!current) continue; - - if (line.startsWith("author ")) { - current.author = line.slice(7).trim(); - continue; - } - if (line.startsWith("author-time ")) { - current.date = formatDate(line.slice(12).trim()); - continue; - } - if (line.startsWith("previous ")) { - const parts = line.split(" "); - if (parts.length >= 3) { - current.original = { - sha: parts[1], - path: currentFilename, - lineNo: current.lineNo, - }; - } - continue; - } - if (line.startsWith("filename ")) { - currentFilename = line.slice(9).trim(); - if (current.original) { - current.original.path = currentFilename; - } - } - } - - return result; -} diff --git a/packages/git-tool/src/parsers/diff-parser.ts b/packages/git-tool/src/parsers/diff-parser.ts deleted file mode 100644 index 1672b6b4f..000000000 --- a/packages/git-tool/src/parsers/diff-parser.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { DiffLine, FileDiff, Hunk } from "../types"; - -export interface DiffParseResult { - files: FileDiff[]; - truncated: boolean; - truncatedFiles: string[]; -} - -export interface DiffParseOptions { - maxLines?: number; -} - -function parseHunkHeader(header: string): { oldStart: number; oldCount: number; newStart: number; newCount: number } { - const match = header.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); - if (!match) { - return { oldStart: 0, oldCount: 0, newStart: 0, newCount: 0 }; - } - return { - oldStart: Number.parseInt(match[1], 10), - oldCount: Number.parseInt(match[2] ?? "1", 10), - newStart: Number.parseInt(match[3], 10), - newCount: Number.parseInt(match[4] ?? "1", 10), - }; -} - -export function parseDiff(raw: string, options?: DiffParseOptions): DiffParseResult { - const maxLines = options?.maxLines ?? Number.POSITIVE_INFINITY; - const lines = raw.split("\n"); - const files: FileDiff[] = []; - const truncatedFiles: string[] = []; - let truncated = false; - let lineCount = 0; - - let current: FileDiff | null = null; - let currentHunk: Hunk | null = null; - let oldLine = 0; - let newLine = 0; - - const finalizeHunk = () => { - if (current && currentHunk) { - current.hunks = current.hunks ?? []; - current.hunks.push(currentHunk); - currentHunk = null; - } - }; - - const finalizeFile = () => { - finalizeHunk(); - if (current) { - files.push(current); - current = null; - } - }; - - for (const line of lines) { - if (line.startsWith("diff --git ")) { - finalizeFile(); - const match = line.match(/^diff --git a\/(.*) b\/(.*)$/); - const path = match ? match[2] : line.slice("diff --git ".length).trim(); - const oldPath = match ? match[1] : undefined; - current = { - path, - oldPath, - status: "modified", - binary: false, - additions: 0, - deletions: 0, - }; - continue; - } - - if (!current) continue; - - if (line.startsWith("new file mode")) { - current.status = "added"; - continue; - } - if (line.startsWith("deleted file mode")) { - current.status = "deleted"; - continue; - } - if (line.startsWith("rename from ")) { - current.status = "renamed"; - current.oldPath = line.slice("rename from ".length).trim(); - continue; - } - if (line.startsWith("rename to ")) { - current.path = line.slice("rename to ".length).trim(); - continue; - } - if (line.startsWith("copy from ")) { - current.status = "copied"; - current.oldPath = line.slice("copy from ".length).trim(); - continue; - } - if (line.startsWith("copy to ")) { - current.path = line.slice("copy to ".length).trim(); - continue; - } - if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) { - current.binary = true; - continue; - } - - if (line.startsWith("@@ ")) { - finalizeHunk(); - const header = line; - const ranges = parseHunkHeader(header); - currentHunk = { - oldStart: ranges.oldStart, - oldCount: ranges.oldCount, - newStart: ranges.newStart, - newCount: ranges.newCount, - header, - lines: [], - }; - oldLine = ranges.oldStart; - newLine = ranges.newStart; - continue; - } - - if (!currentHunk) { - continue; - } - - if (lineCount >= maxLines) { - if (!truncated) { - truncated = true; - truncatedFiles.push(current.path); - } - continue; - } - - if (line.startsWith("\\")) { - continue; - } - - let diffLine: DiffLine | null = null; - if (line.startsWith("+") && !line.startsWith("+++")) { - diffLine = { type: "add", content: line.slice(1), newLineNo: newLine }; - newLine += 1; - current.additions += 1; - } else if (line.startsWith("-") && !line.startsWith("---")) { - diffLine = { type: "delete", content: line.slice(1), oldLineNo: oldLine }; - oldLine += 1; - current.deletions += 1; - } else if (line.startsWith(" ")) { - diffLine = { type: "context", content: line.slice(1), oldLineNo: oldLine, newLineNo: newLine }; - oldLine += 1; - newLine += 1; - } - - if (diffLine) { - currentHunk.lines.push(diffLine); - lineCount += 1; - } - } - - finalizeFile(); - - return { files, truncated, truncatedFiles }; -} diff --git a/packages/git-tool/src/parsers/log-parser.ts b/packages/git-tool/src/parsers/log-parser.ts deleted file mode 100644 index 029bfe595..000000000 --- a/packages/git-tool/src/parsers/log-parser.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Commit } from "../types"; - -const RECORD_SEPARATOR = "\x1e"; -const FIELD_SEPARATOR = "\x00"; - -export function parseLog(output: string): Commit[] { - const records = output.split(RECORD_SEPARATOR).filter((record) => record.trim().length > 0); - const commits: Commit[] = []; - - for (const record of records) { - const fields = record.split(FIELD_SEPARATOR); - if (fields.length < 11) continue; - - const [ - sha, - shortSha, - authorName, - authorEmail, - authorDate, - committerName, - committerEmail, - committerDate, - parentsRaw, - subject, - body, - ] = fields; - - const message = body ? `${subject}\n\n${body}` : subject; - const parents = parentsRaw ? parentsRaw.split(" ").filter(Boolean) : []; - - commits.push({ - sha, - shortSha, - author: { name: authorName, email: authorEmail, date: authorDate }, - committer: { name: committerName, email: committerEmail, date: committerDate }, - message, - subject, - parents, - }); - } - - return commits; -} diff --git a/packages/git-tool/src/parsers/status-parser.ts b/packages/git-tool/src/parsers/status-parser.ts deleted file mode 100644 index 76f13ba9c..000000000 --- a/packages/git-tool/src/parsers/status-parser.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { FileStatus, StatusResult } from "../types"; - -function mapStatus(code: string, path: string, oldPath?: string): FileStatus { - const statusMap: Record = { - A: "added", - M: "modified", - D: "deleted", - R: "renamed", - C: "copied", - T: "modified", - U: "modified", - }; - return { path, status: statusMap[code] ?? "modified", oldPath }; -} - -export function parseStatus(output: string, includeIgnored: boolean): StatusResult { - const lines = output.split("\n"); - const result: StatusResult = { - branch: "", - upstream: null, - ahead: 0, - behind: 0, - staged: [], - modified: [], - untracked: [], - conflicts: [], - }; - if (includeIgnored) { - result.ignored = []; - } - - for (const line of lines) { - if (line.startsWith("# branch.head ")) { - result.branch = line.slice(14).trim(); - continue; - } - if (line.startsWith("# branch.upstream ")) { - result.upstream = line.slice(18).trim(); - continue; - } - if (line.startsWith("# branch.ab ")) { - const match = line.match(/\+(\d+) -?(\d+)/); - if (match) { - result.ahead = Number.parseInt(match[1], 10); - result.behind = Number.parseInt(match[2] ?? "0", 10); - } - continue; - } - - if (line.startsWith("1 ") || line.startsWith("2 ")) { - const parts = line.split(" "); - const xy = parts[1]; - let path = parts.slice(-1)[0] ?? ""; - let oldPath: string | undefined; - - if (line.startsWith("2 ")) { - const [beforeTab, afterTab] = line.split("\t"); - if (afterTab) { - const preParts = beforeTab.split(" "); - path = preParts[preParts.length - 1]; - oldPath = afterTab.trim(); - } - } - - if (xy[0] !== ".") { - result.staged.push(mapStatus(xy[0], path, oldPath)); - } - if (xy[1] !== ".") { - result.modified.push(mapStatus(xy[1], path, oldPath)); - } - continue; - } - - if (line.startsWith("u ")) { - const path = line.split(" ").slice(-1)[0]; - if (path) { - result.conflicts.push(path); - } - continue; - } - - if (line.startsWith("? ")) { - result.untracked.push(line.slice(2)); - continue; - } - - if (includeIgnored && line.startsWith("! ")) { - result.ignored?.push(line.slice(2)); - } - } - - return result; -} diff --git a/packages/git-tool/src/render.ts b/packages/git-tool/src/render.ts deleted file mode 100644 index 18ea7968d..000000000 --- a/packages/git-tool/src/render.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { - AddResult, - BlameResult, - BranchListResult, - CherryPickResult, - CommitResult, - DiffResult, - FetchResult, - LogResult, - MergeResult, - PullResult, - PushResult, - RebaseResult, - ReleaseResult, - RestoreResult, - ShowCommitResult, - ShowFileResult, - StatusResult, - TagResult, -} from "./types"; - -function renderFileList(title: string, files: string[]): string { - if (files.length === 0) return ""; - return `${title}:\n${files.map((file) => `- ${file}`).join("\n")}`; -} - -export function renderStatus(result: StatusResult): string { - const parts: string[] = []; - const sync = result.upstream - ? `(upstream ${result.upstream}, ahead ${result.ahead}, behind ${result.behind})` - : "(no upstream)"; - parts.push(`Branch: ${result.branch} ${sync}`); - - if (result.staged.length > 0) { - parts.push( - `Staged (${result.staged.length}):\n${result.staged.map((file) => `- ${file.path} (${file.status})`).join("\n")}`, - ); - } - if (result.modified.length > 0) { - parts.push( - `Modified (${result.modified.length}):\n${result.modified.map((file) => `- ${file.path} (${file.status})`).join("\n")}`, - ); - } - if (result.untracked.length > 0) { - parts.push(renderFileList(`Untracked (${result.untracked.length})`, result.untracked)); - } - if (result.conflicts.length > 0) { - parts.push(renderFileList(`Conflicts (${result.conflicts.length})`, result.conflicts)); - } - if (result.ignored && result.ignored.length > 0) { - parts.push(renderFileList(`Ignored (${result.ignored.length})`, result.ignored)); - } - - return parts.join("\n\n"); -} - -export function renderDiff(result: DiffResult): string { - const header = `${result.stats.filesChanged} files changed, ${result.stats.insertions} insertions(+), ${result.stats.deletions} deletions(-)`; - const files = result.files.map((file) => { - const status = file.status.toUpperCase(); - return `- ${status} ${file.path} (+${file.additions}/-${file.deletions})`; - }); - const truncatedNote = result.truncated ? "\n\nDiff output truncated." : ""; - return [header, ...files].join("\n") + truncatedNote; -} - -export function renderLog(result: LogResult): string { - if (result.commits.length === 0) return "No commits found."; - const lines = result.commits.map((commit) => `${commit.shortSha} ${commit.subject}`); - const more = result.hasMore ? "\n(more commits available)" : ""; - return lines.join("\n") + more; -} - -export function renderShowCommit(result: ShowCommitResult): string { - const commit = result.commit; - const header = `${commit.shortSha} ${commit.subject}`; - if (!result.diff) return header; - return `${header}\n\n${renderDiff(result.diff)}`; -} - -export function renderShowFile(result: ShowFileResult): string { - const truncated = result.truncated ? "\n\n[truncated]" : ""; - return `${result.content}${truncated}`; -} - -export function renderBlame(result: BlameResult): string { - return result.lines.map((line) => `${line.shortSha} ${line.lineNo} ${line.author}: ${line.content}`).join("\n"); -} - -export function renderBranchList(result: BranchListResult): string { - const lines: string[] = []; - lines.push(`Current: ${result.current}`); - lines.push("Local:"); - for (const branch of result.local) { - const track = branch.upstream - ? ` (${branch.upstream} ahead ${branch.ahead ?? 0} behind ${branch.behind ?? 0}${branch.gone ? ", gone" : ""})` - : ""; - lines.push(`- ${branch.name}${track}`); - } - if (result.remote && result.remote.length > 0) { - lines.push("Remote:"); - for (const branch of result.remote) { - lines.push(`- ${branch.name}`); - } - } - return lines.join("\n"); -} - -export function renderAdd(result: AddResult): string { - return `Staged ${result.staged.length} files`; -} - -export function renderRestore(result: RestoreResult): string { - return `Restored ${result.restored.length} files`; -} - -export function renderCommit(result: CommitResult): string { - return `${result.shortSha} ${result.subject}`; -} - -export function renderMerge(result: MergeResult): string { - if (result.status === "conflict") { - return `Merge conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; - } - return `Merge ${result.status}`; -} - -export function renderRebase(result: RebaseResult): string { - if (result.status === "conflict") { - return `Rebase conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; - } - return `Rebase ${result.status}`; -} - -export function renderCherryPick(result: CherryPickResult): string { - if (result.status === "conflict") { - return `Cherry-pick conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; - } - return `Cherry-pick applied ${result.appliedCommits?.length ?? 0} commits`; -} - -export function renderFetch(result: FetchResult): string { - const lines = result.updated.map((entry) => `- ${entry.ref} ${entry.oldSha} -> ${entry.newSha}`); - if (result.pruned && result.pruned.length > 0) { - lines.push(`Pruned: ${result.pruned.join(", ")}`); - } - return lines.length > 0 ? lines.join("\n") : "Fetch completed"; -} - -export function renderPull(result: PullResult): string { - if (result.status === "conflict") { - return `Pull conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; - } - if (result.status === "up-to-date") return "Already up to date."; - return `Pulled ${result.commits ?? 0} commits`; -} - -export function renderPush(result: PushResult): string { - return `Pushed ${result.commits} commits to ${result.remote}/${result.branch}`; -} - -export function renderRelease(result: ReleaseResult): string { - if ("releases" in result) { - return `Releases: ${result.releases.length}`; - } - if ("tag" in result) { - return `Release ${result.tag}`; - } - return "Release operation completed"; -} - -export function renderTag(result: TagResult): string { - if ("tags" in result) { - return `Tags: ${result.tags.length}`; - } - return "Tag operation completed"; -} diff --git a/packages/git-tool/src/safety/guards.ts b/packages/git-tool/src/safety/guards.ts deleted file mode 100644 index 7385d1a57..000000000 --- a/packages/git-tool/src/safety/guards.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Operation, SafetyCheck, SafetyResult } from "../types"; -import { git } from "../utils"; -import { defaultPolicy, isProtectedBranch } from "./policies"; - -const sessionCommits = new Set(); - -export function markCommitCreated(sha: string): void { - sessionCommits.add(sha); -} - -async function getCurrentBranch(cwd?: string): Promise { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }); - if (result.error) return ""; - return result.stdout.trim(); -} - -async function getHeadSha(cwd?: string): Promise { - const result = await git(["rev-parse", "HEAD"], { cwd }); - if (result.error) return ""; - return result.stdout.trim(); -} - -async function getBranchSync(cwd?: string): Promise<{ upstream: string | null; ahead: number; behind: number }> { - const result = await git(["status", "--porcelain=v2", "--branch", "--ahead-behind"], { cwd }); - if (result.error) return { upstream: null, ahead: 0, behind: 0 }; - - let upstream: string | null = null; - let ahead = 0; - let behind = 0; - for (const line of result.stdout.split("\n")) { - if (line.startsWith("# branch.upstream ")) { - upstream = line.slice(18).trim(); - } - if (line.startsWith("# branch.ab ")) { - const match = line.match(/\+(\d+) -?(\d+)/); - if (match) { - ahead = Number.parseInt(match[1], 10); - behind = Number.parseInt(match[2] ?? "0", 10); - } - } - } - return { upstream, ahead, behind }; -} - -async function isHeadPushed(cwd?: string): Promise { - const sync = await getBranchSync(cwd); - if (!sync.upstream) return false; - return sync.ahead === 0; -} - -function evaluateChecks(checks: SafetyCheck[]): SafetyResult { - const blocked = checks.find((check) => check.level === "block"); - if (blocked) { - return { - blocked: true, - confirm: false, - message: blocked.message, - suggestion: blocked.suggestion, - override: blocked.override, - warnings: [], - }; - } - const confirm = checks.find((check) => check.level === "confirm"); - if (confirm) { - return { - blocked: false, - confirm: true, - message: confirm.message, - suggestion: confirm.suggestion, - override: confirm.override, - warnings: [], - }; - } - const warnings = checks.filter((check) => check.level === "warn").map((check) => check.message); - return { - blocked: false, - confirm: false, - warnings, - }; -} - -export async function checkSafety( - operation: Operation, - params: Record, - cwd?: string, -): Promise { - const checks: SafetyCheck[] = []; - - if (operation === "push" && params.force) { - const branch = (params.branch as string | undefined) ?? (await getCurrentBranch(cwd)); - if (branch && isProtectedBranch(branch)) { - checks.push({ - level: defaultPolicy.forcePushMain, - message: `Force push to protected branch '${branch}' is blocked`, - override: "force_override", - }); - } else { - checks.push({ - level: defaultPolicy.forcePush, - message: `Force push to '${branch || "current branch"}' will overwrite remote history`, - suggestion: "Consider using force_with_lease instead", - }); - } - } - - if (operation === "commit" && params.amend) { - const pushed = await isHeadPushed(cwd); - const headSha = await getHeadSha(cwd); - if (pushed && headSha && !sessionCommits.has(headSha)) { - checks.push({ - level: defaultPolicy.amendPushed, - message: "Cannot amend: HEAD has been pushed to remote", - suggestion: "Create a new commit instead", - }); - } - } - - if (operation === "rebase") { - const pushed = await isHeadPushed(cwd); - if (pushed) { - checks.push({ - level: defaultPolicy.rebasePushed, - message: "Cannot rebase: HEAD has been pushed to remote", - suggestion: "Merge instead of rebasing pushed commits", - }); - } - } - - if (operation === "restore" && params.worktree && !params.staged) { - checks.push({ - level: defaultPolicy.discardChanges, - message: "Restoring worktree will discard local changes", - }); - } - - if (operation === "branch" && params.action === "delete") { - checks.push({ - level: defaultPolicy.deleteBranch, - message: "Deleting a branch will remove its ref", - }); - } - - return evaluateChecks(checks); -} diff --git a/packages/git-tool/src/safety/policies.ts b/packages/git-tool/src/safety/policies.ts deleted file mode 100644 index f1d77b600..000000000 --- a/packages/git-tool/src/safety/policies.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SafetyPolicy } from "../types"; - -export const defaultPolicy: SafetyPolicy = { - forcePush: "confirm", - forcePushMain: "block", - hardReset: "confirm", - discardChanges: "warn", - deleteBranch: "warn", - amendPushed: "block", - rebasePushed: "block", -}; - -export const defaultProtectedBranches = ["main", "master", "develop", "release/*"]; - -export function isProtectedBranch(branch: string): boolean { - return defaultProtectedBranches.some((pattern) => { - if (pattern.endsWith("/")) { - return branch.startsWith(pattern); - } - if (pattern.endsWith("/*")) { - return branch.startsWith(pattern.slice(0, -1)); - } - return branch === pattern; - }); -} diff --git a/packages/git-tool/src/types.ts b/packages/git-tool/src/types.ts deleted file mode 100644 index c6cf5eef2..000000000 --- a/packages/git-tool/src/types.ts +++ /dev/null @@ -1,668 +0,0 @@ -export type Operation = - | "status" - | "diff" - | "log" - | "show" - | "blame" - | "branch" - | "add" - | "restore" - | "commit" - | "checkout" - | "merge" - | "rebase" - | "stash" - | "cherry-pick" - | "fetch" - | "pull" - | "push" - | "tag" - | "pr" - | "issue" - | "ci" - | "release"; - -export interface ToolResult { - data: T; - _rendered: string; -} - -export interface ToolConfirm { - confirm: string; - override: string; - _rendered?: string; -} - -export interface ToolError { - error: string; - code?: string; - suggestion?: string; - _rendered?: string; -} - -export type ToolResponse = ToolResult | ToolConfirm | ToolError; - -export interface FileStatus { - path: string; - status: "added" | "modified" | "deleted" | "renamed" | "copied"; - oldPath?: string; -} - -export interface StatusParams { - operation: "status"; - only?: "branch" | "modified" | "staged" | "untracked" | "conflicts" | "sync"; - ignored?: boolean; -} - -export interface StatusResult { - branch: string; - upstream: string | null; - ahead: number; - behind: number; - staged: FileStatus[]; - modified: FileStatus[]; - untracked: string[]; - conflicts: string[]; - ignored?: string[]; -} - -export type DiffTarget = - | "unstaged" - | "staged" - | "head" - | { - from: string; - to?: string; - }; - -export interface DiffParams { - operation: "diff"; - target?: DiffTarget; - paths?: string[]; - stat_only?: boolean; - name_only?: boolean; - context?: number; - max_lines?: number; - ignore_whitespace?: boolean; -} - -export interface DiffLine { - type: "context" | "add" | "delete"; - content: string; - oldLineNo?: number; - newLineNo?: number; -} - -export interface Hunk { - oldStart: number; - oldCount: number; - newStart: number; - newCount: number; - header: string; - lines: DiffLine[]; -} - -export interface FileDiff { - path: string; - oldPath?: string; - status: "added" | "modified" | "deleted" | "renamed" | "copied"; - binary: boolean; - additions: number; - deletions: number; - hunks?: Hunk[]; -} - -export interface DiffResult { - files: FileDiff[]; - stats: { - filesChanged: number; - insertions: number; - deletions: number; - }; - truncated: boolean; - truncatedFiles?: string[]; -} - -export interface LogParams { - operation: "log"; - limit?: number; - ref?: string; - author?: string; - since?: string; - until?: string; - paths?: string[]; - grep?: string; - format?: "oneline" | "short" | "full"; - stat?: boolean; - merges?: boolean; - first_parent?: boolean; -} - -export interface CommitAuthor { - name: string; - email: string; - date: string; -} - -export interface Commit { - sha: string; - shortSha: string; - author: CommitAuthor; - committer: CommitAuthor; - message: string; - subject: string; - parents: string[]; - stats?: { additions: number; deletions: number; files: number }; -} - -export interface LogResult { - commits: Commit[]; - hasMore: boolean; -} - -export interface ShowParams { - operation: "show"; - ref: string; - path?: string; - diff?: boolean; - stat?: boolean; - lines?: { start: number; end: number }; -} - -export interface ShowCommitResult { - commit: Commit; - diff?: DiffResult; -} - -export interface ShowFileResult { - path: string; - ref: string; - content: string; - truncated: boolean; -} - -export interface BlameParams { - operation: "blame"; - path: string; - lines?: { start: number; end: number }; - root?: boolean; - ignore_whitespace?: boolean; -} - -export interface BlameLine { - lineNo: number; - sha: string; - shortSha: string; - author: string; - date: string; - content: string; - original?: { - sha: string; - path: string; - lineNo: number; - }; -} - -export interface BlameResult { - lines: BlameLine[]; -} - -export interface BranchParams { - operation: "branch"; - action?: "list" | "create" | "delete" | "rename" | "current"; - name?: string; - newName?: string; - startPoint?: string; - remotes?: boolean; - force?: boolean; -} - -export interface BranchInfo { - name: string; - sha: string; - upstream?: string; - ahead?: number; - behind?: number; - gone?: boolean; -} - -export interface BranchListResult { - current: string; - local: BranchInfo[]; - remote?: BranchInfo[]; -} - -export interface AddParams { - operation: "add"; - paths?: string[]; - update?: boolean; - all?: boolean; - dry_run?: boolean; -} - -export interface AddResult { - staged: string[]; -} - -export interface RestoreParams { - operation: "restore"; - paths: string[]; - staged?: boolean; - worktree?: boolean; - source?: string; -} - -export interface RestoreResult { - restored: string[]; -} - -export interface CommitParams { - operation: "commit"; - message: string; - all?: boolean; - allow_empty?: boolean; - sign?: boolean; - no_verify?: boolean; - amend?: boolean; -} - -export interface CommitResult { - sha: string; - shortSha: string; - subject: string; - stats: { additions: number; deletions: number; files: number }; -} - -export interface CheckoutParams { - operation: "checkout"; - ref?: string; - create?: boolean; - paths?: string[]; - force?: boolean; -} - -export interface CheckoutResult { - branch?: string; - previous?: string; - restoredFiles?: string[]; -} - -export interface MergeParams { - operation: "merge"; - ref: string; - message?: string; - no_ff?: boolean; - ff_only?: boolean; - squash?: boolean; - abort?: boolean; - continue?: boolean; -} - -export interface MergeResult { - status: "success" | "conflict" | "up-to-date" | "fast-forward"; - sha?: string; - conflicts?: string[]; - mergedCommits?: number; -} - -export interface RebaseParams { - operation: "rebase"; - onto?: string; - upstream?: string; - abort?: boolean; - continue?: boolean; - skip?: boolean; -} - -export interface RebaseResult { - status: "success" | "conflict" | "up-to-date"; - conflicts?: string[]; - rebasedCommits?: number; -} - -export interface StashParams { - operation: "stash"; - action?: "push" | "pop" | "apply" | "drop" | "list" | "show"; - message?: string; - include_untracked?: boolean; - index?: number; - keep_index?: boolean; -} - -export interface StashEntry { - index: number; - message: string; - branch: string; - date: string; -} - -export interface StashListResult { - stashes: StashEntry[]; -} - -export interface StashShowResult { - stats: { additions: number; deletions: number; files: number }; - files: string[]; -} - -export interface StashActionResult { - status: "success"; -} - -export type StashResult = StashListResult | StashShowResult | StashActionResult; - -export interface CherryPickParams { - operation: "cherry-pick"; - commits: string[]; - no_commit?: boolean; - abort?: boolean; - continue?: boolean; -} - -export interface CherryPickResult { - status: "success" | "conflict"; - appliedCommits?: string[]; - conflicts?: string[]; -} - -export interface FetchParams { - operation: "fetch"; - remote?: string; - branch?: string; - all?: boolean; - prune?: boolean; - tags?: boolean; -} - -export interface FetchResult { - updated: { ref: string; oldSha: string; newSha: string }[]; - pruned?: string[]; -} - -export interface PullParams { - operation: "pull"; - remote?: string; - branch?: string; - rebase?: boolean; - ff_only?: boolean; -} - -export interface PullResult { - status: "success" | "conflict" | "up-to-date"; - commits?: number; - conflicts?: string[]; -} - -export interface PushParams { - operation: "push"; - remote?: string; - branch?: string; - set_upstream?: boolean; - tags?: boolean; - force?: boolean; - force_with_lease?: boolean; - delete?: boolean; - force_override?: boolean; -} - -export interface PushResult { - remote: string; - branch: string; - commits: number; - newBranch: boolean; -} - -export interface TagParams { - operation: "tag"; - action?: "list" | "create" | "delete" | "push"; - name?: string; - message?: string; - ref?: string; - force?: boolean; - sign?: boolean; -} - -export interface TagInfo { - name: string; - sha: string; - message?: string; - tagger?: { name: string; date: string }; - annotated: boolean; -} - -export interface TagListResult { - tags: TagInfo[]; -} - -export interface TagActionResult { - status: "success"; -} - -export type TagResult = TagListResult | TagActionResult; - -export interface PRParams { - operation: "pr"; - action: "list" | "view" | "create" | "checkout" | "diff" | "merge" | "close" | "ready" | "review"; - number?: number; - title?: string; - body?: string; - base?: string; - head?: string; - draft?: boolean; - state?: "open" | "closed" | "merged" | "all"; - author?: string; - limit?: number; - merge_method?: "merge" | "squash" | "rebase"; - review_action?: "approve" | "request-changes" | "comment"; - review_body?: string; -} - -export interface PRInfo { - number: number; - title: string; - state: string; - author: string; - branch: string; - base: string; - url: string; - createdAt: string; - updatedAt: string; - additions: number; - deletions: number; - commits: number; - reviewDecision?: string; - checks?: { passing: number; failing: number; pending: number }; -} - -export interface PRListResult { - prs: PRInfo[]; -} - -export interface PRCreateResult { - number: number; - url: string; -} - -export interface PRActionResult { - status: "success"; - diff?: string; -} - -export type PRResult = PRListResult | PRCreateResult | PRActionResult | { pr: PRInfo }; - -export interface IssueParams { - operation: "issue"; - action: "list" | "view" | "create" | "close" | "reopen" | "comment"; - number?: number; - title?: string; - body?: string; - state?: "open" | "closed" | "all"; - labels?: string[]; - assignee?: string; - limit?: number; - comment_body?: string; -} - -export interface IssueInfo { - number: number; - title: string; - state: string; - author: string; - body: string; - labels: string[]; - assignees: string[]; - url: string; - createdAt: string; - comments: number; -} - -export interface IssueListResult { - issues: IssueInfo[]; -} - -export interface IssueCreateResult { - number: number; - url: string; -} - -export interface IssueActionResult { - status: "success"; -} - -export type IssueResult = IssueListResult | IssueCreateResult | IssueActionResult | { issue: IssueInfo }; - -export interface CIParams { - operation: "ci"; - action: "list" | "view" | "watch" | "run" | "cancel" | "rerun"; - workflow?: string; - run_id?: number; - limit?: number; - branch?: string; - inputs?: Record; - logs_failed?: boolean; -} - -export interface RunInfo { - id: number; - name: string; - status: "queued" | "in_progress" | "completed"; - conclusion?: "success" | "failure" | "cancelled" | "skipped"; - branch: string; - sha: string; - url: string; - createdAt: string; - updatedAt: string; -} - -export interface RunListResult { - runs: RunInfo[]; -} - -export interface JobInfo { - id: number; - name: string; - status: string; - conclusion?: string; - steps: { name: string; status: string; conclusion?: string }[]; -} - -export interface RunViewResult { - run: RunInfo; - jobs: JobInfo[]; - logs?: string; -} - -export interface CIActionResult { - status: "success"; -} - -export type CIResult = RunListResult | RunViewResult | CIActionResult; - -export interface ReleaseParams { - operation: "release"; - action: "list" | "view" | "create" | "delete" | "upload"; - tag?: string; - title?: string; - notes?: string; - generate_notes?: boolean; - draft?: boolean; - prerelease?: boolean; - target?: string; - assets?: string[]; - limit?: number; -} - -export interface ReleaseInfo { - tag: string; - name: string; - body: string; - draft: boolean; - prerelease: boolean; - createdAt: string; - publishedAt: string; - url: string; - assets: { name: string; size: number; downloadCount: number }[]; -} - -export interface ReleaseListResult { - releases: ReleaseInfo[]; -} - -export interface ReleaseActionResult { - status: "success"; - url?: string; -} - -export type ReleaseResult = ReleaseListResult | ReleaseInfo | ReleaseActionResult; - -export type GitParams = - | StatusParams - | DiffParams - | LogParams - | ShowParams - | BlameParams - | BranchParams - | AddParams - | RestoreParams - | CommitParams - | CheckoutParams - | MergeParams - | RebaseParams - | StashParams - | CherryPickParams - | FetchParams - | PullParams - | PushParams - | TagParams - | PRParams - | IssueParams - | CIParams - | ReleaseParams; - -export type SafetyLevel = "safe" | "warn" | "confirm" | "block"; - -export interface SafetyPolicy { - forcePush: SafetyLevel; - forcePushMain: SafetyLevel; - hardReset: SafetyLevel; - discardChanges: SafetyLevel; - deleteBranch: SafetyLevel; - amendPushed: SafetyLevel; - rebasePushed: SafetyLevel; -} - -export interface SafetyCheck { - level: SafetyLevel; - message: string; - suggestion?: string; - override?: string; -} - -export interface SafetyResult { - blocked: boolean; - confirm: boolean; - message?: string; - suggestion?: string; - override?: string; - warnings: string[]; -} diff --git a/packages/git-tool/src/utils.ts b/packages/git-tool/src/utils.ts deleted file mode 100644 index ba6a4ef87..000000000 --- a/packages/git-tool/src/utils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { Subprocess } from "bun"; -import { detectGhError, detectGitError, GitError, GitErrorCode } from "./errors"; - -export interface ExecResult { - stdout: string; - stderr: string; - exitCode: number; - error?: GitError | null; -} - -export interface ExecOptions { - cwd?: string; - signal?: AbortSignal; - timeout?: number; -} - -export async function exec(command: string, args: string[], options?: ExecOptions): Promise { - const cwd = options?.cwd ?? process.cwd(); - const proc: Subprocess = Bun.spawn([command, ...args], { - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - let killed = false; - let timeoutId: ReturnType | undefined; - - const killProcess = () => { - if (!killed) { - killed = true; - proc.kill(); - setTimeout(() => { - try { - proc.kill(9); - } catch { - // Ignore if already dead. - } - }, 5000); - } - }; - - if (options?.signal) { - if (options.signal.aborted) { - killProcess(); - } else { - options.signal.addEventListener("abort", killProcess, { once: true }); - } - } - - if (options?.timeout && options.timeout > 0) { - timeoutId = setTimeout(() => { - killProcess(); - }, options.timeout); - } - - const [stdout, stderr, exitCode] = await Promise.all([ - (proc.stdout as ReadableStream).text(), - (proc.stderr as ReadableStream).text(), - proc.exited, - ]); - - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) { - options.signal.removeEventListener("abort", killProcess); - } - - return { stdout, stderr, exitCode: exitCode ?? 0 }; -} - -export async function git(args: string[], options?: ExecOptions): Promise { - const gitPath = Bun.which("git"); - if (!gitPath) { - return { - stdout: "", - stderr: "git not found", - exitCode: 127, - error: new GitError("git is not installed", GitErrorCode.UNKNOWN), - }; - } - const result = await exec(gitPath, args, options); - return { ...result, error: detectGitError(result.stderr, result.exitCode) }; -} - -export async function gh(args: string[], options?: ExecOptions): Promise { - const ghPath = Bun.which("gh"); - if (!ghPath) { - return { - stdout: "", - stderr: "gh not found", - exitCode: 127, - error: new GitError("GitHub CLI is not installed", GitErrorCode.GH_NOT_INSTALLED), - }; - } - const result = await exec(ghPath, args, options); - return { ...result, error: detectGhError(result.stderr, result.exitCode) }; -} - -export function parseShortstat(text: string): { files: number; additions: number; deletions: number } | null { - const match = text.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); - if (!match) return null; - return { - files: Number.parseInt(match[1], 10), - additions: match[2] ? Number.parseInt(match[2], 10) : 0, - deletions: match[3] ? Number.parseInt(match[3], 10) : 0, - }; -} - -export function isTruthy(value: string | undefined): boolean { - if (!value) return false; - return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; -} diff --git a/packages/git-tool/test/cache.test.ts b/packages/git-tool/test/cache.test.ts deleted file mode 100644 index dabb5da35..000000000 --- a/packages/git-tool/test/cache.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { gitTool } from "../src/git-tool"; -import type { StatusResult, ToolResult } from "../src/types"; -import { createTestRepo, type TestRepo } from "./helpers"; - -let repo: TestRepo; -let previousCwd: string; - -beforeEach(() => { - previousCwd = process.cwd(); - repo = createTestRepo(); - process.chdir(repo.path); -}); - -afterEach(() => { - process.chdir(previousCwd); - repo.remove(); -}); - -describe("git-tool cache", () => { - it("invalidates status cache on write operations", async () => { - repo.writeFile("file.txt", "hello"); - repo.run("add", "file.txt"); - repo.run("commit", "-m", "initial"); - - repo.writeFile("file.txt", "hello world"); - - const status1 = (await gitTool({ operation: "status" })) as ToolResult; - expect(status1.data.modified.map((file) => file.path)).toContain("file.txt"); - - await gitTool({ operation: "add", paths: ["file.txt"] }); - - const status2 = (await gitTool({ operation: "status" })) as ToolResult; - expect(status2.data.staged.map((file) => file.path)).toContain("file.txt"); - expect(status2.data.modified.map((file) => file.path)).not.toContain("file.txt"); - }); -}); diff --git a/packages/git-tool/test/helpers.ts b/packages/git-tool/test/helpers.ts deleted file mode 100644 index 27e6650ce..000000000 --- a/packages/git-tool/test/helpers.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { createTempDirSync } from "@oh-my-pi/pi-utils"; -import { type ExecResult, git } from "../src/utils"; - -export { git, type ExecResult }; - -export interface TestRepo { - readonly path: string; - run(...args: string[]): ExecResult; - remove(): void; - writeFile(path: string, content: string): void; -} - -export function createTestRepo(): TestRepo { - const tempDir = createTempDirSync("@wt-test-"); - const repo = { - _tempDir: tempDir, - path: tempDir.path, - remove: () => tempDir.remove(), - run: (...args: string[]) => { - const result = Bun.spawnSync(["git", ...args], { - cwd: tempDir.path, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - return { - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), - exitCode: result.exitCode, - }; - }, - writeFile(path: string, content: string) { - writeFileSync(join(tempDir.path, path), content); - }, - }; - repo.run("init", "-b", "main"); - repo.run("config", "user.email", "test@example.com"); - repo.run("config", "user.name", "Test User"); - return repo; -} - -export function writeFile(path: string, content: string) { - writeFileSync(path, content); -} diff --git a/packages/git-tool/test/parsers.test.ts b/packages/git-tool/test/parsers.test.ts deleted file mode 100644 index 6e3419c81..000000000 --- a/packages/git-tool/test/parsers.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseBlame } from "../src/parsers/blame-parser"; -import { parseDiff } from "../src/parsers/diff-parser"; -import { parseLog } from "../src/parsers/log-parser"; -import { parseStatus } from "../src/parsers/status-parser"; - -const RS = "\x1e"; -const FS = "\x00"; - -describe("git-tool parsers", () => { - it("parses status porcelain v2", () => { - const output = [ - "# branch.head main", - "# branch.upstream origin/main", - "# branch.ab +1 -2", - "1 M. N... 100644 100644 100644 file1.txt", - "1 .M N... 100644 100644 100644 file2.txt", - "u UU N... 100644 100644 100644 conflict.txt", - "? untracked.txt", - "! ignored.log", - ].join("\n"); - - const result = parseStatus(output, true); - expect(result.branch).toBe("main"); - expect(result.upstream).toBe("origin/main"); - expect(result.ahead).toBe(1); - expect(result.behind).toBe(2); - expect(result.staged.map((item) => item.path)).toContain("file1.txt"); - expect(result.modified.map((item) => item.path)).toContain("file2.txt"); - expect(result.conflicts).toContain("conflict.txt"); - expect(result.untracked).toContain("untracked.txt"); - expect(result.ignored).toContain("ignored.log"); - }); - - it("parses unified diff", () => { - const diff = [ - "diff --git a/file.txt b/file.txt", - "index 123..456 100644", - "--- a/file.txt", - "+++ b/file.txt", - "@@ -1,2 +1,3 @@", - " line1", - "-line2", - "+line2 changed", - "+line3", - ].join("\n"); - - const result = parseDiff(diff); - expect(result.files).toHaveLength(1); - const file = result.files[0]; - expect(file.path).toBe("file.txt"); - expect(file.additions).toBe(2); - expect(file.deletions).toBe(1); - expect(file.hunks?.length).toBe(1); - }); - - it("parses log format", () => { - const record = [ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "aaaaaaa", - "Alice", - "alice@example.com", - "2024-01-01T00:00:00Z", - "Bob", - "bob@example.com", - "2024-01-01T01:00:00Z", - "", - "Commit subject", - "Commit body", - ].join(FS); - const output = `${record}${RS}`; - const commits = parseLog(output); - expect(commits).toHaveLength(1); - expect(commits[0].subject).toBe("Commit subject"); - expect(commits[0].message).toContain("Commit body"); - }); - - it("parses blame porcelain", () => { - const output = [ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1", - "author Alice", - "author-time 1700000000", - "filename file.txt", - "\tline one", - ].join("\n"); - const lines = parseBlame(output); - expect(lines).toHaveLength(1); - expect(lines[0].author).toBe("Alice"); - expect(lines[0].content).toBe("line one"); - }); -}); diff --git a/packages/git-tool/test/safety.test.ts b/packages/git-tool/test/safety.test.ts deleted file mode 100644 index 61ae5b18a..000000000 --- a/packages/git-tool/test/safety.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createTempDirSync } from "@oh-my-pi/pi-utils"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { checkSafety } from "../src/safety/guards"; -import { createTestRepo, type TestRepo } from "./helpers"; - -let repo: TestRepo; -let previousCwd: string; - -beforeEach(() => { - previousCwd = process.cwd(); - repo = createTestRepo(); - process.chdir(repo.path); -}); - -afterEach(() => { - process.chdir(previousCwd); - repo.remove(); -}); - -describe("git-tool safety", () => { - it("blocks force push to protected branch", async () => { - repo.writeFile("init.txt", "init"); - repo.run("add", "."); - repo.run("commit", "-m", "init"); - repo.run("branch", "-M", "main"); - const result = await checkSafety("push", { force: true }); - expect(result.blocked).toBe(true); - }); - - it("warns on discard changes", async () => { - const result = await checkSafety("restore", { worktree: true }); - expect(result.blocked).toBe(false); - expect(result.confirm).toBe(false); - expect(result.warnings.length).toBeGreaterThan(0); - }); - - it("warns on branch delete", async () => { - const result = await checkSafety("branch", { action: "delete" }); - expect(result.blocked).toBe(false); - expect(result.confirm).toBe(false); - expect(result.warnings.length).toBeGreaterThan(0); - }); - - it("blocks amend when HEAD is pushed", async () => { - repo.run("branch", "-M", "main"); - repo.writeFile("file.txt", "hello"); - repo.run("add", "file.txt"); - repo.run("commit", "-m", "initial"); - - const remoteDir = createTempDirSync("@git-tool-remote-"); - Bun.spawnSync(["git", "init", "--bare"], { cwd: remoteDir.path }); - repo.run("remote", "add", "origin", remoteDir.path); - repo.run("push", "-u", "origin", "main"); - - const result = await checkSafety("commit", { amend: true }); - expect(result.blocked).toBe(true); - }); - - it("blocks rebase when HEAD is pushed", async () => { - repo.run("branch", "-M", "main"); - repo.writeFile("file.txt", "hello"); - repo.run("add", "file.txt"); - repo.run("commit", "-m", "initial"); - - const remoteDir = createTempDirSync("@git-tool-remote-"); - Bun.spawnSync(["git", "init", "--bare"], { cwd: remoteDir.path }); - repo.run("remote", "add", "origin", remoteDir.path); - repo.run("push", "-u", "origin", "main"); - - const result = await checkSafety("rebase", {}); - expect(result.blocked).toBe(true); - }); -}); diff --git a/packages/git-tool/tsconfig.build.json b/packages/git-tool/tsconfig.build.json deleted file mode 100644 index 695dd9ad2..000000000 --- a/packages/git-tool/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] -} diff --git a/packages/git-tool/vitest.config.ts b/packages/git-tool/vitest.config.ts deleted file mode 100644 index 361d790c0..000000000 --- a/packages/git-tool/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "node", - testTimeout: 30000, - }, -}); diff --git a/tsconfig.json b/tsconfig.json index 1335948a2..23f367414 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,6 @@ "@oh-my-pi/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], "@oh-my-pi/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"], "@oh-my-pi/pi-coding-agent/*": ["./packages/coding-agent/src/*"], - "@oh-my-pi/pi-git-tool": ["./packages/git-tool/src/index.ts"], - "@oh-my-pi/pi-git-tool/*": ["./packages/git-tool/src/*"], "@sinclair/typebox": ["./node_modules/@sinclair/typebox"], "@oh-my-pi/pi-mom": ["./packages/mom/src/index.ts"], "@oh-my-pi/pi-mom/*": ["./packages/mom/src/*"],