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.
Files changed (32) hide show
  1. package/dist/server/cli/headless/claude-invoker-stream.d.ts +76 -1
  2. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker-stream.js +28 -16
  4. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +2 -0
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/improvisation-session-manager.d.ts +3 -0
  9. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  10. package/dist/server/cli/improvisation-session-manager.js +50 -39
  11. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  12. package/dist/server/services/websocket/git-worktree-handlers.js +25 -16
  13. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  14. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  15. package/dist/server/services/websocket/session-handlers.js +11 -1
  16. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  17. package/dist/server/services/websocket/skill-handlers.d.ts +9 -0
  18. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  19. package/dist/server/services/websocket/skill-handlers.js +244 -3
  20. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  21. package/dist/server/services/websocket/types.d.ts +44 -3
  22. package/dist/server/services/websocket/types.d.ts.map +1 -1
  23. package/dist/server/services/websocket/types.js +38 -0
  24. package/dist/server/services/websocket/types.js.map +1 -1
  25. package/package.json +1 -1
  26. package/server/cli/headless/claude-invoker-stream.ts +114 -32
  27. package/server/cli/headless/claude-invoker.ts +2 -0
  28. package/server/cli/improvisation-session-manager.ts +59 -43
  29. package/server/services/websocket/git-worktree-handlers.ts +30 -14
  30. package/server/services/websocket/session-handlers.ts +17 -1
  31. package/server/services/websocket/skill-handlers.ts +260 -3
  32. 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 = userPrompt;
149
+ this._currentUserPrompt = displayPrompt;
149
150
  this._currentSequenceNumber = sequenceNumber;
150
- this.emit('onMovementStart', sequenceNumber, userPrompt, isAutoContinue);
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: userPrompt, timestamp: Date.now(), executionStartTimestamp: this._executionStartTimestamp },
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, userPrompt, sequenceNumber, _execStart);
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, userPrompt, sequenceNumber, _execStart, state.retryLog, isAutoContinue);
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._isExecuting = false;
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
- this.queueOutput('\n⟳ Response appears incomplete — auto-continuing…\n');
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 removeResult = await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
409
- if (removeResult.exitCode !== 0) {
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 deleteFlag = strategy === 'squash' ? '-D' : '-d';
423
- const branchResult = await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
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
- session.executePrompt(msg.data.prompt, attachments, { workingDir: worktreeDir });
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
- skills.push(...scanProjectSkills(projectSkillsDir));
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
- const seen = new Set(skills.map(s => s.name));
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
+ }