@tekmidian/pai 0.6.1 → 0.6.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.
@@ -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
+ }
@@ -310,8 +310,8 @@ fi
310
310
 
311
311
  # Output the statusline
312
312
  # LINE 1 - Greeting (adaptive: drop CC version when narrow, shorten further if very narrow)
313
- line1_full="${EMOJI_WAVE} ${DA_DISPLAY_COLOR}\"${DA_NAME} here, ready to go...\"${RESET} ${MODEL_PURPLE}Running CC ${cc_version}${RESET}${LINE1_PRIMARY} with ${MODEL_PURPLE}${EMOJI_BRAIN} ${model_name}${RESET}${LINE1_PRIMARY} in ${DIR_COLOR}${EMOJI_FOLDER} ${dir_name}${BRIGHT_CYAN}${session_suffix}${RESET}"
314
- line1_medium="${EMOJI_WAVE} ${DA_DISPLAY_COLOR}\"${DA_NAME}\"${RESET}${LINE1_PRIMARY} ${MODEL_PURPLE}${EMOJI_BRAIN} ${model_name}${RESET}${LINE1_PRIMARY} in ${DIR_COLOR}${EMOJI_FOLDER} ${dir_name}${BRIGHT_CYAN}${session_suffix}${RESET}"
313
+ line1_full="${EMOJI_WAVE} ${DA_DISPLAY_COLOR}${DA_NAME}${RESET} ${MODEL_PURPLE}CC ${cc_version}${RESET}${LINE1_PRIMARY} ${MODEL_PURPLE}${EMOJI_BRAIN} ${model_name}${RESET}${LINE1_PRIMARY} in ${DIR_COLOR}${EMOJI_FOLDER} ${dir_name}${BRIGHT_CYAN}${session_suffix}${RESET}"
314
+ line1_medium="${EMOJI_WAVE} ${DA_DISPLAY_COLOR}${DA_NAME}${RESET} ${MODEL_PURPLE}${EMOJI_BRAIN} ${model_name}${RESET}${LINE1_PRIMARY} in ${DIR_COLOR}${EMOJI_FOLDER} ${dir_name}${BRIGHT_CYAN}${session_suffix}${RESET}"
315
315
  line1_short="${EMOJI_WAVE} ${MODEL_PURPLE}${EMOJI_BRAIN} ${model_name}${RESET}${LINE1_PRIMARY} ${DIR_COLOR}${EMOJI_FOLDER} ${dir_name}${BRIGHT_CYAN}${session_suffix}${RESET}"
316
316
 
317
317
  # Pick line 1 format based on width (plain-text lengths: full~85, medium~45, short~25)
@@ -330,13 +330,125 @@ if [ -n "$mcp_line2" ]; then
330
330
  printf "${LINE2_PRIMARY} ${RESET}${mcp_line2}${RESET}\n"
331
331
  fi
332
332
 
333
- # Auto-compact indicator: detect CLAUDE_AUTOCOMPACT_PCT_OVERRIDE env var
334
- autocompact_suffix=""
335
- if [ -n "${CLAUDE_AUTOCOMPACT_PCT_OVERRIDE:-}" ]; then
336
- autocompact_suffix=" ${BRIGHT_CYAN}[auto-compact: ${CLAUDE_AUTOCOMPACT_PCT_OVERRIDE}%%]${RESET}"
333
+
334
+ # Fetch OAuth usage (5-hour current + 7-day weekly) with caching
335
+ usage_cache="/tmp/claude/statusline-usage-cache.json"
336
+ usage_cache_ttl=60 # seconds
337
+ usage_suffix=""
338
+
339
+ _fetch_usage() {
340
+ # Try to get OAuth token from macOS Keychain
341
+ local token=""
342
+ token=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
343
+ [ -z "$token" ] && return
344
+
345
+ mkdir -p /tmp/claude
346
+ local response
347
+ response=$(curl -sf --max-time 3 \
348
+ -H "Authorization: Bearer $token" \
349
+ -H "anthropic-beta: oauth-2025-04-20" \
350
+ "https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
351
+ [ -n "$response" ] && echo "$response" > "$usage_cache"
352
+ }
353
+
354
+ # Use cache if fresh, otherwise fetch in background
355
+ if [ -f "$usage_cache" ]; then
356
+ cache_age=$(( $(date +%s) - $(stat -f %m "$usage_cache" 2>/dev/null || echo 0) ))
357
+ if [ "$cache_age" -gt "$usage_cache_ttl" ]; then
358
+ _fetch_usage &
359
+ fi
360
+ else
361
+ _fetch_usage &
362
+ fi
363
+
364
+ # Read cached usage data
365
+ if [ -f "$usage_cache" ]; then
366
+ five_hour=$(jq -r '.five_hour.utilization // 0' "$usage_cache" 2>/dev/null)
367
+ seven_day=$(jq -r '.seven_day.utilization // 0' "$usage_cache" 2>/dev/null)
368
+ five_reset=$(jq -r '.five_hour.resets_at // empty' "$usage_cache" 2>/dev/null)
369
+ seven_reset=$(jq -r '.seven_day.resets_at // empty' "$usage_cache" 2>/dev/null)
370
+
371
+ # Round to integers
372
+ five_hour_int=$(printf "%.0f" "$five_hour" 2>/dev/null || echo 0)
373
+ seven_day_int=$(printf "%.0f" "$seven_day" 2>/dev/null || echo 0)
374
+
375
+ # Format reset times as HH:MM (local time)
376
+ five_reset_fmt=""
377
+ seven_reset_fmt=""
378
+ seven_reset_epoch=0
379
+ if [ -n "$five_reset" ]; then
380
+ five_reset_fmt=$(date -jf "%Y-%m-%dT%H:%M:%S" "$(echo "$five_reset" | cut -c1-19)" "+%H:%M" 2>/dev/null || date -d "$five_reset" "+%H:%M" 2>/dev/null || echo "")
381
+ fi
382
+ if [ -n "$seven_reset" ]; then
383
+ seven_reset_fmt=$(date -jf "%Y-%m-%dT%H:%M:%S" "$(echo "$seven_reset" | cut -c1-19)" "+%a %H:%M" 2>/dev/null || date -d "$seven_reset" "+%a %H:%M" 2>/dev/null || echo "")
384
+ seven_reset_epoch=$(date -jf "%Y-%m-%dT%H:%M:%S" "$(echo "$seven_reset" | cut -c1-19)" "+%s" 2>/dev/null || date -d "$seven_reset" "+%s" 2>/dev/null || echo 0)
385
+ fi
386
+
387
+ # Color based on utilization: green < 50%, yellow 50-75%, red > 75%
388
+ _usage_color() {
389
+ local pct=$1
390
+ if [ "$pct" -gt 75 ] 2>/dev/null; then echo "$BRIGHT_RED"
391
+ elif [ "$pct" -gt 50 ] 2>/dev/null; then echo "$BRIGHT_YELLOW"
392
+ else echo "$BRIGHT_GREEN"; fi
393
+ }
394
+
395
+ five_color=$(_usage_color "$five_hour_int")
396
+ seven_color=$(_usage_color "$seven_day_int")
397
+
398
+ # Budget pace indicator for 7-day window
399
+ # Compare actual usage vs linear expected usage based on elapsed time
400
+ pace_dot=""
401
+ if [ "$seven_reset_epoch" -gt 0 ] 2>/dev/null; then
402
+ now_epoch=$(date +%s)
403
+ window_secs=$((7 * 86400))
404
+ remaining_secs=$((seven_reset_epoch - now_epoch))
405
+ [ "$remaining_secs" -lt 0 ] && remaining_secs=0
406
+ elapsed_secs=$((window_secs - remaining_secs))
407
+ # Expected usage if spending linearly: elapsed/total * 100
408
+ expected_pct=$(( elapsed_secs * 100 / window_secs ))
409
+ # Daily pace: actual spend per day vs budget (100/7 ≈ 14%/day)
410
+ elapsed_days_x10=$((elapsed_secs * 10 / 86400))
411
+ [ "$elapsed_days_x10" -lt 1 ] && elapsed_days_x10=1
412
+ spend_per_day=$((seven_day_int * 10 / elapsed_days_x10))
413
+ budget_per_day=14 # 100/7 ≈ 14%
414
+ # Color: green = under budget, orange = near budget, red = over budget
415
+ overspend=$((spend_per_day - budget_per_day))
416
+ if [ "$overspend" -le -3 ] 2>/dev/null; then
417
+ pace_color="$BRIGHT_GREEN" # well under budget
418
+ elif [ "$overspend" -le 2 ] 2>/dev/null; then
419
+ pace_color="$BRIGHT_ORANGE" # near budget
420
+ else
421
+ pace_color="$BRIGHT_RED" # over budget
422
+ fi
423
+ pace_dot="${pace_color}${spend_per_day}%% / ${budget_per_day}%%${RESET}"
424
+ fi
425
+
426
+ # Build usage suffix: 5h: 8% → 00:59 │ 1d: ● 29% / 36% │ 7d: 29% → Fr. 08:00
427
+ five_label="5h: ${five_hour_int}%%"
428
+ [ -n "$five_reset_fmt" ] && five_label="${five_label} → ${five_reset_fmt}"
429
+ seven_label="7d: ${seven_day_int}%%"
430
+ [ -n "$seven_reset_fmt" ] && seven_label="${seven_label} → ${seven_reset_fmt}"
431
+
432
+ usage_suffix=" ${SEPARATOR_COLOR}│${RESET} ${five_color}${five_label}${RESET}"
433
+ [ -n "$pace_dot" ] && usage_suffix="${usage_suffix} ${SEPARATOR_COLOR}│${RESET} ${LINE3_PRIMARY}1d:${RESET} ${pace_dot}"
434
+ usage_suffix="${usage_suffix} ${SEPARATOR_COLOR}│${RESET} ${seven_color}${seven_label}${RESET}"
435
+ fi
436
+
437
+ # LINE 3 - Context meter + usage limits
438
+ # Auto-compact remaining: how much context left until compaction triggers
439
+ ac_threshold="${CLAUDE_AUTOCOMPACT_PCT_OVERRIDE:-80}"
440
+ ac_remaining=$((ac_threshold - context_pct))
441
+ [ "$ac_remaining" -lt 0 ] && ac_remaining=0
442
+ # Color the remaining %: red ≤5, yellow ≤15, green otherwise
443
+ if [ "$ac_remaining" -le 5 ] 2>/dev/null; then
444
+ ac_color="$BRIGHT_RED"
445
+ elif [ "$ac_remaining" -le 15 ] 2>/dev/null; then
446
+ ac_color="$BRIGHT_YELLOW"
447
+ else
448
+ ac_color="$BRIGHT_GREEN"
337
449
  fi
450
+ ac_suffix=" ${ac_color}(${ac_remaining}%%)${RESET}"
338
451
 
339
- # LINE 3 - Context meter (from Claude Code's JSON input)
340
452
  if [ "$context_pct" -gt 0 ] 2>/dev/null; then
341
453
  # Color based on usage: green < 50%, yellow 50-75%, red > 75%
342
454
  if [ $context_pct -gt 75 ]; then
@@ -347,7 +459,7 @@ if [ "$context_pct" -gt 0 ] 2>/dev/null; then
347
459
  ctx_color="$BRIGHT_GREEN"
348
460
  fi
349
461
 
350
- printf "${LINE3_PRIMARY}${EMOJI_GEM} Context${RESET}${LINE3_PRIMARY}${SEPARATOR_COLOR}: ${RESET}${ctx_color}${context_used_k}K${RESET}${LINE3_PRIMARY} / ${context_max_k}K${autocompact_suffix}${RESET}\n"
462
+ printf "${LINE3_PRIMARY}${EMOJI_GEM} Context${RESET}${LINE3_PRIMARY}${SEPARATOR_COLOR}: ${RESET}${ctx_color}${context_used_k}K${RESET}${LINE3_PRIMARY} / ${context_max_k}K${ac_suffix}${usage_suffix}${RESET}\n"
351
463
  else
352
- printf "${LINE3_PRIMARY}${EMOJI_GEM} Context${RESET}${LINE3_PRIMARY}${SEPARATOR_COLOR}: ${RESET}${LINE3_ACCENT}...${autocompact_suffix}${RESET}\n"
464
+ printf "${LINE3_PRIMARY}${EMOJI_GEM} Context${RESET}${LINE3_PRIMARY}${SEPARATOR_COLOR}: ${RESET}${LINE3_ACCENT}...${ac_suffix}${usage_suffix}${RESET}\n"
353
465
  fi