@tekmidian/pai 0.6.0 → 0.6.2

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.
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate SKILL.md files from MCP prompt definitions.
4
+ *
5
+ * Sources (in order):
6
+ * 1. src/daemon-mcp/prompts/*.ts — PAI built-in prompts (tracked in git)
7
+ * 2. src/daemon-mcp/prompts/custom/*.ts — User-created prompts (gitignored)
8
+ *
9
+ * Each prompt becomes a discoverable Claude Code skill at:
10
+ * dist/skills/<TitleCase>/SKILL.md
11
+ *
12
+ * Installation: `pai setup` or `pai skills sync` symlinks (macOS/Linux)
13
+ * or copies (Windows) each skill directory into ~/.claude/skills/.
14
+ *
15
+ * With --sync: also creates/updates symlinks in ~/.claude/skills/ after
16
+ * generating stubs. This is called automatically during `bun run build`.
17
+ *
18
+ * Source of truth: the TypeScript prompt files. Skills are regenerated on
19
+ * every build — never edit the generated SKILL.md files by hand.
20
+ */
21
+
22
+ import {
23
+ readFileSync,
24
+ writeFileSync,
25
+ mkdirSync,
26
+ existsSync,
27
+ readdirSync,
28
+ symlinkSync,
29
+ lstatSync,
30
+ readlinkSync,
31
+ unlinkSync,
32
+ } from "fs";
33
+ import { join, resolve } from "path";
34
+ import { homedir, platform } from "os";
35
+
36
+ const PROMPTS_DIR = "src/daemon-mcp/prompts";
37
+ const CUSTOM_DIR = join(PROMPTS_DIR, "custom");
38
+ const STUBS_OUT = "dist/skills";
39
+ const doSync = process.argv.includes("--sync");
40
+
41
+ // kebab-case → TitleCase for Claude Code's skill scanner
42
+ function toTitleCase(promptName) {
43
+ return promptName
44
+ .split("-")
45
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
46
+ .join("");
47
+ }
48
+
49
+ // Extract description and full content from a prompt .ts file
50
+ function parsePrompt(filePath) {
51
+ const src = readFileSync(filePath, "utf-8");
52
+
53
+ // Extract the description field
54
+ const descMatch = src.match(/description:\s*["'`]([^"'`]+)["'`]/);
55
+ if (!descMatch) return null;
56
+ const description = descMatch[1];
57
+
58
+ // Extract USE WHEN line
59
+ const useWhenMatch = src.match(/USE WHEN[^\n]+/);
60
+ const useWhen = useWhenMatch ? useWhenMatch[0] : "";
61
+
62
+ // Extract the full content template string
63
+ // Match content: `...` handling escaped backticks (\`) inside
64
+ const contentMatch = src.match(/content:\s*`((?:[^`\\]|\\[\s\S])*)`/);
65
+ if (!contentMatch) return null;
66
+ // Unescape template literal escapes
67
+ const content = contentMatch[1]
68
+ .replace(/\\`/g, "`")
69
+ .replace(/\\\$/g, "$")
70
+ .replace(/\\'/g, "'")
71
+ .replace(/\\n/g, "\n");
72
+
73
+ return { description, useWhen, content };
74
+ }
75
+
76
+ // Read export list from index.ts to get built-in prompt file names
77
+ function getBuiltinPromptNames() {
78
+ const indexSrc = readFileSync(join(PROMPTS_DIR, "index.ts"), "utf-8");
79
+ const names = [];
80
+ for (const match of indexSrc.matchAll(
81
+ /export\s+\{[^}]+\}\s+from\s+["']\.\/([^"']+)\.js["']/g
82
+ )) {
83
+ names.push(match[1]);
84
+ }
85
+ return names;
86
+ }
87
+
88
+ // Scan custom/ directory for user-created prompt files
89
+ function getCustomPromptNames() {
90
+ if (!existsSync(CUSTOM_DIR)) return [];
91
+ return readdirSync(CUSTOM_DIR)
92
+ .filter((f) => f.endsWith(".ts") && f !== "index.ts")
93
+ .map((f) => f.replace(/\.ts$/, ""));
94
+ }
95
+
96
+ // Sync symlinks: create/update symlinks in ~/.claude/skills/
97
+ function syncSymlinks(generatedNames) {
98
+ const skillsDir = join(homedir(), ".claude", "skills");
99
+ mkdirSync(skillsDir, { recursive: true });
100
+
101
+ const useSymlinks = platform() !== "win32";
102
+ let created = 0;
103
+ let updated = 0;
104
+ let current = 0;
105
+
106
+ for (const name of generatedNames) {
107
+ const source = resolve(join(STUBS_OUT, name));
108
+ const target = join(skillsDir, name);
109
+
110
+ // Never overwrite non-symlink directories (user's own skills)
111
+ if (existsSync(target) && !lstatSync(target).isSymbolicLink()) {
112
+ continue;
113
+ }
114
+
115
+ // Check existing symlink
116
+ if (existsSync(target) && lstatSync(target).isSymbolicLink()) {
117
+ if (resolve(readlinkSync(target)) === source) {
118
+ current++;
119
+ continue;
120
+ }
121
+ unlinkSync(target);
122
+ updated++;
123
+ }
124
+
125
+ if (useSymlinks) {
126
+ symlinkSync(source, target);
127
+ } else {
128
+ mkdirSync(target, { recursive: true });
129
+ writeFileSync(
130
+ join(target, "SKILL.md"),
131
+ readFileSync(join(source, "SKILL.md")),
132
+ );
133
+ }
134
+ created++;
135
+ }
136
+
137
+ // Clean up stale symlinks from old user/ location
138
+ const oldUserDir = join(skillsDir, "user");
139
+ if (existsSync(oldUserDir)) {
140
+ for (const name of generatedNames) {
141
+ const oldTarget = join(oldUserDir, name);
142
+ if (existsSync(oldTarget) && lstatSync(oldTarget).isSymbolicLink()) {
143
+ unlinkSync(oldTarget);
144
+ }
145
+ }
146
+ }
147
+
148
+ const parts = [];
149
+ if (created > 0) parts.push(`${created} created`);
150
+ if (updated > 0) parts.push(`${updated} updated`);
151
+ if (current > 0) parts.push(`${current} current`);
152
+ console.log(`✔ Skill symlinks synced: ${parts.join(", ")}`);
153
+ }
154
+
155
+ // --- Main ---
156
+
157
+ const builtinNames = getBuiltinPromptNames();
158
+ const customNames = getCustomPromptNames();
159
+ let generated = 0;
160
+ const generatedDirNames = [];
161
+
162
+ mkdirSync(STUBS_OUT, { recursive: true });
163
+
164
+ // Process all prompts (built-in + custom)
165
+ for (const [fileName, dir] of [
166
+ ...builtinNames.map((n) => [n, PROMPTS_DIR]),
167
+ ...customNames.map((n) => [n, CUSTOM_DIR]),
168
+ ]) {
169
+ const filePath = join(dir, `${fileName}.ts`);
170
+ const parsed = parsePrompt(filePath);
171
+ if (!parsed) {
172
+ console.warn(`⚠ Skipping ${fileName}: could not parse`);
173
+ continue;
174
+ }
175
+
176
+ const dirName = toTitleCase(fileName);
177
+ const outDir = join(STUBS_OUT, dirName);
178
+ const outFile = join(outDir, "SKILL.md");
179
+
180
+ mkdirSync(outDir, { recursive: true });
181
+
182
+ const skill = [
183
+ "---",
184
+ `name: ${dirName}`,
185
+ `description: "${parsed.description}. ${parsed.useWhen}"`,
186
+ "---",
187
+ "",
188
+ parsed.content.trim(),
189
+ "",
190
+ ].join("\n");
191
+
192
+ writeFileSync(outFile, skill);
193
+ generatedDirNames.push(dirName);
194
+ generated++;
195
+ }
196
+
197
+ const customLabel = customNames.length > 0 ? ` (${builtinNames.length} built-in + ${customNames.length} custom)` : "";
198
+ console.log(`✔ ${generated} skill stubs generated in ${STUBS_OUT}/${customLabel}`);
199
+
200
+ if (doSync) {
201
+ syncSymlinks(generatedDirNames);
202
+ }