mstro-app 0.4.35 → 0.4.38

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 (37) 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/plan/executor.d.ts +8 -0
  13. package/dist/server/services/plan/executor.d.ts.map +1 -1
  14. package/dist/server/services/plan/executor.js +47 -1
  15. package/dist/server/services/plan/executor.js.map +1 -1
  16. package/dist/server/services/websocket/git-worktree-handlers.js +25 -16
  17. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  18. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  19. package/dist/server/services/websocket/session-handlers.js +11 -1
  20. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  21. package/dist/server/services/websocket/skill-handlers.d.ts +9 -0
  22. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  23. package/dist/server/services/websocket/skill-handlers.js +244 -3
  24. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  25. package/dist/server/services/websocket/types.d.ts +44 -3
  26. package/dist/server/services/websocket/types.d.ts.map +1 -1
  27. package/dist/server/services/websocket/types.js +38 -0
  28. package/dist/server/services/websocket/types.js.map +1 -1
  29. package/package.json +1 -1
  30. package/server/cli/headless/claude-invoker-stream.ts +114 -32
  31. package/server/cli/headless/claude-invoker.ts +2 -0
  32. package/server/cli/improvisation-session-manager.ts +59 -43
  33. package/server/services/plan/executor.ts +51 -1
  34. package/server/services/websocket/git-worktree-handlers.ts +30 -14
  35. package/server/services/websocket/session-handlers.ts +17 -1
  36. package/server/services/websocket/skill-handlers.ts +260 -3
  37. package/server/services/websocket/types.ts +123 -329
@@ -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
+ }