@iinm/plain-agent 1.8.4 → 1.8.6

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 (85) hide show
  1. package/bin/plain +1 -1
  2. package/package.json +8 -9
  3. package/sandbox/bin/plain-sandbox +13 -0
  4. package/src/agent.d.ts +52 -0
  5. package/src/agent.mjs +204 -0
  6. package/src/agentLoop.mjs +419 -0
  7. package/src/agentState.mjs +41 -0
  8. package/src/claudeCodePlugin.mjs +164 -0
  9. package/src/cliArgs.mjs +175 -0
  10. package/src/cliBatch.mjs +147 -0
  11. package/src/cliCommands.mjs +283 -0
  12. package/src/cliCompleter.mjs +227 -0
  13. package/src/cliCost.mjs +309 -0
  14. package/src/cliFormatter.mjs +518 -0
  15. package/src/cliInteractive.mjs +533 -0
  16. package/src/cliInterruptTransform.mjs +51 -0
  17. package/src/cliMuteTransform.mjs +26 -0
  18. package/src/cliPasteTransform.mjs +183 -0
  19. package/src/config.d.ts +36 -0
  20. package/src/config.mjs +197 -0
  21. package/src/context/loadAgentRoles.mjs +267 -0
  22. package/src/context/loadPrompts.mjs +303 -0
  23. package/src/context/loadUserMessageContext.mjs +147 -0
  24. package/src/costTracker.mjs +210 -0
  25. package/src/env.mjs +44 -0
  26. package/src/main.mjs +281 -0
  27. package/src/mcpClient.mjs +351 -0
  28. package/src/mcpIntegration.mjs +160 -0
  29. package/src/model.d.ts +109 -0
  30. package/src/modelCaller.mjs +32 -0
  31. package/src/modelDefinition.d.ts +92 -0
  32. package/src/prompt.mjs +138 -0
  33. package/src/providers/anthropic.d.ts +248 -0
  34. package/src/providers/anthropic.mjs +587 -0
  35. package/src/providers/bedrock.d.ts +249 -0
  36. package/src/providers/bedrock.mjs +700 -0
  37. package/src/providers/gemini.d.ts +208 -0
  38. package/src/providers/gemini.mjs +754 -0
  39. package/src/providers/openai.d.ts +281 -0
  40. package/src/providers/openai.mjs +544 -0
  41. package/src/providers/openaiCompatible.d.ts +147 -0
  42. package/src/providers/openaiCompatible.mjs +652 -0
  43. package/src/providers/platform/awsSigV4.mjs +184 -0
  44. package/src/providers/platform/azure.mjs +42 -0
  45. package/src/providers/platform/bedrock.mjs +78 -0
  46. package/src/providers/platform/googleCloud.mjs +34 -0
  47. package/src/subagent.mjs +265 -0
  48. package/src/tmpfile.mjs +27 -0
  49. package/src/tool.d.ts +74 -0
  50. package/src/toolExecutor.mjs +236 -0
  51. package/src/toolInputValidator.mjs +183 -0
  52. package/src/toolUseApprover.mjs +99 -0
  53. package/src/tools/askURL.mjs +209 -0
  54. package/src/tools/askWeb.mjs +208 -0
  55. package/src/tools/compactContext.d.ts +4 -0
  56. package/src/tools/compactContext.mjs +87 -0
  57. package/src/tools/execCommand.d.ts +22 -0
  58. package/src/tools/execCommand.mjs +200 -0
  59. package/src/tools/patchFile.d.ts +4 -0
  60. package/src/tools/patchFile.mjs +133 -0
  61. package/src/tools/switchToMainAgent.d.ts +3 -0
  62. package/src/tools/switchToMainAgent.mjs +43 -0
  63. package/src/tools/switchToSubagent.d.ts +4 -0
  64. package/src/tools/switchToSubagent.mjs +59 -0
  65. package/src/tools/tmuxCommand.d.ts +14 -0
  66. package/src/tools/tmuxCommand.mjs +194 -0
  67. package/src/tools/writeFile.d.ts +4 -0
  68. package/src/tools/writeFile.mjs +56 -0
  69. package/src/usageStore.mjs +167 -0
  70. package/src/utils/evalJSONConfig.mjs +72 -0
  71. package/src/utils/matchValue.d.ts +6 -0
  72. package/src/utils/matchValue.mjs +40 -0
  73. package/src/utils/noThrow.mjs +31 -0
  74. package/src/utils/notify.mjs +29 -0
  75. package/src/utils/parseFileRange.mjs +18 -0
  76. package/src/utils/parseFrontmatter.mjs +19 -0
  77. package/src/utils/readFileRange.mjs +33 -0
  78. package/src/utils/retryOnError.mjs +41 -0
  79. package/src/voiceInput.mjs +61 -0
  80. package/src/voiceInputGemini.mjs +105 -0
  81. package/src/voiceInputOpenAI.mjs +104 -0
  82. package/src/voiceInputSession.mjs +543 -0
  83. package/src/voiceToggleKey.mjs +62 -0
  84. package/dist/main.mjs +0 -473
  85. package/dist/main.mjs.map +0 -7
@@ -0,0 +1,419 @@
1
+ /**
2
+ * @import { AgentEventEmitter } from "./agent"
3
+ * @import { CallModel, MessageContentText, MessageContentImage, MessageContentToolResult, PartialMessageContent, UserMessage, MessageContentToolUse } from "./model"
4
+ * @import { ToolDefinition, ToolUseApprover } from "./tool"
5
+ * @import { SubagentManager } from "./subagent.mjs"
6
+ * @import { StateManager } from "./agentState.mjs"
7
+ */
8
+
9
+ import { styleText } from "node:util";
10
+ import { compactContextToolName } from "./tools/compactContext.mjs";
11
+
12
+ /**
13
+ * If compact_context was called successfully, discard the prior conversation
14
+ * (keeping only the system prompt) and append the tool result as a standard
15
+ * user message so the model can resume from the reloaded memory file.
16
+ * @param {StateManager} stateManager
17
+ * @param {MessageContentToolUse[]} toolUseParts
18
+ * @param {MessageContentToolResult[]} toolResults
19
+ * @returns {boolean} true if compact was applied
20
+ */
21
+ function applyCompactContextIfCalled(stateManager, toolUseParts, toolResults) {
22
+ const compactToolUse = toolUseParts.find(
23
+ (t) => t.toolName === compactContextToolName,
24
+ );
25
+ if (!compactToolUse) return false;
26
+
27
+ const compactResult = toolResults.find(
28
+ (r) => r.toolUseId === compactToolUse.toolUseId,
29
+ );
30
+ if (!compactResult || compactResult.isError) return false;
31
+
32
+ const systemMessage = stateManager.getMessageAt(0);
33
+ if (!systemMessage) return false;
34
+
35
+ stateManager.setMessages([systemMessage]);
36
+ stateManager.appendMessages([
37
+ { role: "user", content: compactResult.content },
38
+ ]);
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * @typedef {Object} PauseSignal
44
+ * @property {() => boolean} isPaused - Returns true if auto-approve should be paused
45
+ * @property {() => void} reset - Resets the paused state
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} AgentLoopConfig
50
+ * @property {CallModel} callModel - Function to call the language model
51
+ * @property {StateManager} stateManager - State manager for message handling
52
+ * @property {ToolDefinition[]} toolDefs - Tool definitions for the model
53
+ * @property {import("./toolExecutor.mjs").ToolExecutor} toolExecutor - Tool executor instance
54
+ * @property {AgentEventEmitter} agentEventEmitter - Event emitter for agent events
55
+ * @property {ToolUseApprover} toolUseApprover - Tool use approval checker
56
+ * @property {SubagentManager} subagentManager - Subagent manager instance
57
+ * @property {PauseSignal} pauseSignal - Signal to pause auto-approve after current tool completes
58
+ */
59
+
60
+ /**
61
+ * @typedef {ReturnType<typeof createAgentLoop>} AgentLoop
62
+ */
63
+
64
+ /**
65
+ * Create an agent loop handler
66
+ * @param {AgentLoopConfig} config
67
+ */
68
+ export function createAgentLoop({
69
+ callModel,
70
+ stateManager,
71
+ toolDefs,
72
+ toolExecutor,
73
+ agentEventEmitter,
74
+ toolUseApprover,
75
+ subagentManager,
76
+ pauseSignal,
77
+ }) {
78
+ const inputHandler = createInputHandler({
79
+ stateManager,
80
+ toolExecutor,
81
+ subagentManager,
82
+ toolUseApprover,
83
+ });
84
+
85
+ /**
86
+ * Handle user input and run the agent turn loop
87
+ * @param {(MessageContentText | MessageContentImage)[]} input - User input content
88
+ * @returns {Promise<void>}
89
+ */
90
+ async function handleUserInput(input) {
91
+ pauseSignal.reset();
92
+ toolUseApprover.resetApprovalCount();
93
+ await inputHandler.handle(input);
94
+ await runTurnLoop();
95
+ agentEventEmitter.emit("turnEnd");
96
+ }
97
+
98
+ /**
99
+ * Run the main agent turn loop
100
+ * @returns {Promise<void>}
101
+ */
102
+ async function runTurnLoop() {
103
+ let thinkingLoops = 0;
104
+ const maxThinkingLoops = 5;
105
+
106
+ while (true) {
107
+ // Check if auto-approve was paused by Ctrl-C during tool execution
108
+ if (pauseSignal.isPaused()) {
109
+ pauseSignal.reset();
110
+ break;
111
+ }
112
+
113
+ const modelOutput = await callModel({
114
+ messages: stateManager.getMessages(),
115
+ tools: toolDefs,
116
+ /**
117
+ * @param {PartialMessageContent} partialContent
118
+ */
119
+ onPartialMessageContent: (partialContent) => {
120
+ agentEventEmitter.emit("partialMessageContent", partialContent);
121
+ },
122
+ });
123
+
124
+ if (modelOutput instanceof Error) {
125
+ agentEventEmitter.emit("error", modelOutput);
126
+ break;
127
+ }
128
+
129
+ const { message: assistantMessage, providerTokenUsage } = modelOutput;
130
+ stateManager.appendMessages([assistantMessage]);
131
+ if (providerTokenUsage) {
132
+ agentEventEmitter.emit("providerTokenUsage", providerTokenUsage);
133
+ }
134
+
135
+ // Gemini may stop with "thinking" -> continue
136
+ const lastContent = assistantMessage.content.at(-1);
137
+ if (lastContent?.type === "thinking") {
138
+ thinkingLoops += 1;
139
+ if (thinkingLoops > maxThinkingLoops) {
140
+ break;
141
+ }
142
+
143
+ stateManager.appendMessages([
144
+ {
145
+ role: "user",
146
+ content: [{ type: "text", text: "System: Continue" }],
147
+ },
148
+ ]);
149
+ console.error(
150
+ styleText(
151
+ "yellow",
152
+ `\nModel is thinking. Sending "System: Continue" (Loop: ${thinkingLoops}/${maxThinkingLoops})`,
153
+ ),
154
+ );
155
+ continue;
156
+ }
157
+
158
+ const toolUseParts = assistantMessage.content.filter(
159
+ (part) => part.type === "tool_use",
160
+ );
161
+
162
+ // No tool use -> turn end
163
+ if (toolUseParts.length === 0) {
164
+ break;
165
+ }
166
+
167
+ const validation = toolExecutor.validateBatch(toolUseParts);
168
+ if (!validation.isValid) {
169
+ stateManager.appendMessages([
170
+ {
171
+ role: "user",
172
+ content: validation.toolResults,
173
+ },
174
+ ]);
175
+ if (validation.errorMessage) {
176
+ console.error(styleText("yellow", validation.errorMessage));
177
+ }
178
+ continue;
179
+ }
180
+
181
+ // Approve tool use
182
+ const decisions = toolUseParts.map(toolUseApprover.isAllowedToolUse);
183
+
184
+ const hasDeniedToolUse = decisions.some((d) => d.action === "deny");
185
+ if (hasDeniedToolUse) {
186
+ /** @type {MessageContentToolResult[]} */
187
+ const toolResults = toolUseParts.map((toolUse, index) => {
188
+ const decision = decisions[index];
189
+ const rejectionMessage =
190
+ decision.action === "deny"
191
+ ? `Tool call rejected. ${decision.reason || ""}`.trim()
192
+ : "Tool call rejected due to other denied tool calls";
193
+
194
+ return {
195
+ type: "tool_result",
196
+ toolUseId: toolUse.toolUseId,
197
+ toolName: toolUse.toolName,
198
+ content: [{ type: "text", text: rejectionMessage }],
199
+ isError: true,
200
+ };
201
+ });
202
+ stateManager.appendMessages([{ role: "user", content: toolResults }]);
203
+ continue;
204
+ }
205
+
206
+ const isAllToolUseApproved = decisions.every((d) => d.action === "allow");
207
+ if (!isAllToolUseApproved) {
208
+ agentEventEmitter.emit("toolUseRequest");
209
+ break;
210
+ }
211
+
212
+ const executionResult = await toolExecutor.executeBatch(toolUseParts);
213
+
214
+ if (!executionResult.success) {
215
+ stateManager.appendMessages([
216
+ {
217
+ role: "user",
218
+ content: executionResult.errors,
219
+ },
220
+ {
221
+ role: "user",
222
+ content: [
223
+ {
224
+ type: "text",
225
+ text: executionResult.errorMessage,
226
+ },
227
+ ],
228
+ },
229
+ ]);
230
+ console.error(styleText("yellow", executionResult.errorMessage));
231
+ continue;
232
+ }
233
+
234
+ const toolResults = executionResult.results;
235
+
236
+ if (
237
+ applyCompactContextIfCalled(stateManager, toolUseParts, toolResults)
238
+ ) {
239
+ continue;
240
+ }
241
+
242
+ const result = subagentManager.processToolResults(
243
+ toolUseParts,
244
+ toolResults,
245
+ stateManager.getMessages(),
246
+ );
247
+ stateManager.setMessages(result.messages);
248
+ if (result.newMessage) {
249
+ stateManager.appendMessages([result.newMessage]);
250
+ } else {
251
+ stateManager.appendMessages([{ role: "user", content: toolResults }]);
252
+ }
253
+ }
254
+ }
255
+
256
+ return {
257
+ handleUserInput,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * @typedef {Object} InputHandlerContext
263
+ * @property {StateManager} stateManager
264
+ * @property {import("./toolExecutor.mjs").ToolExecutor} toolExecutor
265
+ * @property {import("./subagent.mjs").SubagentManager} subagentManager
266
+ * @property {import("./tool.d.ts").ToolUseApprover} toolUseApprover
267
+ */
268
+
269
+ /**
270
+ * @typedef {ReturnType<typeof createInputHandler>} InputHandler
271
+ */
272
+
273
+ /**
274
+ * Create an input handler.
275
+ *
276
+ * @param {InputHandlerContext} context
277
+ */
278
+ export function createInputHandler(context) {
279
+ const { stateManager, toolExecutor, subagentManager, toolUseApprover } =
280
+ context;
281
+
282
+ /**
283
+ * Determine input type based on current state and input.
284
+ * @param {UserMessage["content"]} input
285
+ * @returns {'toolApproval' | 'resume' | 'text'}
286
+ */
287
+ function determineInputType(input) {
288
+ const lastMessage = stateManager.getMessageAt(-1);
289
+
290
+ // Check if there's a pending tool call
291
+ if (lastMessage?.content.some((part) => part.type === "tool_use")) {
292
+ return "toolApproval";
293
+ }
294
+
295
+ if (
296
+ input.length === 1 &&
297
+ input[0].type === "text" &&
298
+ input[0].text.toLowerCase() === "/resume"
299
+ ) {
300
+ return "resume";
301
+ }
302
+
303
+ return "text";
304
+ }
305
+
306
+ /**
307
+ * Handle tool approval/rejection input.
308
+ * @param {UserMessage["content"]} input
309
+ */
310
+ async function handleToolApproval(input) {
311
+ const lastMessage = stateManager.getMessageAt(-1);
312
+ if (!lastMessage) return;
313
+
314
+ /** @type {MessageContentToolUse[]} */
315
+ const toolUseParts = lastMessage.content.filter(
316
+ (part) => part.type === "tool_use",
317
+ );
318
+
319
+ const isApproval =
320
+ input.length === 1 &&
321
+ input[0].type === "text" &&
322
+ input[0].text.toLocaleLowerCase().match(/^(yes|y|y)$/i);
323
+
324
+ if (isApproval) {
325
+ if (
326
+ /** @type {MessageContentText} */ (input[0]).text.match(/^(YES|Y)$/)
327
+ ) {
328
+ for (const toolUse of toolUseParts) {
329
+ toolUseApprover.allowToolUse(toolUse);
330
+ }
331
+ }
332
+
333
+ const executionResult = await toolExecutor.executeBatch(toolUseParts);
334
+ if (!executionResult.success) {
335
+ stateManager.appendMessages([
336
+ { role: "user", content: executionResult.errors },
337
+ ]);
338
+ return;
339
+ }
340
+
341
+ const toolResults = executionResult.results;
342
+
343
+ if (
344
+ applyCompactContextIfCalled(stateManager, toolUseParts, toolResults)
345
+ ) {
346
+ return;
347
+ }
348
+
349
+ const result = subagentManager.processToolResults(
350
+ toolUseParts,
351
+ toolResults,
352
+ stateManager.getMessages(),
353
+ );
354
+ stateManager.setMessages(result.messages);
355
+
356
+ if (result.newMessage) {
357
+ stateManager.appendMessages([result.newMessage]);
358
+ } else {
359
+ stateManager.appendMessages([{ role: "user", content: toolResults }]);
360
+ }
361
+ } else {
362
+ // Rejected
363
+ /** @type {MessageContentToolResult[]} */
364
+ const toolResults = toolUseParts.map((toolUse) => ({
365
+ type: "tool_result",
366
+ toolUseId: toolUse.toolUseId,
367
+ toolName: toolUse.toolName,
368
+ content: [{ type: "text", text: "Tool call rejected" }],
369
+ isError: true,
370
+ }));
371
+
372
+ stateManager.appendMessages([
373
+ { role: "user", content: toolResults },
374
+ {
375
+ role: "user",
376
+ content: input,
377
+ },
378
+ ]);
379
+ }
380
+ }
381
+
382
+ async function handleResume() {
383
+ // Resume the conversation stopped by unexpected error, etc.
384
+ // No state changes needed
385
+ }
386
+
387
+ /**
388
+ * @param {UserMessage["content"]} input
389
+ */
390
+ async function handleText(input) {
391
+ stateManager.appendMessages([
392
+ {
393
+ role: "user",
394
+ content: input,
395
+ },
396
+ ]);
397
+ }
398
+
399
+ return {
400
+ /**
401
+ * @param {UserMessage["content"]} input
402
+ */
403
+ async handle(input) {
404
+ const inputType = determineInputType(input);
405
+
406
+ switch (inputType) {
407
+ case "toolApproval":
408
+ await handleToolApproval(input);
409
+ break;
410
+ case "resume":
411
+ await handleResume();
412
+ break;
413
+ case "text":
414
+ await handleText(input);
415
+ break;
416
+ }
417
+ },
418
+ };
419
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @import { Message } from "./model"
3
+ */
4
+
5
+ /**
6
+ * @typedef {ReturnType<typeof createStateManager>} StateManager
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} StateEventHandlers
11
+ * @property {(messages: Message[]) => void} onMessagesAppended
12
+ */
13
+
14
+ /**
15
+ * Creates a state manager for message handling.
16
+ * @param {Message[]} initialMessages
17
+ * @param {StateEventHandlers} handlers
18
+ */
19
+ export function createStateManager(initialMessages, handlers) {
20
+ /** @type {Message[]} */
21
+ let messages = [...initialMessages];
22
+
23
+ return {
24
+ /** Get all messages (immutable copy) */
25
+ getMessages: () => [...messages],
26
+
27
+ /** Get message at specific index (supports -1 for last) */
28
+ getMessageAt: /** @param {number} index */ (index) => messages.at(index),
29
+
30
+ /** Append messages */
31
+ appendMessages: /** @param {Message[]} newMessages */ (newMessages) => {
32
+ messages = [...messages, ...newMessages];
33
+ handlers.onMessagesAppended(newMessages);
34
+ },
35
+
36
+ /** Replace all messages */
37
+ setMessages: /** @param {Message[]} newMessages */ (newMessages) => {
38
+ messages = [...newMessages];
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,164 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { loadAppConfig } from "./config.mjs";
5
+ import { CLAUDE_CODE_PLUGIN_DIR } from "./env.mjs";
6
+
7
+ /**
8
+ * @typedef {Object} ClaudeCodePluginRepo
9
+ * @property {string} source
10
+ * @property {Array<{name: string, path: string, only?: string}>} plugins
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} ClaudeCodePlugin
15
+ * @property {string} name
16
+ * @property {string} path
17
+ * @property {RegExp} [only]
18
+ */
19
+
20
+ /**
21
+ * Resolve plugin paths from hierarchical config structure.
22
+ * Converts {source, plugins} to flat {name, path, only} with full paths.
23
+ * @param {ClaudeCodePluginRepo[]} repos
24
+ * @returns {ClaudeCodePlugin[]}
25
+ */
26
+ export function resolvePluginPaths(repos) {
27
+ if (!repos) return [];
28
+
29
+ /** @type {ClaudeCodePlugin[]} */
30
+ const result = [];
31
+
32
+ for (const repo of repos) {
33
+ const ownerRepo = extractOwnerRepo(repo.source);
34
+ if (!ownerRepo) {
35
+ console.warn(`Invalid source URL: ${repo.source}`);
36
+ continue;
37
+ }
38
+
39
+ for (const plugin of repo.plugins) {
40
+ // Compile only pattern to RegExp
41
+ let only;
42
+ if (plugin.only) {
43
+ try {
44
+ only = new RegExp(plugin.only);
45
+ } catch (err) {
46
+ console.warn(
47
+ `Invalid regex pattern "${plugin.only}" for plugin "${plugin.name}":`,
48
+ err instanceof Error ? err.message : String(err),
49
+ );
50
+ }
51
+ }
52
+
53
+ result.push({
54
+ name: plugin.name,
55
+ path: path.join(CLAUDE_CODE_PLUGIN_DIR, ownerRepo, plugin.path),
56
+ only,
57
+ });
58
+ }
59
+ }
60
+
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Install Claude Code plugins by cloning repositories.
66
+ */
67
+ export async function installClaudeCodePlugins() {
68
+ const { appConfig } = await loadAppConfig({ skipTrustCheck: true });
69
+ const repos = appConfig.claudeCodePlugins ?? [];
70
+
71
+ if (repos.length === 0) {
72
+ console.log("No plugins configured.");
73
+ return;
74
+ }
75
+
76
+ let installed = 0;
77
+ let skipped = 0;
78
+ let failed = 0;
79
+
80
+ // Track paths for summary
81
+ /** @type {string[]} */
82
+ const installedPaths = [];
83
+ /** @type {string[]} */
84
+ const skippedPaths = [];
85
+
86
+ // Ensure plugin directory exists
87
+ await fs.mkdir(CLAUDE_CODE_PLUGIN_DIR, { recursive: true });
88
+
89
+ for (const repo of repos) {
90
+ const ownerRepo = extractOwnerRepo(repo.source);
91
+ if (!ownerRepo) {
92
+ console.error(`❌ Invalid source URL: ${repo.source}`);
93
+ failed++;
94
+ continue;
95
+ }
96
+
97
+ const destPath = path.join(CLAUDE_CODE_PLUGIN_DIR, ownerRepo);
98
+
99
+ // Check if already exists
100
+ const exists = await fs
101
+ .access(destPath)
102
+ .then(() => true)
103
+ .catch(() => false);
104
+ if (exists) {
105
+ console.log(`⏭️ Skipping ${repo.source} → ${destPath}: already installed`);
106
+ skippedPaths.push(destPath);
107
+ skipped++;
108
+ continue;
109
+ }
110
+
111
+ // Clone the repository
112
+ console.log(`📥 Installing ${repo.source}...`);
113
+ try {
114
+ await new Promise((resolve, reject) => {
115
+ execFile(
116
+ "git",
117
+ ["clone", "--depth", "1", repo.source, destPath],
118
+ (err) => {
119
+ if (err) reject(err);
120
+ else resolve(undefined);
121
+ },
122
+ );
123
+ });
124
+ console.log(`✅ Installed to ${destPath}`);
125
+ installedPaths.push(destPath);
126
+ installed++;
127
+ } catch (error) {
128
+ console.error(
129
+ `❌ Failed to install: ${error instanceof Error ? error.message : String(error)}`,
130
+ );
131
+ failed++;
132
+ }
133
+ }
134
+
135
+ console.log(
136
+ `\n📊 Summary: ${installed} installed, ${skipped} skipped, ${failed} failed`,
137
+ );
138
+
139
+ if (installedPaths.length > 0) {
140
+ console.log("\nInstalled:");
141
+ for (const p of installedPaths) {
142
+ console.log(` • ${p}`);
143
+ }
144
+ }
145
+
146
+ if (skippedPaths.length > 0) {
147
+ console.log("\nSkipped:");
148
+ for (const p of skippedPaths) {
149
+ console.log(` • ${p}`);
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Extract owner/repo from source URL.
156
+ * @param {string} source
157
+ * @returns {string|null}
158
+ */
159
+ function extractOwnerRepo(source) {
160
+ // Handle: https://github.com/owner/repo
161
+ // Handle: git@github.com:owner/repo.git
162
+ const match = source.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
163
+ return match ? match[1] : null;
164
+ }