mstro-app 0.4.35 → 0.4.37
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.
- package/dist/server/cli/headless/claude-invoker-stream.d.ts +76 -1
- package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stream.js +28 -16
- package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +2 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +3 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +50 -39
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +25 -16
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +11 -1
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/skill-handlers.d.ts +9 -0
- package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/skill-handlers.js +244 -3
- package/dist/server/services/websocket/skill-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +44 -3
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +38 -0
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/claude-invoker-stream.ts +114 -32
- package/server/cli/headless/claude-invoker.ts +2 -0
- package/server/cli/improvisation-session-manager.ts +59 -43
- package/server/services/websocket/git-worktree-handlers.ts +30 -14
- package/server/services/websocket/session-handlers.ts +17 -1
- package/server/services/websocket/skill-handlers.ts +260 -3
- package/server/services/websocket/types.ts +123 -329
|
@@ -131,9 +131,10 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
131
131
|
|
|
132
132
|
// ========== Main Execution ==========
|
|
133
133
|
|
|
134
|
-
async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { workingDir?: string; isAutoContinue?: boolean }): Promise<MovementRecord> {
|
|
134
|
+
async executePrompt(userPrompt: string, attachments?: FileAttachment[], options?: { workingDir?: string; isAutoContinue?: boolean; displayPrompt?: string }): Promise<MovementRecord> {
|
|
135
135
|
const _execStart = Date.now();
|
|
136
136
|
const isAutoContinue = options?.isAutoContinue ?? false;
|
|
137
|
+
const displayPrompt = options?.displayPrompt ?? userPrompt;
|
|
137
138
|
this._isExecuting = true;
|
|
138
139
|
this._cancelled = false;
|
|
139
140
|
this._cancelCompleteEmitted = false;
|
|
@@ -145,9 +146,9 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
145
146
|
this.executionEventLog = [];
|
|
146
147
|
|
|
147
148
|
const sequenceNumber = this.history.movements.length + 1;
|
|
148
|
-
this._currentUserPrompt =
|
|
149
|
+
this._currentUserPrompt = displayPrompt;
|
|
149
150
|
this._currentSequenceNumber = sequenceNumber;
|
|
150
|
-
this.emit('onMovementStart', sequenceNumber,
|
|
151
|
+
this.emit('onMovementStart', sequenceNumber, displayPrompt, isAutoContinue);
|
|
151
152
|
trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
|
|
152
153
|
prompt_length: userPrompt.length,
|
|
153
154
|
has_attachments: !!(attachments && attachments.length > 0),
|
|
@@ -162,7 +163,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
162
163
|
const pendingMovement: MovementRecord = {
|
|
163
164
|
id: `prompt-${sequenceNumber}`,
|
|
164
165
|
sequenceNumber,
|
|
165
|
-
userPrompt,
|
|
166
|
+
userPrompt: displayPrompt,
|
|
166
167
|
timestamp: new Date().toISOString(),
|
|
167
168
|
tokensUsed: 0,
|
|
168
169
|
summary: '',
|
|
@@ -176,7 +177,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
176
177
|
try {
|
|
177
178
|
this.executionEventLog.push({
|
|
178
179
|
type: 'movementStart',
|
|
179
|
-
data: { sequenceNumber, prompt:
|
|
180
|
+
data: { sequenceNumber, prompt: displayPrompt, timestamp: Date.now(), executionStartTimestamp: this._executionStartTimestamp },
|
|
180
181
|
timestamp: Date.now(),
|
|
181
182
|
});
|
|
182
183
|
|
|
@@ -201,7 +202,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
201
202
|
let result = await this.runRetryLoop(state, sequenceNumber, promptWithAttachments, imageAttachments, options?.workingDir);
|
|
202
203
|
|
|
203
204
|
if (this._cancelled) {
|
|
204
|
-
return this.handleCancelledExecution(result,
|
|
205
|
+
return this.handleCancelledExecution(result, displayPrompt, sequenceNumber, _execStart);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
if (state.contextLost) this.claudeSessionId = undefined;
|
|
@@ -209,7 +210,7 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
209
210
|
this.captureSessionAndSurfaceErrors(result);
|
|
210
211
|
this.isFirstPrompt = false;
|
|
211
212
|
|
|
212
|
-
const movement = this.buildMovementRecord(result,
|
|
213
|
+
const movement = this.buildMovementRecord(result, displayPrompt, sequenceNumber, _execStart, state.retryLog, isAutoContinue);
|
|
213
214
|
this.handleConflicts(result);
|
|
214
215
|
this.persistMovement(movement);
|
|
215
216
|
|
|
@@ -218,44 +219,12 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
218
219
|
this.executionEventLog = [];
|
|
219
220
|
|
|
220
221
|
this.emitMovementComplete(movement, result, _execStart, sequenceNumber);
|
|
221
|
-
|
|
222
|
-
if (this.shouldAutoContinue(result, userPrompt)) {
|
|
223
|
-
this.scheduleAutoContinue();
|
|
224
|
-
}
|
|
222
|
+
this.maybeAutoContinue(result, userPrompt);
|
|
225
223
|
|
|
226
224
|
return movement;
|
|
227
225
|
|
|
228
226
|
} catch (error: unknown) {
|
|
229
|
-
this.
|
|
230
|
-
this._executionStartTimestamp = undefined;
|
|
231
|
-
this.executionEventLog = [];
|
|
232
|
-
this.currentRunner = null;
|
|
233
|
-
|
|
234
|
-
// Update the pending movement with error info so it's not lost
|
|
235
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
236
|
-
const errorMovement: MovementRecord = {
|
|
237
|
-
id: `prompt-${sequenceNumber}`,
|
|
238
|
-
sequenceNumber,
|
|
239
|
-
userPrompt,
|
|
240
|
-
timestamp: new Date().toISOString(),
|
|
241
|
-
tokensUsed: 0,
|
|
242
|
-
summary: '',
|
|
243
|
-
filesModified: [],
|
|
244
|
-
errorOutput: errorMessage,
|
|
245
|
-
durationMs: Date.now() - _execStart,
|
|
246
|
-
};
|
|
247
|
-
this.persistMovement(errorMovement);
|
|
248
|
-
|
|
249
|
-
this.emit('onMovementError', error);
|
|
250
|
-
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
|
|
251
|
-
error_message: errorMessage.slice(0, 200),
|
|
252
|
-
sequence_number: sequenceNumber,
|
|
253
|
-
duration_ms: Date.now() - _execStart,
|
|
254
|
-
model: this.options.model || 'default',
|
|
255
|
-
});
|
|
256
|
-
this.queueOutput(`\n❌ Error: ${errorMessage}\n`);
|
|
257
|
-
this.flushOutputQueue();
|
|
258
|
-
throw error;
|
|
227
|
+
this.handleExecutionError(error, displayPrompt, sequenceNumber, _execStart);
|
|
259
228
|
} finally {
|
|
260
229
|
this.flushOutputQueue();
|
|
261
230
|
}
|
|
@@ -410,6 +379,43 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
410
379
|
return cancelledMovement;
|
|
411
380
|
}
|
|
412
381
|
|
|
382
|
+
private handleExecutionError(
|
|
383
|
+
error: unknown,
|
|
384
|
+
displayPrompt: string,
|
|
385
|
+
sequenceNumber: number,
|
|
386
|
+
execStart: number,
|
|
387
|
+
): never {
|
|
388
|
+
this._isExecuting = false;
|
|
389
|
+
this._executionStartTimestamp = undefined;
|
|
390
|
+
this.executionEventLog = [];
|
|
391
|
+
this.currentRunner = null;
|
|
392
|
+
|
|
393
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
394
|
+
const errorMovement: MovementRecord = {
|
|
395
|
+
id: `prompt-${sequenceNumber}`,
|
|
396
|
+
sequenceNumber,
|
|
397
|
+
userPrompt: displayPrompt,
|
|
398
|
+
timestamp: new Date().toISOString(),
|
|
399
|
+
tokensUsed: 0,
|
|
400
|
+
summary: '',
|
|
401
|
+
filesModified: [],
|
|
402
|
+
errorOutput: errorMessage,
|
|
403
|
+
durationMs: Date.now() - execStart,
|
|
404
|
+
};
|
|
405
|
+
this.persistMovement(errorMovement);
|
|
406
|
+
|
|
407
|
+
this.emit('onMovementError', error);
|
|
408
|
+
trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
|
|
409
|
+
error_message: errorMessage.slice(0, 200),
|
|
410
|
+
sequence_number: sequenceNumber,
|
|
411
|
+
duration_ms: Date.now() - execStart,
|
|
412
|
+
model: this.options.model || 'default',
|
|
413
|
+
});
|
|
414
|
+
this.queueOutput(`\n❌ Error: ${errorMessage}\n`);
|
|
415
|
+
this.flushOutputQueue();
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
|
|
413
419
|
// ========== Post-Execution Helpers ==========
|
|
414
420
|
|
|
415
421
|
private captureSessionAndSurfaceErrors(result: HeadlessRunResult): void {
|
|
@@ -493,6 +499,15 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
493
499
|
private _autoContinuePending = false;
|
|
494
500
|
private static readonly MAX_AUTO_CONTINUES = 1;
|
|
495
501
|
|
|
502
|
+
private maybeAutoContinue(result: HeadlessRunResult, userPrompt: string): void {
|
|
503
|
+
const isStallKill = !this._cancelled && !!result.signalName;
|
|
504
|
+
if (isStallKill && this._autoContinueCount < ImprovisationSessionManager.MAX_AUTO_CONTINUES) {
|
|
505
|
+
this.scheduleAutoContinue('Process stalled');
|
|
506
|
+
} else if (this.shouldAutoContinue(result, userPrompt)) {
|
|
507
|
+
this.scheduleAutoContinue();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
496
511
|
private shouldAutoContinue(result: HeadlessRunResult, _userPrompt: string): boolean {
|
|
497
512
|
if (this._autoContinueCount >= ImprovisationSessionManager.MAX_AUTO_CONTINUES) return false;
|
|
498
513
|
if (this._cancelled) return false;
|
|
@@ -510,10 +525,11 @@ export class ImprovisationSessionManager extends EventEmitter {
|
|
|
510
525
|
return thinkingLen >= responseLen * 3;
|
|
511
526
|
}
|
|
512
527
|
|
|
513
|
-
private scheduleAutoContinue(): void {
|
|
528
|
+
private scheduleAutoContinue(reason?: string): void {
|
|
514
529
|
this._autoContinueCount++;
|
|
515
530
|
this._autoContinuePending = true;
|
|
516
|
-
|
|
531
|
+
const msg = reason || 'Response appears incomplete';
|
|
532
|
+
this.queueOutput(`\n[[MSTRO_AUTO_CONTINUE]] ${msg} — resuming session (retry ${this._autoContinueCount}/${ImprovisationSessionManager.MAX_AUTO_CONTINUES}).\n`);
|
|
517
533
|
this.flushOutputQueue();
|
|
518
534
|
|
|
519
535
|
setImmediate(() => {
|
|
@@ -391,6 +391,30 @@ async function detectMergeConflicts(mainPath: string): Promise<string[]> {
|
|
|
391
391
|
return result.stdout.trim().split('\n').filter(f => f.trim());
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
async function removeWorktreeWithFallback(
|
|
395
|
+
mainPath: string,
|
|
396
|
+
worktreePath: string,
|
|
397
|
+
): Promise<{ success: boolean; warning?: string }> {
|
|
398
|
+
const removeResult = await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
|
|
399
|
+
if (removeResult.exitCode === 0) return { success: true };
|
|
400
|
+
const forceResult = await executeGitCommand(['worktree', 'remove', '--force', worktreePath], mainPath);
|
|
401
|
+
if (forceResult.exitCode === 0) return { success: true };
|
|
402
|
+
return { success: false, warning: `Failed to remove worktree: ${forceResult.stderr || 'unknown error'}` };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function deleteBranchAfterMerge(
|
|
406
|
+
mainPath: string,
|
|
407
|
+
branchName: string,
|
|
408
|
+
strategy: string,
|
|
409
|
+
): Promise<string | undefined> {
|
|
410
|
+
const deleteFlag = strategy === 'squash' ? '-D' : '-d';
|
|
411
|
+
const result = await executeGitCommand(['branch', deleteFlag, branchName], mainPath);
|
|
412
|
+
if (result.exitCode !== 0) {
|
|
413
|
+
return `Failed to delete branch: ${result.stderr || 'unknown error'}`;
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
394
418
|
async function cleanupAfterMerge(
|
|
395
419
|
mainPath: string,
|
|
396
420
|
sourceBranch: string,
|
|
@@ -405,25 +429,17 @@ async function cleanupAfterMerge(
|
|
|
405
429
|
const wtList = await executeGitCommand(['worktree', 'list', '--porcelain'], mainPath);
|
|
406
430
|
const worktreePath = findWorktreePathForBranch(wtList.stdout, sourceBranch);
|
|
407
431
|
if (worktreePath && worktreePath !== mainPath) {
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
const forceResult = await executeGitCommand(['worktree', 'remove', '--force', worktreePath], mainPath);
|
|
411
|
-
if (forceResult.exitCode !== 0) {
|
|
412
|
-
warnings.push(`Failed to remove worktree: ${forceResult.stderr || 'unknown error'}`);
|
|
413
|
-
} else {
|
|
414
|
-
removedWorktreePath = worktreePath;
|
|
415
|
-
}
|
|
416
|
-
} else {
|
|
432
|
+
const result = await removeWorktreeWithFallback(mainPath, worktreePath);
|
|
433
|
+
if (result.success) {
|
|
417
434
|
removedWorktreePath = worktreePath;
|
|
435
|
+
} else if (result.warning) {
|
|
436
|
+
warnings.push(result.warning);
|
|
418
437
|
}
|
|
419
438
|
}
|
|
420
439
|
}
|
|
421
440
|
if (deleteBranch) {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
if (branchResult.exitCode !== 0) {
|
|
425
|
-
warnings.push(`Failed to delete branch: ${branchResult.stderr || 'unknown error'}`);
|
|
426
|
-
}
|
|
441
|
+
const warning = await deleteBranchAfterMerge(mainPath, sourceBranch, strategy);
|
|
442
|
+
if (warning) warnings.push(warning);
|
|
427
443
|
}
|
|
428
444
|
await executeGitCommand(['worktree', 'prune'], mainPath);
|
|
429
445
|
return { warnings, removedWorktreePath };
|
|
@@ -5,6 +5,7 @@ import type { FileAttachment, ImprovisationSessionManager } from '../../cli/impr
|
|
|
5
5
|
import { getModel } from '../settings.js';
|
|
6
6
|
import type { HandlerContext } from './handler-context.js';
|
|
7
7
|
import { runQualityScan } from './quality-service.js';
|
|
8
|
+
import { resolveSkillPrompt } from './skill-handlers.js';
|
|
8
9
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
9
10
|
|
|
10
11
|
// Re-export from extracted modules for backward compatibility
|
|
@@ -191,7 +192,22 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
|
|
|
191
192
|
const session = requireSession(ctx, ws, tabId);
|
|
192
193
|
const worktreeDir = ctx.gitDirectories.get(tabId);
|
|
193
194
|
const attachments = mergePreUploadedAttachments(ctx, tabId, msg.data.attachments);
|
|
194
|
-
|
|
195
|
+
|
|
196
|
+
// Resolve slash commands (e.g. "/code-review") to their SKILL.md prompt content.
|
|
197
|
+
// Claude Code's -p headless mode doesn't support skills natively, so we load
|
|
198
|
+
// the skill's instructions and pass them as the actual prompt.
|
|
199
|
+
const rawPrompt = msg.data.prompt as string;
|
|
200
|
+
const effectiveDir = worktreeDir || session.getSessionInfo().workingDir;
|
|
201
|
+
const resolved = resolveSkillPrompt(rawPrompt, effectiveDir);
|
|
202
|
+
|
|
203
|
+
session.executePrompt(
|
|
204
|
+
resolved ? resolved.prompt : rawPrompt,
|
|
205
|
+
attachments,
|
|
206
|
+
{
|
|
207
|
+
workingDir: worktreeDir,
|
|
208
|
+
displayPrompt: resolved ? rawPrompt : undefined,
|
|
209
|
+
},
|
|
210
|
+
);
|
|
195
211
|
break;
|
|
196
212
|
}
|
|
197
213
|
case 'cancel': {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
4
5
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
5
7
|
import { dirname, join } from 'node:path';
|
|
6
8
|
import { fileURLToPath } from 'node:url';
|
|
7
9
|
import { findSkillsDir } from '../../utils/paths.js';
|
|
@@ -10,6 +12,22 @@ import type { SkillEntry, WSContext } from './types.js';
|
|
|
10
12
|
|
|
11
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
14
|
const SYSTEM_AGENTS_DIR = join(__dirname, '..', 'plan', 'agents');
|
|
15
|
+
const USER_SKILLS_DIR = join(homedir(), '.claude', 'skills');
|
|
16
|
+
|
|
17
|
+
const PLATFORM_COMMANDS: SkillEntry[] = [
|
|
18
|
+
{
|
|
19
|
+
name: 'board',
|
|
20
|
+
displayName: '/board',
|
|
21
|
+
description: 'Convert the current chat conversation into a PM board with issues.',
|
|
22
|
+
source: 'platform',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'ship',
|
|
26
|
+
displayName: '/ship',
|
|
27
|
+
description: 'Convert chat into a PM board and auto-implement with AI agents.',
|
|
28
|
+
source: 'platform',
|
|
29
|
+
},
|
|
30
|
+
];
|
|
13
31
|
|
|
14
32
|
function parseFrontmatter(content: string): Record<string, string> {
|
|
15
33
|
if (!content.startsWith('---')) return {};
|
|
@@ -52,6 +70,32 @@ function scanProjectSkills(skillsDir: string): SkillEntry[] {
|
|
|
52
70
|
return entries;
|
|
53
71
|
}
|
|
54
72
|
|
|
73
|
+
function tryReadSkill(skillFile: string): Record<string, string> | null {
|
|
74
|
+
if (!existsSync(skillFile)) return null;
|
|
75
|
+
try {
|
|
76
|
+
return parseFrontmatter(readFileSync(skillFile, 'utf-8'));
|
|
77
|
+
} catch { return null; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function scanUserSkills(userSkillsDir: string, seen: Set<string>): SkillEntry[] {
|
|
81
|
+
if (!existsSync(userSkillsDir)) return [];
|
|
82
|
+
const entries: SkillEntry[] = [];
|
|
83
|
+
for (const name of readdirSync(userSkillsDir, { withFileTypes: true })) {
|
|
84
|
+
if (!name.isDirectory() || seen.has(name.name)) continue;
|
|
85
|
+
const fm = tryReadSkill(join(userSkillsDir, name.name, 'SKILL.md'));
|
|
86
|
+
if (!fm || fm['user-invocable'] === 'false') continue;
|
|
87
|
+
const skillName = fm.name || name.name;
|
|
88
|
+
if (seen.has(skillName)) continue;
|
|
89
|
+
entries.push({
|
|
90
|
+
name: skillName,
|
|
91
|
+
displayName: `/${skillName}`,
|
|
92
|
+
description: fm.description || '',
|
|
93
|
+
source: 'user',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
function scanSystemAgents(agentsDir: string, seen: Set<string>): SkillEntry[] {
|
|
56
100
|
if (!existsSync(agentsDir)) return [];
|
|
57
101
|
const entries: SkillEntry[] = [];
|
|
@@ -75,16 +119,229 @@ function scanSystemAgents(agentsDir: string, seen: Set<string>): SkillEntry[] {
|
|
|
75
119
|
}
|
|
76
120
|
|
|
77
121
|
export function handleListSkills(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
|
|
78
|
-
const skills: SkillEntry[] = [];
|
|
122
|
+
const skills: SkillEntry[] = [...PLATFORM_COMMANDS];
|
|
123
|
+
const seen = new Set(skills.map(s => s.name));
|
|
79
124
|
|
|
80
125
|
const projectSkillsDir = findSkillsDir(workingDir);
|
|
81
126
|
if (projectSkillsDir) {
|
|
82
|
-
|
|
127
|
+
const projectSkills = scanProjectSkills(projectSkillsDir);
|
|
128
|
+
for (const s of projectSkills) {
|
|
129
|
+
if (!seen.has(s.name)) {
|
|
130
|
+
skills.push(s);
|
|
131
|
+
seen.add(s.name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
83
134
|
}
|
|
84
135
|
|
|
85
|
-
|
|
136
|
+
skills.push(...scanUserSkills(USER_SKILLS_DIR, seen));
|
|
137
|
+
|
|
138
|
+
for (const s of skills) seen.add(s.name);
|
|
86
139
|
skills.push(...scanSystemAgents(SYSTEM_AGENTS_DIR, seen));
|
|
87
140
|
|
|
88
141
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
89
142
|
ctx.send(ws, { type: 'skillsList', data: { skills } });
|
|
90
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract prompt content from a SKILL.md file (everything after the frontmatter).
|
|
147
|
+
*/
|
|
148
|
+
function extractSkillContent(fileContent: string): string {
|
|
149
|
+
if (!fileContent.startsWith('---')) return fileContent;
|
|
150
|
+
const endIdx = fileContent.indexOf('---', 3);
|
|
151
|
+
if (endIdx === -1) return fileContent;
|
|
152
|
+
return fileContent.slice(endIdx + 3).trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface SkillFile {
|
|
156
|
+
content: string;
|
|
157
|
+
skillDir: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Find and read a skill's SKILL.md by name. Checks project skills first, then system agents.
|
|
162
|
+
* Returns the raw file content and the skill directory, or null if not found.
|
|
163
|
+
*/
|
|
164
|
+
function findSkillContent(skillName: string, workingDir: string): SkillFile | null {
|
|
165
|
+
// Project skills: .claude/skills/<name>/SKILL.md
|
|
166
|
+
const projectSkillsDir = findSkillsDir(workingDir);
|
|
167
|
+
if (projectSkillsDir) {
|
|
168
|
+
const skillDir = join(projectSkillsDir, skillName);
|
|
169
|
+
const skillFile = join(skillDir, 'SKILL.md');
|
|
170
|
+
if (existsSync(skillFile)) {
|
|
171
|
+
try {
|
|
172
|
+
return { content: readFileSync(skillFile, 'utf-8'), skillDir };
|
|
173
|
+
} catch { /* fall through */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// User skills: ~/.claude/skills/<name>/SKILL.md
|
|
178
|
+
const userSkillDir = join(USER_SKILLS_DIR, skillName);
|
|
179
|
+
const userSkillFile = join(userSkillDir, 'SKILL.md');
|
|
180
|
+
if (existsSync(userSkillFile)) {
|
|
181
|
+
try {
|
|
182
|
+
return { content: readFileSync(userSkillFile, 'utf-8'), skillDir: userSkillDir };
|
|
183
|
+
} catch { /* fall through */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// System agents: <agents-dir>/<name>.md
|
|
187
|
+
const agentFile = join(SYSTEM_AGENTS_DIR, `${skillName}.md`);
|
|
188
|
+
if (existsSync(agentFile)) {
|
|
189
|
+
try {
|
|
190
|
+
return { content: readFileSync(agentFile, 'utf-8'), skillDir: SYSTEM_AGENTS_DIR };
|
|
191
|
+
} catch { /* fall through */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Fill in {{mustache}} template variables in skill content.
|
|
199
|
+
*/
|
|
200
|
+
function fillTemplateVariables(content: string, vars: Record<string, string>): string {
|
|
201
|
+
let result = content;
|
|
202
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
203
|
+
result = result.replaceAll(`{{${key}}}`, value);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse shell-style quoted arguments: `"hello world" second` → ['hello world', 'second']
|
|
210
|
+
*/
|
|
211
|
+
function parseShellArgs(argsStr: string): string[] {
|
|
212
|
+
const args: string[] = [];
|
|
213
|
+
let current = '';
|
|
214
|
+
let inSingle = false;
|
|
215
|
+
let inDouble = false;
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < argsStr.length; i++) {
|
|
218
|
+
const ch = argsStr[i];
|
|
219
|
+
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
|
|
220
|
+
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
|
|
221
|
+
if (ch === ' ' && !inSingle && !inDouble) {
|
|
222
|
+
if (current) { args.push(current); current = ''; }
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
current += ch;
|
|
226
|
+
}
|
|
227
|
+
if (current) args.push(current);
|
|
228
|
+
return args;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Replace $ARGUMENTS, $ARGUMENTS[N], $0, $1, etc. with actual argument values.
|
|
233
|
+
*/
|
|
234
|
+
function fillArgumentVariables(content: string, userArgs: string): string {
|
|
235
|
+
let result = content;
|
|
236
|
+
const parsedArgs = parseShellArgs(userArgs);
|
|
237
|
+
|
|
238
|
+
// $ARGUMENTS[N] and $N — indexed access (must be replaced before $ARGUMENTS)
|
|
239
|
+
result = result.replace(/\$ARGUMENTS\[(\d+)]/g, (_, n) => parsedArgs[parseInt(n, 10)] ?? '');
|
|
240
|
+
result = result.replace(/\$(\d+)\b/g, (_, n) => parsedArgs[parseInt(n, 10)] ?? '');
|
|
241
|
+
|
|
242
|
+
// $ARGUMENTS — full argument string
|
|
243
|
+
result = result.replaceAll('$ARGUMENTS', userArgs);
|
|
244
|
+
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Execute inline shell commands: !`command` → command output.
|
|
250
|
+
* Also handles fenced ```! blocks for multi-line commands.
|
|
251
|
+
* Runs in the skill's working directory with a short timeout.
|
|
252
|
+
*/
|
|
253
|
+
function executeInlineShellCommands(content: string, workingDir: string): string {
|
|
254
|
+
// Fenced ```! blocks — multi-line shell execution
|
|
255
|
+
let result = content.replace(/```!\n([\s\S]*?)```/g, (_, block: string) => {
|
|
256
|
+
const cmd = block.trim();
|
|
257
|
+
try {
|
|
258
|
+
return execSync(cmd, { cwd: workingDir, timeout: 10_000, encoding: 'utf-8' }).trim();
|
|
259
|
+
} catch {
|
|
260
|
+
return `[shell command failed: ${cmd.split('\n')[0]}]`;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Inline !`command` syntax
|
|
265
|
+
result = result.replace(/!`([^`]+)`/g, (_, cmd: string) => {
|
|
266
|
+
try {
|
|
267
|
+
return execSync(cmd.trim(), { cwd: workingDir, timeout: 10_000, encoding: 'utf-8' }).trim();
|
|
268
|
+
} catch {
|
|
269
|
+
return `[shell command failed: ${cmd.trim()}]`;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ResolvedSkill {
|
|
277
|
+
/** The resolved prompt to send to the headless runner */
|
|
278
|
+
prompt: string;
|
|
279
|
+
/** The original skill name */
|
|
280
|
+
skillName: string;
|
|
281
|
+
/** Any user arguments after the skill name */
|
|
282
|
+
userArgs: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Resolve a slash command (e.g. "/code-review src/") into the skill's prompt content.
|
|
287
|
+
* Returns null if the prompt is not a slash command or the skill is not found.
|
|
288
|
+
*
|
|
289
|
+
* Implements the Claude Code skill spec:
|
|
290
|
+
* - Strips YAML frontmatter, extracts body as the prompt
|
|
291
|
+
* - Fills {{mustache}} template variables (dirPath, cliFindingsSection)
|
|
292
|
+
* - Substitutes $ARGUMENTS, $ARGUMENTS[N], $0/$1 with user-provided arguments
|
|
293
|
+
* - Executes inline shell commands (!`cmd` and ```! blocks)
|
|
294
|
+
* - Replaces ${CLAUDE_SKILL_DIR} with the skill's directory path
|
|
295
|
+
* - Appends user args as "ARGUMENTS: ..." if $ARGUMENTS is not used in the content
|
|
296
|
+
*/
|
|
297
|
+
function parseSlashCommand(trimmed: string): { skillName: string; userArgs: string } | null {
|
|
298
|
+
if (!trimmed.startsWith('/')) return null;
|
|
299
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
300
|
+
const skillName = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx);
|
|
301
|
+
const userArgs = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
|
|
302
|
+
return skillName ? { skillName, userArgs } : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const CLAUDE_SKILL_DIR_RE = /\$\{CLAUDE_SKILL_DIR}/g;
|
|
306
|
+
const CLAUDE_SESSION_ID_RE = /\$\{CLAUDE_SESSION_ID}/g;
|
|
307
|
+
|
|
308
|
+
function processSkillContent(rawContent: string, userArgs: string, skillDir: string, workingDir: string): string {
|
|
309
|
+
let content = extractSkillContent(rawContent);
|
|
310
|
+
|
|
311
|
+
content = fillTemplateVariables(content, {
|
|
312
|
+
dirPath: userArgs || workingDir,
|
|
313
|
+
cliFindingsSection: '',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const hasArgumentsPlaceholder = /\$ARGUMENTS|\$\d+\b/.test(content);
|
|
317
|
+
if (hasArgumentsPlaceholder) {
|
|
318
|
+
content = fillArgumentVariables(content, userArgs);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
content = content.replace(CLAUDE_SKILL_DIR_RE, skillDir);
|
|
322
|
+
content = content.replace(CLAUDE_SESSION_ID_RE, `mstro-${Date.now()}`);
|
|
323
|
+
|
|
324
|
+
if (/!`[^`]+`/.test(content) || /```!\n/.test(content)) {
|
|
325
|
+
content = executeInlineShellCommands(content, workingDir);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (userArgs && !hasArgumentsPlaceholder && !rawContent.includes('{{dirPath}}')) {
|
|
329
|
+
content = `${content}\n\nARGUMENTS: ${userArgs}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return content;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function resolveSkillPrompt(prompt: string, workingDir: string): ResolvedSkill | null {
|
|
336
|
+
const parsed = parseSlashCommand(prompt.trim());
|
|
337
|
+
if (!parsed) return null;
|
|
338
|
+
|
|
339
|
+
const found = findSkillContent(parsed.skillName, workingDir);
|
|
340
|
+
if (!found) return null;
|
|
341
|
+
|
|
342
|
+
const fm = parseFrontmatter(found.content);
|
|
343
|
+
if (fm['user-invocable'] === 'false') return null;
|
|
344
|
+
|
|
345
|
+
const skillPrompt = processSkillContent(found.content, parsed.userArgs, found.skillDir, workingDir);
|
|
346
|
+
return { prompt: skillPrompt, skillName: parsed.skillName, userArgs: parsed.userArgs };
|
|
347
|
+
}
|