pi-messenger 0.7.3

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 (45) hide show
  1. package/ARCHITECTURE.md +244 -0
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +394 -0
  4. package/banner.png +0 -0
  5. package/config-overlay.ts +172 -0
  6. package/config.ts +178 -0
  7. package/crew/agents/crew-docs-scout.md +55 -0
  8. package/crew/agents/crew-gap-analyst.md +105 -0
  9. package/crew/agents/crew-github-scout.md +111 -0
  10. package/crew/agents/crew-interview-generator.md +79 -0
  11. package/crew/agents/crew-plan-sync.md +64 -0
  12. package/crew/agents/crew-practice-scout.md +62 -0
  13. package/crew/agents/crew-repo-scout.md +65 -0
  14. package/crew/agents/crew-reviewer.md +58 -0
  15. package/crew/agents/crew-web-scout.md +85 -0
  16. package/crew/agents/crew-worker.md +95 -0
  17. package/crew/agents.ts +200 -0
  18. package/crew/handlers/interview.ts +211 -0
  19. package/crew/handlers/plan.ts +358 -0
  20. package/crew/handlers/review.ts +341 -0
  21. package/crew/handlers/status.ts +257 -0
  22. package/crew/handlers/sync.ts +232 -0
  23. package/crew/handlers/task.ts +511 -0
  24. package/crew/handlers/work.ts +289 -0
  25. package/crew/id-allocator.ts +44 -0
  26. package/crew/index.ts +229 -0
  27. package/crew/state.ts +116 -0
  28. package/crew/store.ts +480 -0
  29. package/crew/types.ts +164 -0
  30. package/crew/utils/artifacts.ts +65 -0
  31. package/crew/utils/config.ts +104 -0
  32. package/crew/utils/discover.ts +170 -0
  33. package/crew/utils/install.ts +373 -0
  34. package/crew/utils/progress.ts +107 -0
  35. package/crew/utils/result.ts +16 -0
  36. package/crew/utils/truncate.ts +79 -0
  37. package/crew-overlay.ts +259 -0
  38. package/handlers.ts +799 -0
  39. package/index.ts +591 -0
  40. package/lib.ts +232 -0
  41. package/overlay.ts +687 -0
  42. package/package.json +20 -0
  43. package/skills/pi-messenger-crew/SKILL.md +140 -0
  44. package/store.ts +1068 -0
  45. package/tsconfig.json +19 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Crew - Configuration Loading
3
+ *
4
+ * Loads and merges user-level and project-level configuration.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import type { MaxOutputConfig } from "./truncate.js";
11
+
12
+ const USER_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "pi-messenger.json");
13
+ const PROJECT_CONFIG_FILE = "config.json";
14
+
15
+ export interface CrewConfig {
16
+ concurrency: {
17
+ scouts: number;
18
+ workers: number;
19
+ };
20
+ truncation: {
21
+ scouts: MaxOutputConfig;
22
+ workers: MaxOutputConfig;
23
+ reviewers: MaxOutputConfig;
24
+ analysts: MaxOutputConfig;
25
+ };
26
+ artifacts: {
27
+ enabled: boolean;
28
+ cleanupDays: number;
29
+ };
30
+ memory: { enabled: boolean };
31
+ planSync: { enabled: boolean };
32
+ review: { enabled: boolean; maxIterations: number };
33
+ work: { maxAttemptsPerTask: number; maxWaves: number; stopOnBlock: boolean };
34
+ }
35
+
36
+ const DEFAULT_CONFIG: CrewConfig = {
37
+ concurrency: {
38
+ scouts: 4,
39
+ workers: 2,
40
+ },
41
+ truncation: {
42
+ scouts: { bytes: 51200, lines: 500 },
43
+ workers: { bytes: 204800, lines: 5000 },
44
+ reviewers: { bytes: 102400, lines: 2000 },
45
+ analysts: { bytes: 102400, lines: 2000 },
46
+ },
47
+ artifacts: { enabled: true, cleanupDays: 7 },
48
+ memory: { enabled: false },
49
+ planSync: { enabled: false },
50
+ review: { enabled: true, maxIterations: 3 },
51
+ work: { maxAttemptsPerTask: 5, maxWaves: 50, stopOnBlock: false },
52
+ };
53
+
54
+ function loadJson(filePath: string): Record<string, unknown> {
55
+ try {
56
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ function deepMerge<T extends object>(target: T, ...sources: Partial<T>[]): T {
63
+ const result = { ...target };
64
+ for (const source of sources) {
65
+ for (const key of Object.keys(source) as (keyof T)[]) {
66
+ const targetVal = result[key];
67
+ const sourceVal = source[key];
68
+ if (sourceVal && typeof sourceVal === "object" && !Array.isArray(sourceVal)) {
69
+ result[key] = deepMerge(targetVal as object, sourceVal as object) as T[keyof T];
70
+ } else if (sourceVal !== undefined) {
71
+ result[key] = sourceVal as T[keyof T];
72
+ }
73
+ }
74
+ }
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Load crew configuration with priority: defaults <- user <- project
80
+ */
81
+ export function loadCrewConfig(crewDir: string): CrewConfig {
82
+ // User-level config (from ~/.pi/agent/pi-messenger.json -> crew section)
83
+ const userConfig = loadJson(USER_CONFIG_PATH);
84
+ const userCrewConfig = (userConfig.crew ?? {}) as Partial<CrewConfig>;
85
+
86
+ // Project-level config (from .pi/messenger/crew/config.json)
87
+ const projectConfig = loadJson(path.join(crewDir, PROJECT_CONFIG_FILE)) as Partial<CrewConfig>;
88
+
89
+ // Merge: defaults <- user <- project
90
+ return deepMerge(DEFAULT_CONFIG, userCrewConfig, projectConfig);
91
+ }
92
+
93
+ /**
94
+ * Get truncation config for a specific role.
95
+ */
96
+ export function getTruncationForRole(config: CrewConfig, role: string): MaxOutputConfig {
97
+ switch (role) {
98
+ case "scout": return config.truncation.scouts;
99
+ case "worker": return config.truncation.workers;
100
+ case "reviewer": return config.truncation.reviewers;
101
+ case "analyst": return config.truncation.analysts;
102
+ default: return config.truncation.workers;
103
+ }
104
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Crew - Agent Discovery
3
+ *
4
+ * Discovers agent definitions from user and project directories,
5
+ * with crew-specific filtering by role.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import type { MaxOutputConfig } from "./truncate.js";
12
+
13
+ export type CrewRole = "scout" | "worker" | "reviewer" | "analyst";
14
+
15
+ export interface CrewAgentConfig {
16
+ name: string;
17
+ description: string;
18
+ tools?: string[];
19
+ model?: string;
20
+ systemPrompt: string;
21
+ source: "user" | "project";
22
+ filePath: string;
23
+ // Crew-specific extensions
24
+ crewRole?: CrewRole;
25
+ maxOutput?: MaxOutputConfig;
26
+ parallel?: boolean;
27
+ retryable?: boolean;
28
+ }
29
+
30
+ export interface AgentDiscoveryResult {
31
+ agents: CrewAgentConfig[];
32
+ projectAgentsDir: string | null;
33
+ }
34
+
35
+ /**
36
+ * Parse YAML-like frontmatter from markdown content.
37
+ */
38
+ function parseFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {
39
+ const normalized = content.replace(/\r\n/g, "\n");
40
+ if (!normalized.startsWith("---")) {
41
+ return { frontmatter: {}, body: normalized };
42
+ }
43
+ const endIndex = normalized.indexOf("\n---", 3);
44
+ if (endIndex === -1) {
45
+ return { frontmatter: {}, body: normalized };
46
+ }
47
+ const frontmatterBlock = normalized.slice(4, endIndex);
48
+ const body = normalized.slice(endIndex + 4).trim();
49
+
50
+ const frontmatter: Record<string, unknown> = {};
51
+ for (const line of frontmatterBlock.split("\n")) {
52
+ const match = line.match(/^([\w-]+):\s*(.*)$/);
53
+ if (match) {
54
+ let value: unknown = match[2].trim();
55
+ // Handle quoted strings
56
+ if ((value as string).startsWith('"') || (value as string).startsWith("'")) {
57
+ value = (value as string).slice(1, -1);
58
+ }
59
+ // Handle inline YAML objects (e.g., maxOutput: { bytes: 1024, lines: 500 })
60
+ if ((value as string).startsWith("{") && (value as string).endsWith("}")) {
61
+ try {
62
+ // YAML inline objects don't require quoted keys, but JSON does
63
+ const jsonStr = (value as string).replace(/(\w+):/g, '"$1":');
64
+ value = JSON.parse(jsonStr);
65
+ } catch {
66
+ // Keep as string if parse fails
67
+ }
68
+ }
69
+ // Handle booleans
70
+ if (value === "true") value = true;
71
+ if (value === "false") value = false;
72
+ frontmatter[match[1]] = value;
73
+ }
74
+ }
75
+ return { frontmatter, body };
76
+ }
77
+
78
+ function isDirectory(p: string): boolean {
79
+ try {
80
+ return fs.statSync(p).isDirectory();
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function findProjectAgentsDir(cwd: string): string | null {
87
+ let currentDir = cwd;
88
+ while (true) {
89
+ const candidate = path.join(currentDir, ".pi", "agents");
90
+ if (isDirectory(candidate)) return candidate;
91
+ const parentDir = path.dirname(currentDir);
92
+ if (parentDir === currentDir) return null;
93
+ currentDir = parentDir;
94
+ }
95
+ }
96
+
97
+ function loadAgentsFromDir(dir: string, source: "user" | "project"): CrewAgentConfig[] {
98
+ if (!fs.existsSync(dir)) return [];
99
+ const agents: CrewAgentConfig[] = [];
100
+
101
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
102
+ if (!entry.name.endsWith(".md")) continue;
103
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
104
+
105
+ const filePath = path.join(dir, entry.name);
106
+ let content: string;
107
+ try {
108
+ content = fs.readFileSync(filePath, "utf-8");
109
+ } catch {
110
+ continue;
111
+ }
112
+ const { frontmatter, body } = parseFrontmatter(content);
113
+
114
+ if (!frontmatter.name || !frontmatter.description) continue;
115
+
116
+ // Parse tools (comma-separated, filter empty)
117
+ const tools = (frontmatter.tools as string)
118
+ ?.split(",")
119
+ .map(t => t.trim())
120
+ .filter(Boolean);
121
+
122
+ agents.push({
123
+ name: frontmatter.name as string,
124
+ description: frontmatter.description as string,
125
+ tools: tools && tools.length > 0 ? tools : undefined,
126
+ model: frontmatter.model as string | undefined,
127
+ systemPrompt: body,
128
+ source,
129
+ filePath,
130
+ // Crew extensions
131
+ crewRole: frontmatter.crewRole as CrewRole | undefined,
132
+ maxOutput: frontmatter.maxOutput as MaxOutputConfig | undefined,
133
+ parallel: frontmatter.parallel as boolean | undefined ?? true,
134
+ retryable: frontmatter.retryable as boolean | undefined ?? true,
135
+ });
136
+ }
137
+ return agents;
138
+ }
139
+
140
+ /**
141
+ * Discover all agents from user and/or project directories.
142
+ */
143
+ export function discoverAgents(cwd: string, scope: "user" | "project" | "both"): AgentDiscoveryResult {
144
+ const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
145
+ const projectDir = findProjectAgentsDir(cwd);
146
+
147
+ const userAgents = scope !== "project" ? loadAgentsFromDir(userDir, "user") : [];
148
+ const projectAgents = scope !== "user" && projectDir ? loadAgentsFromDir(projectDir, "project") : [];
149
+
150
+ // Project overrides user (same name = project wins)
151
+ const agentMap = new Map<string, CrewAgentConfig>();
152
+ for (const a of userAgents) agentMap.set(a.name, a);
153
+ for (const a of projectAgents) agentMap.set(a.name, a);
154
+
155
+ return { agents: Array.from(agentMap.values()), projectAgentsDir: projectDir };
156
+ }
157
+
158
+ /**
159
+ * Discover only crew agents (those with crewRole set).
160
+ */
161
+ export function discoverCrewAgents(cwd: string): CrewAgentConfig[] {
162
+ return discoverAgents(cwd, "both").agents.filter(a => a.crewRole !== undefined);
163
+ }
164
+
165
+ /**
166
+ * Get crew agents filtered by role.
167
+ */
168
+ export function getAgentsByRole(cwd: string, role: CrewRole): CrewAgentConfig[] {
169
+ return discoverCrewAgents(cwd).filter(a => a.crewRole === role);
170
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Crew - Agent & Skill Installer
3
+ *
4
+ * Copies crew agent definitions and skills from extension source to user directories.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { homedir } from "node:os";
11
+
12
+ // Resolve paths relative to this file
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ // Agents: crew/agents/ -> ~/.pi/agent/agents/
17
+ const SOURCE_AGENTS_DIR = path.resolve(__dirname, "..", "agents");
18
+ const TARGET_AGENTS_DIR = path.join(homedir(), ".pi", "agent", "agents");
19
+
20
+ // Skills: skills/ -> ~/.pi/agent/skills/
21
+ const SOURCE_SKILLS_DIR = path.resolve(__dirname, "..", "..", "skills");
22
+ const TARGET_SKILLS_DIR = path.join(homedir(), ".pi", "agent", "skills");
23
+
24
+ // List of crew agents to install
25
+ const CREW_AGENTS = [
26
+ // Scouts (5)
27
+ "crew-repo-scout.md",
28
+ "crew-practice-scout.md",
29
+ "crew-docs-scout.md",
30
+ "crew-web-scout.md",
31
+ "crew-github-scout.md",
32
+ // Analysts (3)
33
+ "crew-gap-analyst.md",
34
+ "crew-interview-generator.md",
35
+ "crew-plan-sync.md",
36
+ // Worker (1)
37
+ "crew-worker.md",
38
+ // Reviewer (1)
39
+ "crew-reviewer.md",
40
+ ];
41
+
42
+ // List of skills to install (directory names)
43
+ const CREW_SKILLS = [
44
+ "pi-messenger-crew",
45
+ ];
46
+
47
+ export interface InstallResult {
48
+ installed: string[];
49
+ updated: string[];
50
+ skipped: string[];
51
+ errors: string[];
52
+ targetDir: string;
53
+ }
54
+
55
+ /**
56
+ * Check if an agent needs updating by comparing modification times.
57
+ */
58
+ function needsUpdate(sourcePath: string, targetPath: string): boolean {
59
+ if (!fs.existsSync(targetPath)) return true;
60
+
61
+ try {
62
+ const sourceStat = fs.statSync(sourcePath);
63
+ const targetStat = fs.statSync(targetPath);
64
+ return sourceStat.mtimeMs > targetStat.mtimeMs;
65
+ } catch {
66
+ return true;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Check which agents are missing or need updating.
72
+ */
73
+ export function checkAgentStatus(): { missing: string[]; outdated: string[]; current: string[] } {
74
+ const missing: string[] = [];
75
+ const outdated: string[] = [];
76
+ const current: string[] = [];
77
+
78
+ for (const agent of CREW_AGENTS) {
79
+ const sourcePath = path.join(SOURCE_AGENTS_DIR, agent);
80
+ const targetPath = path.join(TARGET_AGENTS_DIR, agent);
81
+
82
+ if (!fs.existsSync(sourcePath)) {
83
+ // Source doesn't exist - skip
84
+ continue;
85
+ }
86
+
87
+ if (!fs.existsSync(targetPath)) {
88
+ missing.push(agent);
89
+ } else if (needsUpdate(sourcePath, targetPath)) {
90
+ outdated.push(agent);
91
+ } else {
92
+ current.push(agent);
93
+ }
94
+ }
95
+
96
+ return { missing, outdated, current };
97
+ }
98
+
99
+ /**
100
+ * Install or update crew agents.
101
+ *
102
+ * @param force - If true, overwrite even if target is newer
103
+ */
104
+ export function installAgents(force: boolean = false): InstallResult {
105
+ const result: InstallResult = {
106
+ installed: [],
107
+ updated: [],
108
+ skipped: [],
109
+ errors: [],
110
+ targetDir: TARGET_AGENTS_DIR,
111
+ };
112
+
113
+ // Ensure target directory exists
114
+ if (!fs.existsSync(TARGET_AGENTS_DIR)) {
115
+ try {
116
+ fs.mkdirSync(TARGET_AGENTS_DIR, { recursive: true });
117
+ } catch (err) {
118
+ result.errors.push(`Failed to create directory: ${TARGET_AGENTS_DIR}`);
119
+ return result;
120
+ }
121
+ }
122
+
123
+ for (const agent of CREW_AGENTS) {
124
+ const sourcePath = path.join(SOURCE_AGENTS_DIR, agent);
125
+ const targetPath = path.join(TARGET_AGENTS_DIR, agent);
126
+
127
+ // Check source exists
128
+ if (!fs.existsSync(sourcePath)) {
129
+ result.errors.push(`Source not found: ${agent}`);
130
+ continue;
131
+ }
132
+
133
+ // Check if we need to copy
134
+ const targetExists = fs.existsSync(targetPath);
135
+ const shouldUpdate = force || needsUpdate(sourcePath, targetPath);
136
+
137
+ if (!shouldUpdate) {
138
+ result.skipped.push(agent);
139
+ continue;
140
+ }
141
+
142
+ // Copy the file
143
+ try {
144
+ fs.copyFileSync(sourcePath, targetPath);
145
+ if (targetExists) {
146
+ result.updated.push(agent);
147
+ } else {
148
+ result.installed.push(agent);
149
+ }
150
+ } catch (err) {
151
+ result.errors.push(`Failed to copy ${agent}: ${err}`);
152
+ }
153
+ }
154
+
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Uninstall crew agents (remove from target directory).
160
+ */
161
+ export function uninstallAgents(): { removed: string[]; notFound: string[]; errors: string[] } {
162
+ const removed: string[] = [];
163
+ const notFound: string[] = [];
164
+ const errors: string[] = [];
165
+
166
+ for (const agent of CREW_AGENTS) {
167
+ const targetPath = path.join(TARGET_AGENTS_DIR, agent);
168
+
169
+ if (!fs.existsSync(targetPath)) {
170
+ notFound.push(agent);
171
+ continue;
172
+ }
173
+
174
+ try {
175
+ fs.unlinkSync(targetPath);
176
+ removed.push(agent);
177
+ } catch (err) {
178
+ errors.push(`Failed to remove ${agent}: ${err}`);
179
+ }
180
+ }
181
+
182
+ return { removed, notFound, errors };
183
+ }
184
+
185
+ /**
186
+ * Ensure agents are installed (auto-install if missing).
187
+ * Returns true if all agents are available.
188
+ */
189
+ export function ensureAgentsInstalled(): boolean {
190
+ const status = checkAgentStatus();
191
+
192
+ if (status.missing.length === 0 && status.outdated.length === 0) {
193
+ return true;
194
+ }
195
+
196
+ const result = installAgents();
197
+ return result.errors.length === 0;
198
+ }
199
+
200
+ /**
201
+ * Get the source agents directory path.
202
+ */
203
+ export function getSourceAgentsDir(): string {
204
+ return SOURCE_AGENTS_DIR;
205
+ }
206
+
207
+ /**
208
+ * Get the target agents directory path.
209
+ */
210
+ export function getTargetAgentsDir(): string {
211
+ return TARGET_AGENTS_DIR;
212
+ }
213
+
214
+ // =============================================================================
215
+ // Skills Installation
216
+ // =============================================================================
217
+
218
+ export interface SkillInstallResult {
219
+ installed: string[];
220
+ updated: string[];
221
+ skipped: string[];
222
+ errors: string[];
223
+ targetDir: string;
224
+ }
225
+
226
+ /**
227
+ * Check if a skill directory needs updating.
228
+ */
229
+ function skillNeedsUpdate(sourceDir: string, targetDir: string): boolean {
230
+ if (!fs.existsSync(targetDir)) return true;
231
+
232
+ const skillFile = path.join(sourceDir, "SKILL.md");
233
+ const targetFile = path.join(targetDir, "SKILL.md");
234
+
235
+ return needsUpdate(skillFile, targetFile);
236
+ }
237
+
238
+ /**
239
+ * Check which skills are missing or need updating.
240
+ */
241
+ export function checkSkillStatus(): { missing: string[]; outdated: string[]; current: string[] } {
242
+ const missing: string[] = [];
243
+ const outdated: string[] = [];
244
+ const current: string[] = [];
245
+
246
+ for (const skill of CREW_SKILLS) {
247
+ const sourceDir = path.join(SOURCE_SKILLS_DIR, skill);
248
+ const targetDir = path.join(TARGET_SKILLS_DIR, skill);
249
+
250
+ if (!fs.existsSync(sourceDir)) {
251
+ continue;
252
+ }
253
+
254
+ if (!fs.existsSync(targetDir)) {
255
+ missing.push(skill);
256
+ } else if (skillNeedsUpdate(sourceDir, targetDir)) {
257
+ outdated.push(skill);
258
+ } else {
259
+ current.push(skill);
260
+ }
261
+ }
262
+
263
+ return { missing, outdated, current };
264
+ }
265
+
266
+ /**
267
+ * Install or update skills.
268
+ */
269
+ export function installSkills(force: boolean = false): SkillInstallResult {
270
+ const result: SkillInstallResult = {
271
+ installed: [],
272
+ updated: [],
273
+ skipped: [],
274
+ errors: [],
275
+ targetDir: TARGET_SKILLS_DIR,
276
+ };
277
+
278
+ // Ensure target directory exists
279
+ if (!fs.existsSync(TARGET_SKILLS_DIR)) {
280
+ try {
281
+ fs.mkdirSync(TARGET_SKILLS_DIR, { recursive: true });
282
+ } catch (err) {
283
+ result.errors.push(`Failed to create directory: ${TARGET_SKILLS_DIR}`);
284
+ return result;
285
+ }
286
+ }
287
+
288
+ for (const skill of CREW_SKILLS) {
289
+ const sourceDir = path.join(SOURCE_SKILLS_DIR, skill);
290
+ const targetDir = path.join(TARGET_SKILLS_DIR, skill);
291
+
292
+ if (!fs.existsSync(sourceDir)) {
293
+ result.errors.push(`Source not found: ${skill}`);
294
+ continue;
295
+ }
296
+
297
+ const targetExists = fs.existsSync(targetDir);
298
+ const shouldUpdate = force || skillNeedsUpdate(sourceDir, targetDir);
299
+
300
+ if (!shouldUpdate) {
301
+ result.skipped.push(skill);
302
+ continue;
303
+ }
304
+
305
+ try {
306
+ // Create target directory
307
+ if (!targetExists) {
308
+ fs.mkdirSync(targetDir, { recursive: true });
309
+ }
310
+
311
+ // Copy all files from skill directory
312
+ const files = fs.readdirSync(sourceDir);
313
+ for (const file of files) {
314
+ const srcFile = path.join(sourceDir, file);
315
+ const dstFile = path.join(targetDir, file);
316
+ if (fs.statSync(srcFile).isFile()) {
317
+ fs.copyFileSync(srcFile, dstFile);
318
+ }
319
+ }
320
+
321
+ if (targetExists) {
322
+ result.updated.push(skill);
323
+ } else {
324
+ result.installed.push(skill);
325
+ }
326
+ } catch (err) {
327
+ result.errors.push(`Failed to copy ${skill}: ${err}`);
328
+ }
329
+ }
330
+
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * Uninstall skills.
336
+ */
337
+ export function uninstallSkills(): { removed: string[]; notFound: string[]; errors: string[] } {
338
+ const removed: string[] = [];
339
+ const notFound: string[] = [];
340
+ const errors: string[] = [];
341
+
342
+ for (const skill of CREW_SKILLS) {
343
+ const targetDir = path.join(TARGET_SKILLS_DIR, skill);
344
+
345
+ if (!fs.existsSync(targetDir)) {
346
+ notFound.push(skill);
347
+ continue;
348
+ }
349
+
350
+ try {
351
+ fs.rmSync(targetDir, { recursive: true });
352
+ removed.push(skill);
353
+ } catch (err) {
354
+ errors.push(`Failed to remove ${skill}: ${err}`);
355
+ }
356
+ }
357
+
358
+ return { removed, notFound, errors };
359
+ }
360
+
361
+ /**
362
+ * Ensure skills are installed (auto-install if missing).
363
+ */
364
+ export function ensureSkillsInstalled(): boolean {
365
+ const status = checkSkillStatus();
366
+
367
+ if (status.missing.length === 0 && status.outdated.length === 0) {
368
+ return true;
369
+ }
370
+
371
+ const result = installSkills();
372
+ return result.errors.length === 0;
373
+ }