@vibecodetown/mcp-server 2.1.4 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +10 -10
  2. package/build/auth/credential_store.js +146 -0
  3. package/build/auth/public_key.js +6 -4
  4. package/build/bootstrap/doctor.js +113 -5
  5. package/build/bootstrap/installer.js +85 -15
  6. package/build/bootstrap/registry.js +11 -6
  7. package/build/bootstrap/skills-installer.js +365 -0
  8. package/build/control_plane/gate.js +52 -70
  9. package/build/dx/activity.js +26 -3
  10. package/build/engine.js +151 -0
  11. package/build/errors.js +107 -0
  12. package/build/generated/bridge_build_seed_input.js +2 -0
  13. package/build/generated/bridge_build_seed_output.js +2 -0
  14. package/build/generated/bridge_confirm_reference_input.js +2 -0
  15. package/build/generated/bridge_confirm_reference_output.js +2 -0
  16. package/build/generated/bridge_confirmed_reference_file.js +2 -0
  17. package/build/generated/bridge_generate_references_input.js +2 -0
  18. package/build/generated/bridge_generate_references_output.js +2 -0
  19. package/build/generated/bridge_references_file.js +2 -0
  20. package/build/generated/bridge_work_order_seed_file.js +2 -0
  21. package/build/generated/contracts_bundle_info.js +3 -3
  22. package/build/generated/index.js +14 -0
  23. package/build/generated/ingress_input.js +2 -0
  24. package/build/generated/ingress_output.js +2 -0
  25. package/build/generated/ingress_resolution_file.js +2 -0
  26. package/build/generated/ingress_summary_file.js +2 -0
  27. package/build/generated/message_template_id_mapping_file.js +2 -0
  28. package/build/generated/run_app_input.js +1 -1
  29. package/build/index.js +4 -1
  30. package/build/local-mode/git.js +36 -22
  31. package/build/local-mode/paths.js +1 -0
  32. package/build/local-mode/project-state.js +176 -0
  33. package/build/local-mode/setup.js +21 -1
  34. package/build/local-mode/templates.js +3 -3
  35. package/build/path-utils.js +68 -0
  36. package/build/runtime/cli_invoker.js +416 -0
  37. package/build/tools/vibe_pm/advisory_review.js +5 -3
  38. package/build/tools/vibe_pm/bridge_build_seed.js +164 -0
  39. package/build/tools/vibe_pm/bridge_confirm_reference.js +91 -0
  40. package/build/tools/vibe_pm/bridge_generate_references.js +258 -0
  41. package/build/tools/vibe_pm/briefing.js +26 -1
  42. package/build/tools/vibe_pm/context.js +79 -0
  43. package/build/tools/vibe_pm/create_work_order.js +200 -3
  44. package/build/tools/vibe_pm/doctor.js +95 -0
  45. package/build/tools/vibe_pm/entity_gate/preflight.js +8 -3
  46. package/build/tools/vibe_pm/export_output.js +14 -13
  47. package/build/tools/vibe_pm/finalize_work.js +74 -0
  48. package/build/tools/vibe_pm/force_override.js +104 -0
  49. package/build/tools/vibe_pm/get_decision.js +2 -2
  50. package/build/tools/vibe_pm/index.js +160 -3
  51. package/build/tools/vibe_pm/ingress.js +645 -0
  52. package/build/tools/vibe_pm/ingress_gate.js +116 -0
  53. package/build/tools/vibe_pm/inspect_code.js +90 -20
  54. package/build/tools/vibe_pm/kce/doc_usage.js +4 -9
  55. package/build/tools/vibe_pm/kce/on_finalize.js +2 -2
  56. package/build/tools/vibe_pm/kce/preflight.js +11 -7
  57. package/build/tools/vibe_pm/list_rules.js +135 -0
  58. package/build/tools/vibe_pm/memory_status.js +11 -8
  59. package/build/tools/vibe_pm/memory_sync.js +11 -8
  60. package/build/tools/vibe_pm/pm_language.js +17 -16
  61. package/build/tools/vibe_pm/pre_commit_analysis.js +292 -0
  62. package/build/tools/vibe_pm/publish_mcp.js +271 -0
  63. package/build/tools/vibe_pm/python_error.js +115 -0
  64. package/build/tools/vibe_pm/run_app.js +215 -86
  65. package/build/tools/vibe_pm/run_app_podman.js +64 -2
  66. package/build/tools/vibe_pm/save_rule.js +120 -0
  67. package/build/tools/vibe_pm/search_oss.js +5 -3
  68. package/build/tools/vibe_pm/spec_rag.js +185 -0
  69. package/build/tools/vibe_pm/status.js +50 -3
  70. package/build/tools/vibe_pm/submit_decision.js +2 -2
  71. package/build/tools/vibe_pm/types.js +28 -0
  72. package/build/tools/vibe_pm/undo_last_task.js +23 -20
  73. package/build/tools/vibe_pm/waiter_mapping.js +155 -0
  74. package/build/tools/vibe_pm/zoekt_evidence.js +5 -3
  75. package/build/tools.js +13 -5
  76. package/build/version-check.js +5 -5
  77. package/build/vibe-cli.js +742 -39
  78. package/package.json +5 -4
  79. package/skills/VRIP_INSTALL_MANIFEST_DOCTOR.skill.md +288 -0
  80. package/skills/index.json +14 -0
@@ -0,0 +1,176 @@
1
+ // adapters/mcp-ts/src/local-mode/project-state.ts
2
+ // Per-folder project state persistence
3
+ // Stores workflow state, reminders, and pending actions
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { getVibeRepoPaths } from "./paths.js";
7
+ const DEFAULT_STATE = {
8
+ schema_version: 1,
9
+ phases: [],
10
+ pending_actions: [],
11
+ reminders: [],
12
+ };
13
+ /**
14
+ * Load project state from .vibe/project_state.json
15
+ */
16
+ export function loadProjectState(basePath = process.cwd()) {
17
+ const paths = getVibeRepoPaths(basePath);
18
+ const statePath = path.join(paths.vibeDir, "project_state.json");
19
+ if (!fs.existsSync(statePath)) {
20
+ return null;
21
+ }
22
+ try {
23
+ const content = fs.readFileSync(statePath, "utf-8");
24
+ return JSON.parse(content);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /**
31
+ * Save project state to .vibe/project_state.json
32
+ */
33
+ export function saveProjectState(state, basePath = process.cwd()) {
34
+ const paths = getVibeRepoPaths(basePath);
35
+ const statePath = path.join(paths.vibeDir, "project_state.json");
36
+ // Ensure .vibe directory exists
37
+ if (!fs.existsSync(paths.vibeDir)) {
38
+ fs.mkdirSync(paths.vibeDir, { recursive: true });
39
+ }
40
+ state.updated_at = new Date().toISOString();
41
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
42
+ }
43
+ /**
44
+ * Get or create project state
45
+ */
46
+ export function getProjectState(basePath = process.cwd()) {
47
+ const existing = loadProjectState(basePath);
48
+ if (existing) {
49
+ return existing;
50
+ }
51
+ const projectId = path.basename(basePath).toLowerCase().replace(/[^a-z0-9_-]/g, "_");
52
+ return {
53
+ ...DEFAULT_STATE,
54
+ project_id: projectId,
55
+ updated_at: new Date().toISOString(),
56
+ };
57
+ }
58
+ /**
59
+ * Add a pending action
60
+ */
61
+ export function addPendingAction(action, basePath = process.cwd()) {
62
+ const state = getProjectState(basePath);
63
+ // Check for duplicate
64
+ const exists = state.pending_actions.some((a) => a.type === action.type && a.reason === action.reason);
65
+ if (exists)
66
+ return;
67
+ state.pending_actions.push({
68
+ ...action,
69
+ created_at: new Date().toISOString(),
70
+ });
71
+ saveProjectState(state, basePath);
72
+ }
73
+ /**
74
+ * Remove a pending action by type
75
+ */
76
+ export function removePendingAction(type, basePath = process.cwd()) {
77
+ const state = getProjectState(basePath);
78
+ state.pending_actions = state.pending_actions.filter((a) => a.type !== type);
79
+ saveProjectState(state, basePath);
80
+ }
81
+ /**
82
+ * Get all pending actions (for reminders)
83
+ */
84
+ export function getPendingActions(basePath = process.cwd()) {
85
+ const state = loadProjectState(basePath);
86
+ return state?.pending_actions || [];
87
+ }
88
+ /**
89
+ * Update last commit info
90
+ */
91
+ export function updateLastCommit(info, basePath = process.cwd()) {
92
+ const state = getProjectState(basePath);
93
+ state.last_commit = info;
94
+ // If pushed, remove git_push pending action
95
+ if (info.pushed) {
96
+ state.pending_actions = state.pending_actions.filter((a) => a.type !== "git_push");
97
+ }
98
+ else {
99
+ // Add git_push pending action if not pushed
100
+ const exists = state.pending_actions.some((a) => a.type === "git_push");
101
+ if (!exists) {
102
+ state.pending_actions.push({
103
+ type: "git_push",
104
+ reason: `커밋 ${info.hash.slice(0, 7)} 푸시 필요`,
105
+ created_at: new Date().toISOString(),
106
+ priority: "medium",
107
+ });
108
+ }
109
+ }
110
+ saveProjectState(state, basePath);
111
+ }
112
+ /**
113
+ * Update last MCP build info
114
+ */
115
+ export function updateLastMcpBuild(info, basePath = process.cwd()) {
116
+ const state = getProjectState(basePath);
117
+ state.last_mcp_build = info;
118
+ // If published, remove mcp_build and npm_publish pending actions
119
+ if (info.published) {
120
+ state.pending_actions = state.pending_actions.filter((a) => a.type !== "mcp_build" && a.type !== "npm_publish");
121
+ }
122
+ saveProjectState(state, basePath);
123
+ }
124
+ /**
125
+ * Format pending actions as reminder text
126
+ */
127
+ export function formatReminders(basePath = process.cwd()) {
128
+ const actions = getPendingActions(basePath);
129
+ if (actions.length === 0)
130
+ return null;
131
+ const lines = ["## 📋 대기 중인 작업"];
132
+ // Sort by priority
133
+ const sorted = [...actions].sort((a, b) => {
134
+ const order = { high: 0, medium: 1, low: 2 };
135
+ return order[a.priority] - order[b.priority];
136
+ });
137
+ for (const action of sorted) {
138
+ const emoji = action.priority === "high" ? "🔴" : action.priority === "medium" ? "🟡" : "🟢";
139
+ const tool = getToolForAction(action.type);
140
+ lines.push(`- ${emoji} ${action.reason}${tool ? ` → \`${tool}\`` : ""}`);
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+ function getToolForAction(type) {
145
+ const mapping = {
146
+ mcp_build: "vibe_pm.publish_mcp",
147
+ engine_build: null, // Manual
148
+ npm_publish: "vibe_pm.publish_mcp",
149
+ git_push: "vibe_pm.finalize_work",
150
+ review: "vibe_pm.inspect_code",
151
+ custom: null,
152
+ };
153
+ return mapping[type];
154
+ }
155
+ /**
156
+ * Check and add MCP build pending action if needed
157
+ */
158
+ export async function checkAndAddMcpBuildAction(basePath = process.cwd()) {
159
+ // Dynamically import to avoid circular dependency
160
+ const { needsMcpBuild } = await import("../tools/vibe_pm/publish_mcp.js");
161
+ try {
162
+ const needs = await needsMcpBuild(basePath);
163
+ if (needs) {
164
+ addPendingAction({
165
+ type: "mcp_build",
166
+ reason: "MCP 소스 변경됨 - 빌드 필요",
167
+ priority: "high",
168
+ }, basePath);
169
+ return true;
170
+ }
171
+ }
172
+ catch {
173
+ // ignore
174
+ }
175
+ return false;
176
+ }
@@ -4,6 +4,7 @@ import { initLocalModeRepo } from "./init.js";
4
4
  import { writeVersionLock } from "./version-lock.js";
5
5
  import { ensureEngines } from "../bootstrap/installer.js";
6
6
  import { healthCheck } from "../bootstrap/doctor.js";
7
+ import { activateSkills, getSkillsHealth } from "../bootstrap/skills-installer.js";
7
8
  /**
8
9
  * Run unified setup process
9
10
  */
@@ -42,5 +43,24 @@ export async function runSetup(repoRoot, cliVersion, opts) {
42
43
  catch (e) {
43
44
  warnings.push(`버전 잠금 파일 생성 실패: ${e instanceof Error ? e.message : String(e)}`);
44
45
  }
45
- return { local, engines, versionLock, warnings };
46
+ // Step 4: Skills activation
47
+ let skills = { activated: [], errors: [] };
48
+ if (!opts.skipSkills) {
49
+ try {
50
+ const skillsHealth = getSkillsHealth();
51
+ if (skillsHealth.installed && skillsHealth.skills.length > 0) {
52
+ // Activate all available skills by default
53
+ skills = await activateSkills(repoRoot);
54
+ if (skills.errors.length > 0) {
55
+ for (const err of skills.errors) {
56
+ warnings.push(`스킬 활성화 오류: ${err}`);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ catch (e) {
62
+ warnings.push(`스킬 활성화 실패: ${e instanceof Error ? e.message : String(e)}`);
63
+ }
64
+ }
65
+ return { local, engines, skills, versionLock, warnings };
46
66
  }
@@ -763,9 +763,9 @@ jobs:
763
763
  - name: Run Vibe guard
764
764
  shell: bash
765
765
  env:
766
- # Default: WARN does not fail the job (seatbelt philosophy).
767
- # Opt-in strict mode: set to "true" in the generated workflow.
768
- VIBE_FAIL_ON_WARN: "false"
766
+ # P0-2: CI 환경에서는 WARN도 빌드 실패 처리 (기본값)
767
+ # 이전 동작이 필요하면 "false" 변경
768
+ VIBE_FAIL_ON_WARN: "true"
769
769
  run: |
770
770
  set -euo pipefail
771
771
  if [[ ! -f ".vibe/lib/validate.sh" ]]; then
@@ -0,0 +1,68 @@
1
+ // adapters/mcp-ts/src/path-utils.ts
2
+ // P1-5: Centralized path normalization utilities (DRY)
3
+ import * as path from "node:path";
4
+ /**
5
+ * Convert Windows backslashes to POSIX forward slashes.
6
+ * This is the most basic normalization used across the codebase.
7
+ */
8
+ export function toPosixPath(value) {
9
+ return value.replace(/\\/g, "/");
10
+ }
11
+ /**
12
+ * Normalize a relative path for POSIX style:
13
+ * - Convert backslashes to forward slashes
14
+ * - Trim whitespace
15
+ * - Remove leading slashes (makes it relative)
16
+ */
17
+ export function normalizeRelativePosix(value) {
18
+ return (value ?? "").trim().replace(/\\/g, "/").replace(/^\/+/, "");
19
+ }
20
+ /**
21
+ * Normalize a path for comparison/matching:
22
+ * - Convert backslashes to forward slashes
23
+ * - Trim whitespace
24
+ * - Remove leading "./" prefix
25
+ */
26
+ export function normalizeForMatch(value) {
27
+ let v = (value ?? "").trim().replace(/\\/g, "/");
28
+ if (v.startsWith("./"))
29
+ v = v.slice(2);
30
+ return v;
31
+ }
32
+ /**
33
+ * Resolve a path relative to a base path and normalize to POSIX.
34
+ * Returns empty string for empty input, "." for base path itself.
35
+ */
36
+ export function resolveRelativePosix(basePath, value) {
37
+ const trimmed = (value ?? "").trim();
38
+ if (!trimmed)
39
+ return "";
40
+ const absolute = path.isAbsolute(trimmed) ? trimmed : path.resolve(basePath, trimmed);
41
+ const relative = path.relative(basePath, absolute);
42
+ if (!relative)
43
+ return ".";
44
+ return toPosixPath(relative);
45
+ }
46
+ /**
47
+ * Process and deduplicate an array of paths, normalizing each to POSIX relative format.
48
+ * Empty and duplicate paths are filtered out.
49
+ */
50
+ export function normalizePathArray(basePath, values) {
51
+ const seen = new Set();
52
+ const out = [];
53
+ for (const raw of values) {
54
+ const normalized = resolveRelativePosix(basePath, raw);
55
+ if (!normalized || seen.has(normalized))
56
+ continue;
57
+ seen.add(normalized);
58
+ out.push(normalized);
59
+ }
60
+ return out;
61
+ }
62
+ /**
63
+ * Deduplicate and normalize an array of strings for set operations.
64
+ * Uses basic POSIX normalization (backslash to forward slash).
65
+ */
66
+ export function uniquePosixPaths(items) {
67
+ return Array.from(new Set(items.map((p) => normalizeRelativePosix(p)).filter(Boolean))).sort();
68
+ }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * cli_invoker.ts - Single CLI Invocation Point (SSOT)
3
+ *
4
+ * All MCP tools MUST use this module for CLI invocations.
5
+ * Direct usage of child_process outside this file is forbidden.
6
+ *
7
+ * This module provides:
8
+ * - Centralized CLI invocation
9
+ * - Invocation logging for test verification
10
+ * - Environment sanitization
11
+ * - Timeout handling
12
+ *
13
+ * @module runtime/cli_invoker
14
+ */
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { sanitizeEnv } from "../cli.js";
17
+ import { getEngineCtx } from "../engine.js";
18
+ // ============================================================================
19
+ // Invocation Log (for test verification)
20
+ // ============================================================================
21
+ const invocationLog = [];
22
+ /**
23
+ * Get a copy of the invocation log
24
+ * Used by tests to verify CLI calls
25
+ */
26
+ export function getInvocationLog() {
27
+ return [...invocationLog];
28
+ }
29
+ /**
30
+ * Clear the invocation log
31
+ * Should be called in test beforeEach()
32
+ */
33
+ export function clearInvocationLog() {
34
+ invocationLog.length = 0;
35
+ }
36
+ /**
37
+ * Check if a specific CLI prefix was invoked
38
+ * Used by enforcement tests
39
+ */
40
+ export function wasInvoked(bin, argsPrefix) {
41
+ return invocationLog.some((record) => record.bin === bin &&
42
+ argsPrefix.every((prefix, i) => record.args[i] === prefix));
43
+ }
44
+ // ============================================================================
45
+ // Binary Resolution
46
+ // ============================================================================
47
+ let vibeBinPath = null;
48
+ /**
49
+ * Get the vibe CLI binary path
50
+ * This is the path to the vibe-cli.ts entry point
51
+ */
52
+ async function getVibeBinPath() {
53
+ if (vibeBinPath)
54
+ return vibeBinPath;
55
+ // In production, vibe is invoked via npx or global install
56
+ // For now, we use the current process argv
57
+ // TODO: Resolve proper vibe binary path from package
58
+ vibeBinPath = process.argv[1] ?? "vibe";
59
+ return vibeBinPath;
60
+ }
61
+ /** Known system binaries that should be resolved via PATH */
62
+ const SYSTEM_BINS = new Set([
63
+ "git", "semgrep", "python", "python3", "opa", "node", "npm", "npx",
64
+ // Common utilities that might be needed
65
+ "tar", "curl", "wget", "which", "bash", "sh",
66
+ ]);
67
+ /** Engine binary names (for type checking) */
68
+ const ENGINE_BINS = new Set([
69
+ "spec-high", "vibe-execution-engine", "clinic",
70
+ ]);
71
+ /**
72
+ * Resolve binary path for a given CLI bin
73
+ */
74
+ async function resolveBinPath(bin) {
75
+ if (bin === "vibe") {
76
+ return getVibeBinPath();
77
+ }
78
+ // System binary - use the name directly (resolved via PATH)
79
+ if (SYSTEM_BINS.has(bin)) {
80
+ return bin;
81
+ }
82
+ // Engine binary - use cached path from bootstrap
83
+ if (ENGINE_BINS.has(bin)) {
84
+ const ctx = await getEngineCtx();
85
+ const enginePath = ctx.bins[bin];
86
+ if (!enginePath) {
87
+ throw new Error(`Engine binary not installed: ${bin}`);
88
+ }
89
+ return enginePath;
90
+ }
91
+ // Unknown binary - assume it's a system command (PATH resolution)
92
+ // This allows flexibility for arbitrary commands while logging them
93
+ return bin;
94
+ }
95
+ // ============================================================================
96
+ // CLI Invocation
97
+ // ============================================================================
98
+ /**
99
+ * Invoke a CLI command
100
+ *
101
+ * This is the ONLY function that should spawn CLI processes.
102
+ * All other code should use this function.
103
+ *
104
+ * @param inv - Invocation request
105
+ * @returns CLI result with exit code, stdout, stderr, and duration
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * const result = await invokeCli({
110
+ * bin: "vibe",
111
+ * args: ["inspect", "--run-id", "abc123"],
112
+ * cwd: "/path/to/project",
113
+ * timeoutMs: 60000
114
+ * });
115
+ * ```
116
+ */
117
+ export async function invokeCli(inv) {
118
+ const startTime = Date.now();
119
+ const timeoutMs = inv.timeoutMs ?? 120_000;
120
+ // Create log record
121
+ const record = {
122
+ ...inv,
123
+ timestamp: new Date().toISOString(),
124
+ };
125
+ invocationLog.push(record);
126
+ // Resolve binary path
127
+ const binPath = await resolveBinPath(inv.bin);
128
+ return new Promise((resolve) => {
129
+ let resolved = false;
130
+ const p = spawn(binPath, inv.args, {
131
+ cwd: inv.cwd,
132
+ env: sanitizeEnv(inv.env),
133
+ shell: false,
134
+ stdio: ["ignore", "pipe", "pipe"],
135
+ });
136
+ let stdout = "";
137
+ let stderr = "";
138
+ const finish = (exitCode) => {
139
+ if (resolved)
140
+ return;
141
+ resolved = true;
142
+ const result = {
143
+ exitCode,
144
+ stdout,
145
+ stderr,
146
+ durationMs: Date.now() - startTime,
147
+ };
148
+ // Update log record with result
149
+ record.result = result;
150
+ resolve(result);
151
+ };
152
+ const killTimer = setTimeout(() => {
153
+ try {
154
+ p.kill("SIGKILL");
155
+ }
156
+ catch {
157
+ // Ignore kill errors
158
+ }
159
+ stderr += "\n[TIMEOUT]";
160
+ finish(124);
161
+ }, timeoutMs);
162
+ p.stdout.on("data", (d) => (stdout += d.toString("utf-8")));
163
+ p.stderr.on("data", (d) => (stderr += d.toString("utf-8")));
164
+ p.on("error", (e) => {
165
+ clearTimeout(killTimer);
166
+ const msg = e instanceof Error ? e.message : String(e);
167
+ stderr += `\n[SPAWN_ERROR] ${msg}`;
168
+ finish(127);
169
+ });
170
+ p.on("close", (code) => {
171
+ clearTimeout(killTimer);
172
+ finish(code ?? 1);
173
+ });
174
+ });
175
+ }
176
+ // ============================================================================
177
+ // Convenience Functions
178
+ // ============================================================================
179
+ /**
180
+ * Invoke vibe CLI command (convenience wrapper)
181
+ *
182
+ * @example
183
+ * ```typescript
184
+ * const result = await invokeVibe(["inspect", "--run-id", "abc123"], "/project");
185
+ * ```
186
+ */
187
+ export async function invokeVibe(args, cwd, opts) {
188
+ return invokeCli({
189
+ bin: "vibe",
190
+ args,
191
+ cwd,
192
+ env: opts?.env,
193
+ timeoutMs: opts?.timeoutMs,
194
+ });
195
+ }
196
+ /**
197
+ * Invoke engine binary directly (legacy, will be deprecated)
198
+ *
199
+ * Prefer using invokeVibe() with appropriate vibe subcommand instead.
200
+ *
201
+ * @deprecated Use invokeVibe() instead
202
+ */
203
+ export async function invokeEngine(engine, args, cwd, opts) {
204
+ return invokeCli({
205
+ bin: engine,
206
+ args,
207
+ cwd,
208
+ env: opts?.env,
209
+ timeoutMs: opts?.timeoutMs,
210
+ });
211
+ }
212
+ /**
213
+ * Invoke a system binary (git, semgrep, python, etc.)
214
+ *
215
+ * These commands are resolved via PATH.
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * // Run git status
220
+ * const result = await invokeSystem("git", ["status"], "/project");
221
+ *
222
+ * // Run semgrep scan
223
+ * const result = await invokeSystem("semgrep", ["scan", "--config", "auto"], "/project");
224
+ * ```
225
+ */
226
+ export async function invokeSystem(bin, args, cwd, opts) {
227
+ return invokeCli({
228
+ bin,
229
+ args,
230
+ cwd,
231
+ env: opts?.env,
232
+ timeoutMs: opts?.timeoutMs,
233
+ });
234
+ }
235
+ /**
236
+ * Invoke git command (convenience wrapper)
237
+ *
238
+ * @example
239
+ * ```typescript
240
+ * const result = await invokeGit(["status"], "/project");
241
+ * const result = await invokeGit(["add", "."], "/project");
242
+ * ```
243
+ */
244
+ export async function invokeGit(args, cwd, opts) {
245
+ return invokeSystem("git", args, cwd, opts);
246
+ }
247
+ /**
248
+ * Invoke a system binary synchronously
249
+ *
250
+ * Use sparingly - prefer async versions. Only use for:
251
+ * - Startup/bootstrap code
252
+ * - Simple checks that must block
253
+ *
254
+ * @example
255
+ * ```typescript
256
+ * const result = invokeSystemSync("git", ["rev-parse", "--show-toplevel"], "/project");
257
+ *
258
+ * // With stdin input
259
+ * const result = invokeSystemSync("opa", ["eval", "-I", ...], "/project", {
260
+ * input: JSON.stringify(data)
261
+ * });
262
+ * ```
263
+ */
264
+ export function invokeSystemSync(bin, args, cwd, opts) {
265
+ const timeoutMs = opts?.timeoutMs ?? 30_000;
266
+ // Log the invocation (sync version also logs for consistency)
267
+ const record = {
268
+ bin,
269
+ args,
270
+ cwd,
271
+ env: opts?.env,
272
+ timeoutMs,
273
+ timestamp: new Date().toISOString(),
274
+ };
275
+ invocationLog.push(record);
276
+ const result = spawnSync(bin, args, {
277
+ cwd,
278
+ encoding: "utf-8",
279
+ env: sanitizeEnv(opts?.env),
280
+ input: opts?.input,
281
+ stdio: opts?.input ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
282
+ timeout: timeoutMs,
283
+ });
284
+ const cliResult = {
285
+ exitCode: result.status ?? (result.signal ? 124 : 1),
286
+ stdout: result.stdout ?? "",
287
+ stderr: result.stderr ?? "",
288
+ };
289
+ // Check for spawn errors (e.g., binary not found)
290
+ if (result.error) {
291
+ const err = result.error;
292
+ cliResult.exitCode = err.code === "ENOENT" ? 127 : 1;
293
+ cliResult.stderr = `[SPAWN_ERROR] ${err.message}`;
294
+ }
295
+ // Update log record
296
+ record.result = { ...cliResult, durationMs: 0 };
297
+ return cliResult;
298
+ }
299
+ /**
300
+ * Invoke git command synchronously
301
+ *
302
+ * @example
303
+ * ```typescript
304
+ * const result = invokeGitSync(["rev-parse", "--show-toplevel"], "/project");
305
+ * ```
306
+ */
307
+ export function invokeGitSync(args, cwd, opts) {
308
+ return invokeSystemSync("git", args, cwd, opts);
309
+ }
310
+ // ============================================================================
311
+ // Detached Process (for background services)
312
+ // ============================================================================
313
+ /**
314
+ * Spawn a detached process that continues running after parent exits
315
+ *
316
+ * Use for background services like web servers.
317
+ * The process is spawned with `detached: true` and `unref()`.
318
+ *
319
+ * @returns PID of the spawned process, or null if spawn failed
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * const pid = spawnDetached("npm", ["run", "dev"], "/project", {
324
+ * PORT: "3000"
325
+ * });
326
+ * ```
327
+ */
328
+ export function spawnDetached(bin, args, cwd, env) {
329
+ // Log the invocation
330
+ const record = {
331
+ bin,
332
+ args,
333
+ cwd,
334
+ env,
335
+ timestamp: new Date().toISOString(),
336
+ };
337
+ invocationLog.push(record);
338
+ try {
339
+ const child = spawn(bin, args, {
340
+ cwd,
341
+ detached: true,
342
+ stdio: "ignore",
343
+ env: sanitizeEnv(env),
344
+ });
345
+ // Unref to allow parent to exit independently
346
+ child.unref();
347
+ const pid = child.pid ?? null;
348
+ // Update log record
349
+ record.result = {
350
+ exitCode: 0,
351
+ stdout: "",
352
+ stderr: "",
353
+ durationMs: 0,
354
+ };
355
+ return pid;
356
+ }
357
+ catch {
358
+ record.result = {
359
+ exitCode: 127,
360
+ stdout: "",
361
+ stderr: "[SPAWN_ERROR]",
362
+ durationMs: 0,
363
+ };
364
+ return null;
365
+ }
366
+ }
367
+ /**
368
+ * Parse VRIP control response from CLI result
369
+ *
370
+ * @param result - CLI invocation result
371
+ * @returns Parsed control response or error
372
+ */
373
+ export function parseVripResponse(result) {
374
+ if (result.exitCode > 1) {
375
+ // System crash - no JSON expected
376
+ return { ok: false, error: `CLI crashed (code ${result.exitCode}): ${result.stderr}` };
377
+ }
378
+ try {
379
+ const ctrl = JSON.parse(result.stdout.trim());
380
+ if (typeof ctrl.ok !== "boolean" || typeof ctrl.run_id !== "string") {
381
+ return { ok: false, error: "Invalid VRIP response: missing ok or run_id" };
382
+ }
383
+ return { ok: true, ctrl };
384
+ }
385
+ catch (e) {
386
+ const msg = e instanceof Error ? e.message : String(e);
387
+ return { ok: false, error: `JSON parse failed: ${msg}\nRaw: ${result.stdout}` };
388
+ }
389
+ }
390
+ /**
391
+ * Invoke vibe CLI with VRIP protocol
392
+ *
393
+ * Automatically adds --json flag and parses VRIP response.
394
+ *
395
+ * @example
396
+ * ```typescript
397
+ * const { ctrl, result } = await invokeVibeVrip(
398
+ * ["inspect", "--target", "src/"],
399
+ * "/project"
400
+ * );
401
+ * if (ctrl.ok) {
402
+ * const runDir = `.vibe/runs/${ctrl.run_id}`;
403
+ * // Read actual results from run_dir
404
+ * }
405
+ * ```
406
+ */
407
+ export async function invokeVibeVrip(args, cwd, opts) {
408
+ // Always add --json for VRIP protocol
409
+ const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
410
+ const result = await invokeVibe(jsonArgs, cwd, opts);
411
+ const parsed = parseVripResponse(result);
412
+ if (!parsed.ok) {
413
+ throw new Error(parsed.error);
414
+ }
415
+ return { ctrl: parsed.ctrl, result };
416
+ }