pan-wizard 2.9.1 → 3.5.0

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 (75) hide show
  1. package/README.md +31 -9
  2. package/agents/pan-conductor.md +189 -0
  3. package/agents/pan-counterfactual.md +112 -0
  4. package/agents/pan-debugger.md +15 -1
  5. package/agents/pan-distiller.md +82 -0
  6. package/agents/pan-document_code.md +21 -0
  7. package/agents/pan-executor.md +16 -0
  8. package/agents/pan-hardener.md +113 -0
  9. package/agents/pan-integration-checker.md +2 -0
  10. package/agents/pan-knowledge.md +81 -0
  11. package/agents/pan-meta-reviewer.md +91 -0
  12. package/agents/pan-optimizer.md +242 -0
  13. package/agents/pan-plan-checker.md +2 -0
  14. package/agents/pan-previewer.md +98 -0
  15. package/agents/pan-project-researcher.md +4 -4
  16. package/agents/pan-reviewer.md +2 -0
  17. package/agents/pan-verifier.md +2 -0
  18. package/bin/install-lib.cjs +197 -0
  19. package/bin/install.js +2048 -1959
  20. package/commands/pan/cost.md +132 -0
  21. package/commands/pan/exec-phase.md +15 -0
  22. package/commands/pan/focus-auto.md +168 -3
  23. package/commands/pan/focus-exec.md +21 -1
  24. package/commands/pan/focus-scan.md +6 -0
  25. package/commands/pan/git.md +223 -0
  26. package/commands/pan/knowledge.md +129 -0
  27. package/commands/pan/learn.md +61 -0
  28. package/commands/pan/map-codebase.md +15 -0
  29. package/commands/pan/mcp-bridge.md +145 -0
  30. package/commands/pan/milestone-done.md +9 -0
  31. package/commands/pan/optimize.md +86 -0
  32. package/commands/pan/plan-phase.md +11 -0
  33. package/commands/pan/preview.md +114 -0
  34. package/commands/pan/profile.md +37 -0
  35. package/commands/pan/review-deep.md +128 -0
  36. package/commands/pan/verify-phase.md +11 -0
  37. package/commands/pan/what-if.md +146 -0
  38. package/hooks/dist/pan-cost-logger.js +102 -0
  39. package/hooks/dist/pan-statusline.js +154 -108
  40. package/hooks/dist/pan-trace-logger.js +197 -0
  41. package/package.json +1 -1
  42. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  43. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  44. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  45. package/pan-wizard-core/bin/lib/commands.cjs +1 -0
  46. package/pan-wizard-core/bin/lib/constants.cjs +44 -1
  47. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  48. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  49. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  50. package/pan-wizard-core/bin/lib/distill.cjs +510 -0
  51. package/pan-wizard-core/bin/lib/focus.cjs +108 -3
  52. package/pan-wizard-core/bin/lib/git.cjs +407 -0
  53. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  54. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  55. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  56. package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
  57. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  58. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  59. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  60. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  61. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  62. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  63. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  64. package/pan-wizard-core/bin/pan-tools.cjs +317 -4
  65. package/pan-wizard-core/templates/playbook.md +53 -0
  66. package/pan-wizard-core/templates/preview-report.md +93 -0
  67. package/pan-wizard-core/templates/roadmap.md +24 -24
  68. package/pan-wizard-core/templates/state.md +12 -9
  69. package/pan-wizard-core/workflows/exec-phase.md +97 -0
  70. package/pan-wizard-core/workflows/learn.md +91 -0
  71. package/pan-wizard-core/workflows/optimize.md +139 -0
  72. package/pan-wizard-core/workflows/plan-phase.md +28 -1
  73. package/pan-wizard-core/workflows/quick.md +7 -0
  74. package/pan-wizard-core/workflows/verify-phase.md +16 -0
  75. package/scripts/build-hooks.js +3 -1
package/bin/install.js CHANGED
@@ -1,1959 +1,2048 @@
1
- #!/usr/bin/env node
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const os = require('os');
6
- const readline = require('readline');
7
- const crypto = require('crypto');
8
-
9
- // Extracted pure functions (testable independently)
10
- const lib = require('./install-lib.cjs');
11
- const {
12
- colorNameToHex, claudeToOpencodeTools, claudeToGeminiTools, claudeToCopilotTools,
13
- getDirName, getConfigDirFromHome, expandTilde, toSingleLine, yamlQuote, buildHookCommand,
14
- extractFrontmatterAndBody, extractFrontmatterField,
15
- convertToolName, convertGeminiToolName, convertCopilotToolName,
16
- convertSlashCommandsToCodexSkillMentions, convertSlashCommandsToCopilotSkillMentions,
17
- convertClaudeToCodexMarkdown, convertClaudeToCopilotMarkdown,
18
- convertClaudeToOpencodeFrontmatter, convertClaudeToGeminiToml, convertClaudeToGeminiAgent,
19
- rewriteAskUserQuestionForCopilot, stripSubTags,
20
- getCodexSkillAdapterHeader, convertClaudeCommandToCodexSkill,
21
- getCopilotSkillAdapterHeader, convertClaudeCommandToCopilotSkill, convertClaudeToCopilotAgent,
22
- processAttribution, parseJsonc,
23
- } = lib;
24
-
25
- // Colors
26
- const cyan = '\x1b[36m';
27
- const green = '\x1b[32m';
28
- const yellow = '\x1b[33m';
29
- const red = '\x1b[31m';
30
- const dim = '\x1b[2m';
31
- const reset = '\x1b[0m';
32
-
33
- // Get version from package.json
34
- const pkg = require('../package.json');
35
-
36
- // Source repo root — prevent installing PAN into its own source directory
37
- const PAN_SOURCE_ROOT = path.resolve(__dirname, '..');
38
-
39
- // Parse args
40
- const args = process.argv.slice(2);
41
- const hasGlobal = args.includes('--global') || args.includes('-g');
42
- const hasLocal = args.includes('--local') || args.includes('-l');
43
- const hasOpencode = args.includes('--opencode');
44
- const hasClaude = args.includes('--claude');
45
- const hasGemini = args.includes('--gemini');
46
- const hasCodex = args.includes('--codex');
47
- const hasCopilot = args.includes('--copilot');
48
- const hasBoth = args.includes('--both'); // Legacy flag, keeps working
49
- const hasAll = args.includes('--all');
50
- const hasUninstall = args.includes('--uninstall') || args.includes('-u');
51
-
52
- // Runtime selection - can be set by flags or interactive prompt
53
- let selectedRuntimes = [];
54
- if (hasAll) {
55
- selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot'];
56
- } else if (hasBoth) {
57
- selectedRuntimes = ['claude', 'opencode'];
58
- } else {
59
- if (hasOpencode) selectedRuntimes.push('opencode');
60
- if (hasClaude) selectedRuntimes.push('claude');
61
- if (hasGemini) selectedRuntimes.push('gemini');
62
- if (hasCodex) selectedRuntimes.push('codex');
63
- if (hasCopilot) selectedRuntimes.push('copilot');
64
- }
65
-
66
- /**
67
- * Get the global config directory for OpenCode
68
- * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
69
- * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
70
- */
71
- function getOpencodeGlobalDir() {
72
- // 1. Explicit OPENCODE_CONFIG_DIR env var
73
- if (process.env.OPENCODE_CONFIG_DIR) {
74
- return expandTilde(process.env.OPENCODE_CONFIG_DIR);
75
- }
76
-
77
- // 2. OPENCODE_CONFIG env var (use its directory)
78
- if (process.env.OPENCODE_CONFIG) {
79
- return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
80
- }
81
-
82
- // 3. XDG_CONFIG_HOME/opencode
83
- if (process.env.XDG_CONFIG_HOME) {
84
- return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
85
- }
86
-
87
- // 4. Default: ~/.config/opencode (XDG default)
88
- return path.join(os.homedir(), '.config', 'opencode');
89
- }
90
-
91
- /**
92
- * Get the global config directory for a runtime
93
- * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex'
94
- * @param {string|null} explicitDir - Explicit directory from --config-dir flag
95
- */
96
- function getGlobalDir(runtime, explicitDir = null) {
97
- if (runtime === 'opencode') {
98
- // For OpenCode, --config-dir overrides env vars
99
- if (explicitDir) {
100
- return expandTilde(explicitDir);
101
- }
102
- return getOpencodeGlobalDir();
103
- }
104
-
105
- if (runtime === 'gemini') {
106
- // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
107
- if (explicitDir) {
108
- return expandTilde(explicitDir);
109
- }
110
- if (process.env.GEMINI_CONFIG_DIR) {
111
- return expandTilde(process.env.GEMINI_CONFIG_DIR);
112
- }
113
- return path.join(os.homedir(), '.gemini');
114
- }
115
-
116
- if (runtime === 'codex') {
117
- // Codex: --config-dir > CODEX_HOME > ~/.codex
118
- if (explicitDir) {
119
- return expandTilde(explicitDir);
120
- }
121
- if (process.env.CODEX_HOME) {
122
- return expandTilde(process.env.CODEX_HOME);
123
- }
124
- return path.join(os.homedir(), '.codex');
125
- }
126
-
127
- if (runtime === 'copilot') {
128
- // Copilot CLI: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
129
- if (explicitDir) {
130
- return expandTilde(explicitDir);
131
- }
132
- if (process.env.COPILOT_CONFIG_DIR) {
133
- return expandTilde(process.env.COPILOT_CONFIG_DIR);
134
- }
135
- return path.join(os.homedir(), '.copilot');
136
- }
137
-
138
- // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
139
- if (explicitDir) {
140
- return expandTilde(explicitDir);
141
- }
142
- if (process.env.CLAUDE_CONFIG_DIR) {
143
- return expandTilde(process.env.CLAUDE_CONFIG_DIR);
144
- }
145
- return path.join(os.homedir(), '.claude');
146
- }
147
-
148
- const banner = '\n' +
149
- cyan + ' ██████╗ █████╗ ███╗ ██╗\n' +
150
- ' ██╔══██╗██╔══██╗████╗ ██║\n' +
151
- ' ██████╔╝███████║██╔██╗ ██║\n' +
152
- ' ██╔═══╝ ██╔══██║██║╚██╗██║\n' +
153
- ' ██║ ██║ ██║██║ ╚████║\n' +
154
- ' ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝' + reset + '\n' +
155
- '\n' +
156
- ' PAN Wizard ' + dim + 'v' + pkg.version + reset + '\n' +
157
- ' A lightweight workflow automation and context engineering\n' +
158
- ' system for Claude Code, OpenCode, Gemini, Codex, and Copilot CLI.\n';
159
-
160
- // Parse --config-dir argument
161
- function parseConfigDirArg() {
162
- const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
163
- if (configDirIndex !== -1) {
164
- const nextArg = args[configDirIndex + 1];
165
- // Error if --config-dir is provided without a value or next arg is another flag
166
- if (!nextArg || nextArg.startsWith('-')) {
167
- console.error(` ${yellow}--config-dir requires a path argument${reset}`);
168
- process.exit(1);
169
- }
170
- return nextArg;
171
- }
172
- // Also handle --config-dir=value format
173
- const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
174
- if (configDirArg) {
175
- const value = configDirArg.split('=')[1];
176
- if (!value) {
177
- console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
178
- process.exit(1);
179
- }
180
- return value;
181
- }
182
- return null;
183
- }
184
- const explicitConfigDir = parseConfigDirArg();
185
- const hasHelp = args.includes('--help') || args.includes('-h');
186
- const forceStatusline = args.includes('--force-statusline');
187
-
188
- console.log(banner);
189
-
190
- // Show help if requested
191
- if (hasHelp) {
192
- console.log(` ${yellow}Usage:${reset} npx pan-wizard [options]\n\n ${yellow}Options:${reset}\n ${cyan}-l, --local${reset} Install locally to current directory (default)\n ${cyan}-g, --global${reset} Install globally to config directory\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall PAN (remove all PAN files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx pan-wizard\n\n ${dim}# Install for Claude Code in current project (default)${reset}\n npx pan-wizard --claude --local\n\n ${dim}# Install for all runtimes in current project${reset}\n npx pan-wizard --all --local\n\n ${dim}# Install globally (available in all projects)${reset}\n npx pan-wizard --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx pan-wizard --gemini --global\n\n ${dim}# Install to custom config directory${reset}\n npx pan-wizard --codex --global --config-dir ~/.codex-work\n\n ${dim}# Uninstall PAN from Codex globally${reset}\n npx pan-wizard --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n By default, PAN installs into the current project directory only.\n Use --global to install system-wide (writes to ~/.claude, ~/.gemini, etc.).\n The --config-dir option takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME.\n`);
193
- process.exit(0);
194
- }
195
-
196
- /**
197
- * Read and parse settings.json, returning empty object if it doesn't exist
198
- */
199
- function readSettings(settingsPath) {
200
- try {
201
- return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
202
- } catch {
203
- return {};
204
- }
205
- }
206
-
207
- /**
208
- * Write settings.json with proper formatting
209
- */
210
- function writeSettings(settingsPath, settings) {
211
- try {
212
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
213
- } catch (e) {
214
- console.error(` ${yellow}⚠${reset} Failed to write settings: ${e.message}`);
215
- }
216
- }
217
-
218
- // Cache for attribution settings (populated once per runtime during install)
219
- const attributionCache = new Map();
220
-
221
- /**
222
- * Get commit attribution setting for a runtime
223
- * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex'
224
- * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom
225
- */
226
- function getCommitAttribution(runtime) {
227
- // Return cached value if available
228
- if (attributionCache.has(runtime)) {
229
- return attributionCache.get(runtime);
230
- }
231
-
232
- let result;
233
-
234
- if (runtime === 'opencode') {
235
- const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
236
- result = config.disable_ai_attribution === true ? null : undefined;
237
- } else if (runtime === 'gemini') {
238
- // Gemini: check gemini settings.json for attribution config
239
- const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
240
- if (!settings.attribution || settings.attribution.commit === undefined) {
241
- result = undefined;
242
- } else if (settings.attribution.commit === '') {
243
- result = null;
244
- } else {
245
- result = settings.attribution.commit;
246
- }
247
- } else if (runtime === 'claude') {
248
- // Claude Code
249
- const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
250
- if (!settings.attribution || settings.attribution.commit === undefined) {
251
- result = undefined;
252
- } else if (settings.attribution.commit === '') {
253
- result = null;
254
- } else {
255
- result = settings.attribution.commit;
256
- }
257
- } else if (runtime === 'copilot') {
258
- // Copilot CLI: check config.json for attribution setting
259
- const config = readSettings(path.join(getGlobalDir('copilot', explicitConfigDir), 'config.json'));
260
- if (!config.attribution || config.attribution.commit === undefined) {
261
- result = undefined;
262
- } else if (config.attribution.commit === '') {
263
- result = null;
264
- } else {
265
- result = config.attribution.commit;
266
- }
267
- } else {
268
- // Codex currently has no attribution setting equivalent
269
- result = undefined;
270
- }
271
-
272
- // Cache and return
273
- attributionCache.set(runtime, result);
274
- return result;
275
- }
276
-
277
- // processAttribution, parseJsonc, and all pure converter functions
278
- // are provided by install-lib.cjs (see require at top of file).
279
-
280
- /**
281
- * Copy commands to a flat structure for OpenCode
282
- * OpenCode expects: command/pan-help.md (invoked as /pan-help)
283
- * Source structure: commands/pan/help.md
284
- *
285
- * @param {string} srcDir - Source directory (e.g., commands/pan/)
286
- * @param {string} destDir - Destination directory (e.g., command/)
287
- * @param {string} prefix - Prefix for filenames (e.g., 'pan')
288
- * @param {string} pathPrefix - Path prefix for file references
289
- * @param {string} runtime - Target runtime ('claude' or 'opencode')
290
- */
291
- function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
292
- if (!fs.existsSync(srcDir)) {
293
- return;
294
- }
295
-
296
- // Remove old pan-*.md files before copying new ones
297
- if (fs.existsSync(destDir)) {
298
- for (const file of fs.readdirSync(destDir)) {
299
- if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
300
- try { fs.unlinkSync(path.join(destDir, file)); } catch {}
301
- }
302
- }
303
- } else {
304
- try { fs.mkdirSync(destDir, { recursive: true }); } catch {}
305
- }
306
-
307
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
308
-
309
- for (const entry of entries) {
310
- const srcPath = path.join(srcDir, entry.name);
311
-
312
- if (entry.isDirectory()) {
313
- // Recurse into subdirectories, adding to prefix
314
- // e.g., commands/pan/debug/start.md -> command/pan-debug-start.md
315
- copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
316
- } else if (entry.name.endsWith('.md')) {
317
- // Flatten: help.md -> pan-help.md
318
- const baseName = entry.name.replace('.md', '');
319
- const destName = `${prefix}-${baseName}.md`;
320
- const destPath = path.join(destDir, destName);
321
-
322
- let content = fs.readFileSync(srcPath, 'utf8');
323
- const globalClaudeRegex = /~\/\.claude\//g;
324
- const localClaudeRegex = /\.\/\.claude\//g;
325
- const opencodeDirRegex = /~\/\.opencode\//g;
326
- content = content.replace(globalClaudeRegex, pathPrefix);
327
- content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
328
- content = content.replace(opencodeDirRegex, pathPrefix);
329
- content = processAttribution(content, getCommitAttribution(runtime));
330
- content = convertClaudeToOpencodeFrontmatter(content);
331
-
332
- fs.writeFileSync(destPath, content);
333
- }
334
- }
335
- }
336
-
337
- function listCodexSkillNames(skillsDir, prefix = 'pan-') {
338
- if (!fs.existsSync(skillsDir)) return [];
339
- const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
340
- return entries
341
- .filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
342
- .filter(entry => fs.existsSync(path.join(skillsDir, entry.name, 'SKILL.md')))
343
- .map(entry => entry.name)
344
- .sort();
345
- }
346
-
347
- function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) {
348
- if (!fs.existsSync(srcDir)) {
349
- return;
350
- }
351
-
352
- try { fs.mkdirSync(skillsDir, { recursive: true }); } catch {}
353
-
354
- // Remove previous PAN Codex skills to avoid stale command skills.
355
- const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
356
- for (const entry of existing) {
357
- if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
358
- try { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); } catch {}
359
- }
360
- }
361
-
362
- function recurse(currentSrcDir, currentPrefix) {
363
- const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
364
-
365
- for (const entry of entries) {
366
- const srcPath = path.join(currentSrcDir, entry.name);
367
- if (entry.isDirectory()) {
368
- recurse(srcPath, `${currentPrefix}-${entry.name}`);
369
- continue;
370
- }
371
-
372
- if (!entry.name.endsWith('.md')) {
373
- continue;
374
- }
375
-
376
- const baseName = entry.name.replace('.md', '');
377
- const skillName = `${currentPrefix}-${baseName}`;
378
- const skillDir = path.join(skillsDir, skillName);
379
- fs.mkdirSync(skillDir, { recursive: true });
380
-
381
- let content = fs.readFileSync(srcPath, 'utf8');
382
- const globalClaudeRegex = /~\/\.claude\//g;
383
- const localClaudeRegex = /\.\/\.claude\//g;
384
- const codexDirRegex = /~\/\.codex\//g;
385
- content = content.replace(globalClaudeRegex, pathPrefix);
386
- content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
387
- content = content.replace(codexDirRegex, pathPrefix);
388
- content = processAttribution(content, getCommitAttribution(runtime));
389
- content = convertClaudeCommandToCodexSkill(content, skillName);
390
-
391
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
392
- }
393
- }
394
-
395
- recurse(srcDir, prefix);
396
- }
397
-
398
- /**
399
- * Copy PAN commands as Copilot CLI skills.
400
- * Creates skills/pan-{name}/SKILL.md directory structure.
401
- * Similar to copyCommandsAsCodexSkills but uses Copilot CLI converters.
402
- */
403
- function copyCommandsAsCopilotSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) {
404
- if (!fs.existsSync(srcDir)) {
405
- return;
406
- }
407
-
408
- try { fs.mkdirSync(skillsDir, { recursive: true }); } catch {}
409
-
410
- // Remove previous PAN Copilot skills to avoid stale command skills
411
- const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
412
- for (const entry of existing) {
413
- if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
414
- try { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); } catch {}
415
- }
416
- }
417
-
418
- function recurse(currentSrcDir, currentPrefix) {
419
- const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
420
-
421
- for (const entry of entries) {
422
- const srcPath = path.join(currentSrcDir, entry.name);
423
- if (entry.isDirectory()) {
424
- recurse(srcPath, `${currentPrefix}-${entry.name}`);
425
- continue;
426
- }
427
-
428
- if (!entry.name.endsWith('.md')) {
429
- continue;
430
- }
431
-
432
- const baseName = entry.name.replace('.md', '');
433
- const skillName = `${currentPrefix}-${baseName}`;
434
- const skillDir = path.join(skillsDir, skillName);
435
- fs.mkdirSync(skillDir, { recursive: true });
436
-
437
- let content = fs.readFileSync(srcPath, 'utf8');
438
- const globalClaudeRegex = /~\/\.claude\//g;
439
- const localClaudeRegex = /\.\/\.claude\//g;
440
- content = content.replace(globalClaudeRegex, pathPrefix);
441
- content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
442
- content = processAttribution(content, getCommitAttribution(runtime));
443
- content = convertClaudeCommandToCopilotSkill(content, skillName);
444
-
445
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
446
- }
447
- }
448
-
449
- recurse(srcDir, prefix);
450
- }
451
-
452
- /**
453
- * Recursively copy directory, replacing paths in .md files
454
- * Deletes existing destDir first to remove orphaned files from previous versions
455
- * @param {string} srcDir - Source directory
456
- * @param {string} destDir - Destination directory
457
- * @param {string} pathPrefix - Path prefix for file references
458
- * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot')
459
- */
460
- function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) {
461
- const isOpencode = runtime === 'opencode';
462
- const isCodex = runtime === 'codex';
463
- const dirName = getDirName(runtime);
464
-
465
- // Clean install: remove existing destination to prevent orphaned files
466
- try {
467
- if (fs.existsSync(destDir)) {
468
- fs.rmSync(destDir, { recursive: true });
469
- }
470
- fs.mkdirSync(destDir, { recursive: true });
471
- } catch {}
472
-
473
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
474
-
475
- for (const entry of entries) {
476
- const srcPath = path.join(srcDir, entry.name);
477
- const destPath = path.join(destDir, entry.name);
478
-
479
- if (entry.isDirectory()) {
480
- copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand);
481
- } else if (entry.name.endsWith('.md')) {
482
- try {
483
- // Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths
484
- let content = fs.readFileSync(srcPath, 'utf8');
485
- const globalClaudeRegex = /~\/\.claude\//g;
486
- const localClaudeRegex = /\.\/\.claude\//g;
487
- content = content.replace(globalClaudeRegex, pathPrefix);
488
- content = content.replace(localClaudeRegex, `./${dirName}/`);
489
- content = processAttribution(content, getCommitAttribution(runtime));
490
-
491
- // Convert frontmatter for opencode compatibility
492
- if (isOpencode) {
493
- content = convertClaudeToOpencodeFrontmatter(content);
494
- fs.writeFileSync(destPath, content);
495
- } else if (runtime === 'gemini') {
496
- if (isCommand) {
497
- // Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
498
- content = stripSubTags(content);
499
- const tomlContent = convertClaudeToGeminiToml(content);
500
- // Replace extension with .toml
501
- const tomlPath = destPath.replace(/\.md$/, '.toml');
502
- fs.writeFileSync(tomlPath, tomlContent);
503
- } else {
504
- fs.writeFileSync(destPath, content);
505
- }
506
- } else if (isCodex) {
507
- content = convertClaudeToCodexMarkdown(content);
508
- fs.writeFileSync(destPath, content);
509
- } else if (runtime === 'copilot') {
510
- content = convertClaudeToCopilotMarkdown(content);
511
- fs.writeFileSync(destPath, content);
512
- } else {
513
- fs.writeFileSync(destPath, content);
514
- }
515
- } catch {}
516
- } else {
517
- try { fs.copyFileSync(srcPath, destPath); } catch {}
518
- }
519
- }
520
- }
521
-
522
- /**
523
- * Clean up orphaned files from previous PAN versions
524
- */
525
- function cleanupOrphanedFiles(configDir) {
526
- const orphanedFiles = [
527
- 'hooks/pan-notify.sh', // Removed in v1.6.x
528
- 'hooks/statusline.js', // Renamed to pan-statusline.js in v1.9.0
529
- ];
530
-
531
- for (const relPath of orphanedFiles) {
532
- const fullPath = path.join(configDir, relPath);
533
- if (fs.existsSync(fullPath)) {
534
- fs.unlinkSync(fullPath);
535
- console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
536
- }
537
- }
538
- }
539
-
540
- /**
541
- * Clean up orphaned hook registrations from settings.json
542
- */
543
- function cleanupOrphanedHooks(settings) {
544
- const orphanedHookPatterns = [
545
- 'pan-notify.sh', // Removed in v1.6.x
546
- 'hooks/statusline.js', // Renamed to pan-statusline.js in v1.9.0
547
- 'pan-intel-index.js', // Removed in v1.9.2
548
- 'pan-intel-session.js', // Removed in v1.9.2
549
- 'pan-intel-prune.js', // Removed in v1.9.2
550
- ];
551
-
552
- let cleanedHooks = false;
553
-
554
- // Check all hook event types (Stop, SessionStart, etc.)
555
- if (settings.hooks) {
556
- for (const eventType of Object.keys(settings.hooks)) {
557
- const hookEntries = settings.hooks[eventType];
558
- if (Array.isArray(hookEntries)) {
559
- // Filter out entries that contain orphaned hooks
560
- const filtered = hookEntries.filter(entry => {
561
- if (entry.hooks && Array.isArray(entry.hooks)) {
562
- // Check if any hook in this entry matches orphaned patterns
563
- const hasOrphaned = entry.hooks.some(h =>
564
- h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
565
- );
566
- if (hasOrphaned) {
567
- cleanedHooks = true;
568
- return false; // Remove this entry
569
- }
570
- }
571
- return true; // Keep this entry
572
- });
573
- settings.hooks[eventType] = filtered;
574
- }
575
- }
576
- }
577
-
578
- if (cleanedHooks) {
579
- console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
580
- }
581
-
582
- // Fix #330: Update statusLine if it points to old statusline.js path
583
- if (settings.statusLine && settings.statusLine.command &&
584
- settings.statusLine.command.includes('statusline.js') &&
585
- !settings.statusLine.command.includes('pan-statusline.js')) {
586
- // Replace old path with new path
587
- settings.statusLine.command = settings.statusLine.command.replace(
588
- /statusline\.js/,
589
- 'pan-statusline.js'
590
- );
591
- console.log(` ${green}✓${reset} Updated statusline path (statusline.js → pan-statusline.js)`);
592
- }
593
-
594
- return settings;
595
- }
596
-
597
- /**
598
- * Uninstall PAN from the specified directory for a specific runtime
599
- * Removes only PAN-specific files/directories, preserves user content
600
- * @param {boolean} isGlobal - Whether to uninstall from global or local
601
- * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
602
- */
603
- function uninstall(isGlobal, runtime = 'claude') {
604
- const isOpencode = runtime === 'opencode';
605
- const isCodex = runtime === 'codex';
606
- const isCopilot = runtime === 'copilot';
607
- const dirName = getDirName(runtime);
608
-
609
- // Get the target directory based on runtime and install type
610
- const targetDir = isGlobal
611
- ? getGlobalDir(runtime, explicitConfigDir)
612
- : path.join(process.cwd(), dirName);
613
-
614
- const locationLabel = isGlobal
615
- ? targetDir.replace(os.homedir(), '~')
616
- : targetDir.replace(process.cwd(), '.');
617
-
618
- let runtimeLabel = 'Claude Code';
619
- if (runtime === 'opencode') runtimeLabel = 'OpenCode';
620
- if (runtime === 'gemini') runtimeLabel = 'Gemini';
621
- if (runtime === 'codex') runtimeLabel = 'Codex';
622
- if (runtime === 'copilot') runtimeLabel = 'GitHub Copilot CLI';
623
-
624
- // Guard: never uninstall from the PAN source repository itself
625
- if (path.resolve(process.cwd()) === PAN_SOURCE_ROOT) {
626
- console.error(`\n ${red}✗${reset} Refusing to uninstall from PAN's own source repository.`);
627
- console.error(` Run from your target project directory instead.\n`);
628
- process.exit(1);
629
- }
630
-
631
- console.log(` Uninstalling PAN from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
632
-
633
- // Check if target directory exists
634
- if (!fs.existsSync(targetDir)) {
635
- console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
636
- console.log(` Nothing to uninstall.\n`);
637
- return;
638
- }
639
-
640
- let removedCount = 0;
641
-
642
- // 1. Remove PAN commands/skills
643
- if (isOpencode) {
644
- // OpenCode: remove command/pan-*.md files
645
- const commandDir = path.join(targetDir, 'command');
646
- if (fs.existsSync(commandDir)) {
647
- const files = fs.readdirSync(commandDir);
648
- for (const file of files) {
649
- if (file.startsWith('pan-') && file.endsWith('.md')) {
650
- try { fs.unlinkSync(path.join(commandDir, file)); } catch {}
651
- removedCount++;
652
- }
653
- }
654
- console.log(` ${green}✓${reset} Removed PAN commands from command/`);
655
- }
656
- } else if (isCodex || isCopilot) {
657
- // Codex & Copilot CLI: remove skills/pan-*/SKILL.md skill directories
658
- const skillsDir = path.join(targetDir, 'skills');
659
- if (fs.existsSync(skillsDir)) {
660
- let skillCount = 0;
661
- const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
662
- for (const entry of entries) {
663
- if (entry.isDirectory() && entry.name.startsWith('pan-')) {
664
- try { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); } catch {}
665
- skillCount++;
666
- }
667
- }
668
- if (skillCount > 0) {
669
- removedCount++;
670
- console.log(` ${green}✓${reset} Removed ${skillCount} ${isCopilot ? 'Copilot CLI' : 'Codex'} skills`);
671
- }
672
- }
673
- } else {
674
- // Claude Code & Gemini: remove commands/pan/ directory
675
- const panCommandsDir = path.join(targetDir, 'commands', 'pan');
676
- if (fs.existsSync(panCommandsDir)) {
677
- try { fs.rmSync(panCommandsDir, { recursive: true }); } catch {}
678
- removedCount++;
679
- console.log(` ${green}✓${reset} Removed commands/pan/`);
680
- }
681
- }
682
-
683
- // 2. Remove pan-wizard-core directory
684
- const panDir = path.join(targetDir, 'pan-wizard-core');
685
- if (fs.existsSync(panDir)) {
686
- try { fs.rmSync(panDir, { recursive: true }); } catch {}
687
- removedCount++;
688
- console.log(` ${green}✓${reset} Removed pan-wizard-core/`);
689
- }
690
-
691
- // 3. Remove PAN agents (pan-*.md files only)
692
- const agentsDir = path.join(targetDir, 'agents');
693
- if (fs.existsSync(agentsDir)) {
694
- const files = fs.readdirSync(agentsDir);
695
- let agentCount = 0;
696
- for (const file of files) {
697
- if (file.startsWith('pan-') && file.endsWith('.md')) {
698
- try { fs.unlinkSync(path.join(agentsDir, file)); } catch {}
699
- agentCount++;
700
- }
701
- }
702
- if (agentCount > 0) {
703
- removedCount++;
704
- console.log(` ${green}✓${reset} Removed ${agentCount} PAN agents`);
705
- }
706
- }
707
-
708
- // 4. Remove PAN hooks
709
- const hooksDir = path.join(targetDir, 'hooks');
710
- if (fs.existsSync(hooksDir)) {
711
- const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js'];
712
- let hookCount = 0;
713
- for (const hook of panHooks) {
714
- const hookPath = path.join(hooksDir, hook);
715
- if (fs.existsSync(hookPath)) {
716
- try { fs.unlinkSync(hookPath); } catch {}
717
- hookCount++;
718
- }
719
- }
720
- if (hookCount > 0) {
721
- removedCount++;
722
- console.log(` ${green}✓${reset} Removed ${hookCount} PAN hooks`);
723
- }
724
- }
725
-
726
- // 5. Remove PAN package.json (CommonJS mode marker)
727
- const pkgJsonPath = path.join(targetDir, 'package.json');
728
- if (fs.existsSync(pkgJsonPath)) {
729
- try {
730
- const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
731
- // Only remove if it's our minimal CommonJS marker (handle formatting variations)
732
- const normalized = content.replace(/\s+/g, '');
733
- if (normalized === '{"type":"commonjs"}') {
734
- fs.unlinkSync(pkgJsonPath);
735
- removedCount++;
736
- console.log(` ${green}✓${reset} Removed PAN package.json`);
737
- }
738
- } catch (e) {
739
- // Ignore read errors
740
- }
741
- }
742
-
743
- // 6a. Clean up Copilot CLI config.json
744
- if (isCopilot) {
745
- const configPath = path.join(targetDir, 'config.json');
746
- if (fs.existsSync(configPath)) {
747
- try {
748
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
749
- let configModified = false;
750
-
751
- // Remove PAN statusline
752
- if (config.statusLine && config.statusLine.command &&
753
- config.statusLine.command.includes('pan-statusline')) {
754
- delete config.statusLine;
755
- configModified = true;
756
- console.log(` ${green}✓${reset} Removed PAN statusline from config`);
757
- }
758
-
759
- // Remove PAN hooks from sessionStart
760
- if (config.hooks && config.hooks.sessionStart) {
761
- const before = config.hooks.sessionStart.length;
762
- config.hooks.sessionStart = config.hooks.sessionStart.filter(h =>
763
- !(h.command && h.command.includes('pan-check-update'))
764
- );
765
- if (config.hooks.sessionStart.length < before) {
766
- configModified = true;
767
- console.log(` ${green}✓${reset} Removed PAN hooks from config`);
768
- }
769
- if (config.hooks.sessionStart.length === 0) {
770
- delete config.hooks.sessionStart;
771
- }
772
- }
773
-
774
- // Remove PAN hooks from postToolUse
775
- if (config.hooks && config.hooks.postToolUse) {
776
- const before = config.hooks.postToolUse.length;
777
- config.hooks.postToolUse = config.hooks.postToolUse.filter(h =>
778
- !(h.command && h.command.includes('pan-context-monitor'))
779
- );
780
- if (config.hooks.postToolUse.length < before) {
781
- configModified = true;
782
- console.log(` ${green}✓${reset} Removed context monitor from config`);
783
- }
784
- if (config.hooks.postToolUse.length === 0) {
785
- delete config.hooks.postToolUse;
786
- }
787
- }
788
-
789
- if (config.hooks && Object.keys(config.hooks).length === 0) {
790
- delete config.hooks;
791
- }
792
-
793
- if (configModified) {
794
- if (Object.keys(config).length === 0) {
795
- fs.unlinkSync(configPath);
796
- console.log(` ${green}✓${reset} Removed empty config.json`);
797
- } else {
798
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
799
- }
800
- removedCount++;
801
- }
802
- } catch (e) {
803
- // Ignore JSON parse errors
804
- }
805
- }
806
- }
807
-
808
- // 6b. Clean up settings.json (remove PAN hooks and statusline)
809
- const settingsPath = path.join(targetDir, 'settings.json');
810
- if (fs.existsSync(settingsPath)) {
811
- let settings = readSettings(settingsPath);
812
- let settingsModified = false;
813
-
814
- // Remove PAN statusline if it references our hook
815
- if (settings.statusLine && settings.statusLine.command &&
816
- settings.statusLine.command.includes('pan-statusline')) {
817
- delete settings.statusLine;
818
- settingsModified = true;
819
- console.log(` ${green}✓${reset} Removed PAN statusline from settings`);
820
- }
821
-
822
- // Remove PAN hooks from SessionStart
823
- if (settings.hooks && settings.hooks.SessionStart) {
824
- const before = settings.hooks.SessionStart.length;
825
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => {
826
- if (entry.hooks && Array.isArray(entry.hooks)) {
827
- // Filter out PAN hooks
828
- const hasPanHook = entry.hooks.some(h =>
829
- h.command && (h.command.includes('pan-check-update') || h.command.includes('pan-statusline'))
830
- );
831
- return !hasPanHook;
832
- }
833
- return true;
834
- });
835
- if (settings.hooks.SessionStart.length < before) {
836
- settingsModified = true;
837
- console.log(` ${green}✓${reset} Removed PAN hooks from settings`);
838
- }
839
- // Clean up empty array
840
- if (settings.hooks.SessionStart.length === 0) {
841
- delete settings.hooks.SessionStart;
842
- }
843
- }
844
-
845
- // Remove PAN hooks from PostToolUse
846
- if (settings.hooks && settings.hooks.PostToolUse) {
847
- const before = settings.hooks.PostToolUse.length;
848
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
849
- if (entry.hooks && Array.isArray(entry.hooks)) {
850
- const hasPanHook = entry.hooks.some(h =>
851
- h.command && h.command.includes('pan-context-monitor')
852
- );
853
- return !hasPanHook;
854
- }
855
- return true;
856
- });
857
- if (settings.hooks.PostToolUse.length < before) {
858
- settingsModified = true;
859
- console.log(` ${green}✓${reset} Removed context monitor hook from settings`);
860
- }
861
- if (settings.hooks.PostToolUse.length === 0) {
862
- delete settings.hooks.PostToolUse;
863
- }
864
- }
865
-
866
- // Clean up empty hooks object
867
- if (settings.hooks && Object.keys(settings.hooks).length === 0) {
868
- delete settings.hooks;
869
- }
870
-
871
- // Remove Gemini experimental.enableAgents if PAN set it
872
- if (settings.experimental && settings.experimental.enableAgents === true) {
873
- delete settings.experimental.enableAgents;
874
- if (Object.keys(settings.experimental).length === 0) {
875
- delete settings.experimental;
876
- }
877
- settingsModified = true;
878
- console.log(` ${green}✓${reset} Removed experimental agents flag from settings`);
879
- }
880
-
881
- if (settingsModified) {
882
- // If settings is now empty (only PAN entries existed), remove the file
883
- if (Object.keys(settings).length === 0) {
884
- fs.unlinkSync(settingsPath);
885
- console.log(` ${green}✓${reset} Removed empty settings.json`);
886
- } else {
887
- writeSettings(settingsPath, settings);
888
- }
889
- removedCount++;
890
- }
891
- }
892
-
893
- // 6. For OpenCode, clean up permissions from opencode.json
894
- if (isOpencode) {
895
- // For local uninstalls, clean up ./.opencode/opencode.json
896
- // For global uninstalls, clean up ~/.config/opencode/opencode.json
897
- const opencodeConfigDir = isGlobal
898
- ? getOpencodeGlobalDir()
899
- : path.join(process.cwd(), '.opencode');
900
- const configPath = path.join(opencodeConfigDir, 'opencode.json');
901
- if (fs.existsSync(configPath)) {
902
- try {
903
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
904
- let modified = false;
905
-
906
- // Remove PAN permission entries
907
- if (config.permission) {
908
- for (const permType of ['read', 'external_directory']) {
909
- if (config.permission[permType]) {
910
- const keys = Object.keys(config.permission[permType]);
911
- for (const key of keys) {
912
- if (key.includes('pan-wizard-core')) {
913
- delete config.permission[permType][key];
914
- modified = true;
915
- }
916
- }
917
- // Clean up empty objects
918
- if (Object.keys(config.permission[permType]).length === 0) {
919
- delete config.permission[permType];
920
- }
921
- }
922
- }
923
- if (Object.keys(config.permission).length === 0) {
924
- delete config.permission;
925
- }
926
- }
927
-
928
- if (modified) {
929
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
930
- removedCount++;
931
- console.log(` ${green}✓${reset} Removed PAN permissions from opencode.json`);
932
- }
933
- } catch (e) {
934
- // Ignore JSON parse errors
935
- }
936
- }
937
- }
938
-
939
- // 7. Remove pan-file-manifest.json
940
- const manifestPath = path.join(targetDir, MANIFEST_NAME);
941
- if (fs.existsSync(manifestPath)) {
942
- try { fs.unlinkSync(manifestPath); } catch {}
943
- removedCount++;
944
- console.log(` ${green}✓${reset} Removed ${MANIFEST_NAME}`);
945
- }
946
-
947
- // 8. Clean up empty PAN directories
948
- const dirsToClean = [
949
- path.join(targetDir, 'agents'),
950
- path.join(targetDir, 'hooks'),
951
- path.join(targetDir, 'skills'),
952
- path.join(targetDir, 'commands', 'pan'),
953
- path.join(targetDir, 'commands'),
954
- ];
955
- for (const dir of dirsToClean) {
956
- try {
957
- if (fs.existsSync(dir)) {
958
- const remaining = fs.readdirSync(dir);
959
- if (remaining.length === 0) {
960
- fs.rmdirSync(dir);
961
- }
962
- }
963
- } catch {}
964
- }
965
-
966
- if (removedCount === 0) {
967
- console.log(` ${yellow}⚠${reset} No PAN files found to remove.`);
968
- }
969
-
970
- console.log(`
971
- ${green}Done!${reset} PAN has been uninstalled from ${runtimeLabel}.
972
- Your other files and settings have been preserved.
973
- `);
974
- }
975
-
976
-
977
- /**
978
- * Configure OpenCode permissions to allow reading PAN reference docs
979
- * This prevents permission prompts when PAN accesses the pan-wizard-core directory
980
- * @param {boolean} isGlobal - Whether this is a global or local install
981
- */
982
- function configureOpencodePermissions(isGlobal = true) {
983
- // For local installs, use ./.opencode/opencode.json
984
- // For global installs, use ~/.config/opencode/opencode.json
985
- const opencodeConfigDir = isGlobal
986
- ? getOpencodeGlobalDir()
987
- : path.join(process.cwd(), '.opencode');
988
- const configPath = path.join(opencodeConfigDir, 'opencode.json');
989
-
990
- // Ensure config directory exists
991
- fs.mkdirSync(opencodeConfigDir, { recursive: true });
992
-
993
- // Read existing config or create empty object
994
- let config = {};
995
- if (fs.existsSync(configPath)) {
996
- try {
997
- const content = fs.readFileSync(configPath, 'utf8');
998
- config = parseJsonc(content);
999
- } catch (e) {
1000
- // Cannot parse - DO NOT overwrite user's config
1001
- console.log(` ${yellow}⚠${reset} Could not parse opencode.json - skipping permission config`);
1002
- console.log(` ${dim}Reason: ${e.message}${reset}`);
1003
- console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
1004
- return;
1005
- }
1006
- }
1007
-
1008
- // Ensure permission structure exists
1009
- if (!config.permission) {
1010
- config.permission = {};
1011
- }
1012
-
1013
- // Build the PAN path using the actual config directory
1014
- // Use ~ shorthand if it's in the default location, otherwise use full path
1015
- const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
1016
- const panPath = opencodeConfigDir === defaultConfigDir
1017
- ? '~/.config/opencode/pan-wizard/*'
1018
- : `${opencodeConfigDir.replace(/\\/g, '/')}/pan-wizard-core/*`;
1019
-
1020
- let modified = false;
1021
-
1022
- // Configure read permission
1023
- if (!config.permission.read || typeof config.permission.read !== 'object') {
1024
- config.permission.read = {};
1025
- }
1026
- if (config.permission.read[panPath] !== 'allow') {
1027
- config.permission.read[panPath] = 'allow';
1028
- modified = true;
1029
- }
1030
-
1031
- // Configure external_directory permission (the safety guard for paths outside project)
1032
- if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
1033
- config.permission.external_directory = {};
1034
- }
1035
- if (config.permission.external_directory[panPath] !== 'allow') {
1036
- config.permission.external_directory[panPath] = 'allow';
1037
- modified = true;
1038
- }
1039
-
1040
- if (!modified) {
1041
- return; // Already configured
1042
- }
1043
-
1044
- // Write config back
1045
- try {
1046
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1047
- console.log(` ${green}✓${reset} Configured read permission for PAN docs`);
1048
- } catch (e) {
1049
- console.error(` ${yellow}⚠${reset} Failed to write config: ${e.message}`);
1050
- }
1051
- }
1052
-
1053
- /**
1054
- * Verify a directory exists and contains files
1055
- */
1056
- function verifyInstalled(dirPath, description) {
1057
- if (!fs.existsSync(dirPath)) {
1058
- console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
1059
- return false;
1060
- }
1061
- try {
1062
- const entries = fs.readdirSync(dirPath);
1063
- if (entries.length === 0) {
1064
- console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
1065
- return false;
1066
- }
1067
- } catch (e) {
1068
- console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
1069
- return false;
1070
- }
1071
- return true;
1072
- }
1073
-
1074
- /**
1075
- * Verify a file exists
1076
- */
1077
- function verifyFileInstalled(filePath, description) {
1078
- if (!fs.existsSync(filePath)) {
1079
- console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
1080
- return false;
1081
- }
1082
- return true;
1083
- }
1084
-
1085
- /**
1086
- * Install to the specified directory for a specific runtime
1087
- * @param {boolean} isGlobal - Whether to install globally or locally
1088
- * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
1089
- */
1090
-
1091
- // ──────────────────────────────────────────────────────
1092
- // Local Patch Persistence
1093
- // ──────────────────────────────────────────────────────
1094
-
1095
- const PATCHES_DIR_NAME = 'pan-local-patches';
1096
- const MANIFEST_NAME = 'pan-file-manifest.json';
1097
-
1098
- /**
1099
- * Compute SHA256 hash of file contents
1100
- */
1101
- function fileHash(filePath) {
1102
- try {
1103
- const content = fs.readFileSync(filePath);
1104
- return crypto.createHash('sha256').update(content).digest('hex');
1105
- } catch (e) {
1106
- return null;
1107
- }
1108
- }
1109
-
1110
- /**
1111
- * Recursively collect all files in dir with their hashes
1112
- */
1113
- function generateManifest(dir, baseDir) {
1114
- if (!baseDir) baseDir = dir;
1115
- const manifest = {};
1116
- if (!fs.existsSync(dir)) return manifest;
1117
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1118
- for (const entry of entries) {
1119
- const fullPath = path.join(dir, entry.name);
1120
- const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
1121
- if (entry.isDirectory()) {
1122
- Object.assign(manifest, generateManifest(fullPath, baseDir));
1123
- } else {
1124
- manifest[relPath] = fileHash(fullPath);
1125
- }
1126
- }
1127
- return manifest;
1128
- }
1129
-
1130
- /**
1131
- * Write file manifest after installation for future modification detection
1132
- */
1133
- function writeManifest(configDir, runtime = 'claude') {
1134
- const isOpencode = runtime === 'opencode';
1135
- const isCodex = runtime === 'codex';
1136
- const isCopilot = runtime === 'copilot';
1137
- const panDir = path.join(configDir, 'pan-wizard-core');
1138
- const commandsDir = path.join(configDir, 'commands', 'pan');
1139
- const opencodeCommandDir = path.join(configDir, 'command');
1140
- const codexSkillsDir = path.join(configDir, 'skills');
1141
- const agentsDir = path.join(configDir, 'agents');
1142
- const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} };
1143
-
1144
- const panHashes = generateManifest(panDir);
1145
- for (const [rel, hash] of Object.entries(panHashes)) {
1146
- manifest.files['pan-wizard-core/' + rel] = hash;
1147
- }
1148
- if (!isOpencode && !isCodex && fs.existsSync(commandsDir)) {
1149
- const cmdHashes = generateManifest(commandsDir);
1150
- for (const [rel, hash] of Object.entries(cmdHashes)) {
1151
- manifest.files['commands/pan/' + rel] = hash;
1152
- }
1153
- }
1154
- if (isOpencode && fs.existsSync(opencodeCommandDir)) {
1155
- for (const file of fs.readdirSync(opencodeCommandDir)) {
1156
- if (file.startsWith('pan-') && file.endsWith('.md')) {
1157
- manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
1158
- }
1159
- }
1160
- }
1161
- if ((isCodex || isCopilot) && fs.existsSync(codexSkillsDir)) {
1162
- for (const skillName of listCodexSkillNames(codexSkillsDir)) {
1163
- const skillRoot = path.join(codexSkillsDir, skillName);
1164
- const skillHashes = generateManifest(skillRoot);
1165
- for (const [rel, hash] of Object.entries(skillHashes)) {
1166
- manifest.files[`skills/${skillName}/${rel}`] = hash;
1167
- }
1168
- }
1169
- }
1170
- if (fs.existsSync(agentsDir)) {
1171
- for (const file of fs.readdirSync(agentsDir)) {
1172
- if (file.startsWith('pan-') && file.endsWith('.md')) {
1173
- manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
1174
- }
1175
- }
1176
- }
1177
- // Track hook scripts in manifest for modification detection during upgrades
1178
- const hooksDir = path.join(configDir, 'hooks');
1179
- if (fs.existsSync(hooksDir)) {
1180
- for (const file of fs.readdirSync(hooksDir)) {
1181
- if (file.startsWith('pan-') && file.endsWith('.js')) {
1182
- manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
1183
- }
1184
- }
1185
- }
1186
-
1187
- try {
1188
- fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
1189
- } catch (e) {
1190
- console.error(` ${yellow}⚠${reset} Failed to write manifest: ${e.message}`);
1191
- }
1192
- return manifest;
1193
- }
1194
-
1195
- /**
1196
- * Detect user-modified PAN files by comparing against install manifest.
1197
- * Backs up modified files to pan-local-patches/ for reapply after update.
1198
- */
1199
- function saveLocalPatches(configDir) {
1200
- const manifestPath = path.join(configDir, MANIFEST_NAME);
1201
- if (!fs.existsSync(manifestPath)) return [];
1202
-
1203
- let manifest;
1204
- try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
1205
-
1206
- const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1207
- const modified = [];
1208
-
1209
- for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
1210
- const fullPath = path.join(configDir, relPath);
1211
- if (!fs.existsSync(fullPath)) continue;
1212
- const currentHash = fileHash(fullPath);
1213
- if (currentHash !== originalHash) {
1214
- const backupPath = path.join(patchesDir, relPath);
1215
- fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1216
- fs.copyFileSync(fullPath, backupPath);
1217
- modified.push(relPath);
1218
- }
1219
- }
1220
-
1221
- if (modified.length > 0) {
1222
- const meta = {
1223
- backed_up_at: new Date().toISOString(),
1224
- from_version: manifest.version,
1225
- files: modified
1226
- };
1227
- fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
1228
- console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified PAN file(s) backed up to ' + PATCHES_DIR_NAME + '/');
1229
- for (const f of modified) {
1230
- console.log(' ' + dim + f + reset);
1231
- }
1232
- }
1233
- return modified;
1234
- }
1235
-
1236
- /**
1237
- * After install, report backed-up patches for user to reapply.
1238
- */
1239
- function reportLocalPatches(configDir, runtime = 'claude') {
1240
- const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1241
- const metaPath = path.join(patchesDir, 'backup-meta.json');
1242
- if (!fs.existsSync(metaPath)) return [];
1243
-
1244
- let meta;
1245
- try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
1246
-
1247
- if (meta.files && meta.files.length > 0) {
1248
- const reapplyCommand = runtime === 'opencode'
1249
- ? '/pan-patches'
1250
- : runtime === 'codex'
1251
- ? '$pan-patches'
1252
- : '/pan:patches';
1253
- console.log('');
1254
- console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
1255
- for (const f of meta.files) {
1256
- console.log(' ' + cyan + f + reset);
1257
- }
1258
- console.log('');
1259
- console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset);
1260
- console.log(' Run ' + cyan + reapplyCommand + reset + ' to merge them into the new version.');
1261
- console.log(' Or manually compare and merge the files.');
1262
- console.log('');
1263
- }
1264
- return meta.files || [];
1265
- }
1266
-
1267
- function install(isGlobal, runtime = 'claude') {
1268
- const isOpencode = runtime === 'opencode';
1269
- const isGemini = runtime === 'gemini';
1270
- const isCodex = runtime === 'codex';
1271
- const isCopilot = runtime === 'copilot';
1272
- const dirName = getDirName(runtime);
1273
- const src = path.join(__dirname, '..');
1274
-
1275
- // Get the target directory based on runtime and install type
1276
- const targetDir = isGlobal
1277
- ? getGlobalDir(runtime, explicitConfigDir)
1278
- : path.join(process.cwd(), dirName);
1279
-
1280
- const locationLabel = isGlobal
1281
- ? targetDir.replace(os.homedir(), '~')
1282
- : targetDir.replace(process.cwd(), '.');
1283
-
1284
- // Path prefix for file references in markdown content
1285
- // For global installs: use full path
1286
- // For local installs: use relative
1287
- const pathPrefix = isGlobal
1288
- ? `${targetDir.replace(/\\/g, '/')}/`
1289
- : `./${dirName}/`;
1290
-
1291
- let runtimeLabel = 'Claude Code';
1292
- if (isOpencode) runtimeLabel = 'OpenCode';
1293
- if (isGemini) runtimeLabel = 'Gemini';
1294
- if (isCodex) runtimeLabel = 'Codex';
1295
- if (isCopilot) runtimeLabel = 'GitHub Copilot CLI';
1296
-
1297
- // Guard: never install into the PAN source repository itself
1298
- if (path.resolve(process.cwd()) === PAN_SOURCE_ROOT) {
1299
- console.error(`\n ${red}✗${reset} Refusing to install PAN into its own source repository.`);
1300
- console.error(` Run the installer from your target project directory instead.\n`);
1301
- console.error(` Example: cd /path/to/my-project && node ${path.resolve(__dirname, 'install.js')} --claude --local\n`);
1302
- process.exit(1);
1303
- }
1304
-
1305
- console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
1306
-
1307
- // Early writability check fail fast before any file operations
1308
- try {
1309
- fs.mkdirSync(targetDir, { recursive: true });
1310
- const probe = path.join(targetDir, '.pan-write-test');
1311
- fs.writeFileSync(probe, '');
1312
- fs.unlinkSync(probe);
1313
- } catch (e) {
1314
- console.error(` ${red}✗${reset} Cannot write to ${locationLabel}: ${e.message}`);
1315
- console.error(` Check directory permissions and try again.`);
1316
- process.exit(1);
1317
- }
1318
-
1319
- // Track installation failures
1320
- const failures = [];
1321
-
1322
- // Save any locally modified PAN files before they get wiped
1323
- saveLocalPatches(targetDir);
1324
-
1325
- // Clean up orphaned files from previous versions
1326
- cleanupOrphanedFiles(targetDir);
1327
-
1328
- // OpenCode uses command/ (flat), Codex uses skills/, Claude/Gemini use commands/pan/
1329
- try {
1330
- if (isOpencode) {
1331
- // OpenCode: flat structure in command/ directory
1332
- const commandDir = path.join(targetDir, 'command');
1333
- fs.mkdirSync(commandDir, { recursive: true });
1334
-
1335
- // Copy commands/pan/*.md as command/pan-*.md (flatten structure)
1336
- const panSrc = path.join(src, 'commands', 'pan');
1337
- copyFlattenedCommands(panSrc, commandDir, 'pan', pathPrefix, runtime);
1338
- if (verifyInstalled(commandDir, 'command/pan-*')) {
1339
- const count = fs.readdirSync(commandDir).filter(f => f.startsWith('pan-')).length;
1340
- console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
1341
- } else {
1342
- failures.push('command/pan-*');
1343
- }
1344
- } else if (isCodex) {
1345
- const skillsDir = path.join(targetDir, 'skills');
1346
- const panSrc = path.join(src, 'commands', 'pan');
1347
- copyCommandsAsCodexSkills(panSrc, skillsDir, 'pan', pathPrefix, runtime);
1348
- const installedSkillNames = listCodexSkillNames(skillsDir);
1349
- if (installedSkillNames.length > 0) {
1350
- console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`);
1351
- } else {
1352
- failures.push('skills/pan-*');
1353
- }
1354
- } else if (isCopilot) {
1355
- const skillsDir = path.join(targetDir, 'skills');
1356
- const panSrc = path.join(src, 'commands', 'pan');
1357
- copyCommandsAsCopilotSkills(panSrc, skillsDir, 'pan', pathPrefix, runtime);
1358
- const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
1359
- .filter(e => e.isDirectory() && e.name.startsWith('pan-'));
1360
- if (installedSkills.length > 0) {
1361
- console.log(` ${green}✓${reset} Installed ${installedSkills.length} skills to skills/`);
1362
- } else {
1363
- failures.push('skills/pan-*');
1364
- }
1365
- } else {
1366
- // Claude Code & Gemini: nested structure in commands/ directory
1367
- const commandsDir = path.join(targetDir, 'commands');
1368
- fs.mkdirSync(commandsDir, { recursive: true });
1369
-
1370
- const panSrc = path.join(src, 'commands', 'pan');
1371
- const panDest = path.join(commandsDir, 'pan');
1372
- copyWithPathReplacement(panSrc, panDest, pathPrefix, runtime, true);
1373
- if (verifyInstalled(panDest, 'commands/pan')) {
1374
- console.log(` ${green}✓${reset} Installed commands/pan`);
1375
- } else {
1376
- failures.push('commands/pan');
1377
- }
1378
- }
1379
- } catch (e) {
1380
- console.error(` ${yellow}✗${reset} Commands install failed: ${e.message}`);
1381
- failures.push('commands');
1382
- }
1383
-
1384
- // Copy pan-wizard-core skill with path replacement
1385
- try {
1386
- const skillSrc = path.join(src, 'pan-wizard-core');
1387
- const skillDest = path.join(targetDir, 'pan-wizard-core');
1388
- copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime);
1389
- if (verifyInstalled(skillDest, 'pan-wizard-core')) {
1390
- console.log(` ${green}✓${reset} Installed pan-wizard-core`);
1391
- } else {
1392
- failures.push('pan-wizard-core');
1393
- }
1394
- } catch (e) {
1395
- console.error(` ${yellow}✗${reset} pan-wizard-core install failed: ${e.message}`);
1396
- failures.push('pan-wizard-core');
1397
- }
1398
-
1399
- // Copy agents to agents directory
1400
- try {
1401
- const agentsSrc = path.join(src, 'agents');
1402
- if (fs.existsSync(agentsSrc)) {
1403
- const agentsDest = path.join(targetDir, 'agents');
1404
- fs.mkdirSync(agentsDest, { recursive: true });
1405
-
1406
- // Remove old PAN agents (pan-*.md and pan-*.agent.md) before copying new ones
1407
- if (fs.existsSync(agentsDest)) {
1408
- for (const file of fs.readdirSync(agentsDest)) {
1409
- if (file.startsWith('pan-') && file.endsWith('.md')) {
1410
- fs.unlinkSync(path.join(agentsDest, file));
1411
- }
1412
- }
1413
- }
1414
-
1415
- // Copy new agents
1416
- const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
1417
- for (const entry of agentEntries) {
1418
- if (entry.isFile() && entry.name.endsWith('.md')) {
1419
- let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
1420
- // Always replace ~/.claude/ as it is the source of truth in the repo
1421
- const dirRegex = /~\/\.claude\//g;
1422
- content = content.replace(dirRegex, pathPrefix);
1423
- content = processAttribution(content, getCommitAttribution(runtime));
1424
- // Convert frontmatter for runtime compatibility
1425
- if (isOpencode) {
1426
- content = convertClaudeToOpencodeFrontmatter(content);
1427
- } else if (isGemini) {
1428
- content = convertClaudeToGeminiAgent(content);
1429
- } else if (isCodex) {
1430
- content = convertClaudeToCodexMarkdown(content);
1431
- } else if (isCopilot) {
1432
- content = convertClaudeToCopilotAgent(content);
1433
- }
1434
- // Copilot CLI uses .agent.md extension; others use .md
1435
- const destName = isCopilot
1436
- ? entry.name.replace(/\.md$/, '.agent.md')
1437
- : entry.name;
1438
- fs.writeFileSync(path.join(agentsDest, destName), content);
1439
- }
1440
- }
1441
- if (verifyInstalled(agentsDest, 'agents')) {
1442
- console.log(` ${green}✓${reset} Installed agents`);
1443
- } else {
1444
- failures.push('agents');
1445
- }
1446
- }
1447
- } catch (e) {
1448
- console.error(` ${yellow}✗${reset} Agents install failed: ${e.message}`);
1449
- failures.push('agents');
1450
- }
1451
-
1452
- // Copy CHANGELOG.md
1453
- try {
1454
- const changelogSrc = path.join(src, 'CHANGELOG.md');
1455
- const changelogDest = path.join(targetDir, 'pan-wizard-core', 'CHANGELOG.md');
1456
- if (fs.existsSync(changelogSrc)) {
1457
- fs.copyFileSync(changelogSrc, changelogDest);
1458
- if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
1459
- console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
1460
- } else {
1461
- failures.push('CHANGELOG.md');
1462
- }
1463
- }
1464
- } catch (e) {
1465
- console.error(` ${yellow}✗${reset} CHANGELOG install failed: ${e.message}`);
1466
- failures.push('CHANGELOG.md');
1467
- }
1468
-
1469
- // Write VERSION file (with upgrade/same/downgrade detection)
1470
- try {
1471
- const versionDest = path.join(targetDir, 'pan-wizard-core', 'VERSION');
1472
- let versionMsg = `${pkg.version}`;
1473
- try {
1474
- const prev = fs.readFileSync(versionDest, 'utf8').trim();
1475
- if (prev && prev !== pkg.version) {
1476
- versionMsg = `${prev} ${pkg.version}`;
1477
- } else if (prev === pkg.version) {
1478
- versionMsg = `${pkg.version} (reinstall)`;
1479
- }
1480
- } catch (_) { /* first install */ }
1481
- fs.writeFileSync(versionDest, pkg.version + '\n');
1482
- if (verifyFileInstalled(versionDest, 'VERSION')) {
1483
- console.log(` ${green}✓${reset} Wrote VERSION (${versionMsg})`);
1484
- } else {
1485
- failures.push('VERSION');
1486
- }
1487
- } catch (e) {
1488
- console.error(` ${yellow}✗${reset} VERSION write failed: ${e.message}`);
1489
- failures.push('VERSION');
1490
- }
1491
-
1492
- if (!isCodex) {
1493
- // Write package.json to force CommonJS mode for PAN scripts
1494
- // Prevents "require is not defined" errors when project has "type": "module"
1495
- // Node.js walks up looking for package.json - this stops inheritance from project
1496
- try {
1497
- const pkgJsonDest = path.join(targetDir, 'package.json');
1498
- fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
1499
- console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
1500
- } catch (e) {
1501
- console.error(` ${yellow}✗${reset} package.json write failed: ${e.message}`);
1502
- failures.push('package.json');
1503
- }
1504
- }
1505
-
1506
- if (!isCodex && !isOpencode) {
1507
- // Copy hooks from dist/ (bundled with dependencies)
1508
- // Hooks are only supported by Claude Code, Gemini, and Copilot CLI
1509
- // Template paths for the target runtime (replaces '.claude' with correct config dir)
1510
- try {
1511
- const hooksSrc = path.join(src, 'hooks', 'dist');
1512
- if (fs.existsSync(hooksSrc)) {
1513
- const hooksDest = path.join(targetDir, 'hooks');
1514
- fs.mkdirSync(hooksDest, { recursive: true });
1515
- const hookEntries = fs.readdirSync(hooksSrc);
1516
- const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
1517
- for (const entry of hookEntries) {
1518
- const srcFile = path.join(hooksSrc, entry);
1519
- if (fs.statSync(srcFile).isFile()) {
1520
- const destFile = path.join(hooksDest, entry);
1521
- // Template .js files to replace '.claude' with runtime-specific config dir
1522
- if (entry.endsWith('.js')) {
1523
- let content = fs.readFileSync(srcFile, 'utf8');
1524
- content = content.replace(/'\.claude'/g, configDirReplacement);
1525
- fs.writeFileSync(destFile, content);
1526
- } else {
1527
- fs.copyFileSync(srcFile, destFile);
1528
- }
1529
- }
1530
- }
1531
- if (verifyInstalled(hooksDest, 'hooks')) {
1532
- console.log(` ${green}✓${reset} Installed hooks (bundled)`);
1533
- } else {
1534
- failures.push('hooks');
1535
- }
1536
- }
1537
- } catch (e) {
1538
- console.error(` ${yellow}✗${reset} Hooks install failed: ${e.message}`);
1539
- failures.push('hooks');
1540
- }
1541
- }
1542
-
1543
- if (failures.length > 0) {
1544
- console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
1545
- process.exit(1);
1546
- }
1547
-
1548
- // Write file manifest for future modification detection
1549
- writeManifest(targetDir, runtime);
1550
- console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
1551
-
1552
- // Report any backed-up local patches
1553
- reportLocalPatches(targetDir, runtime);
1554
-
1555
- // Post-install self-check: verify critical files exist before declaring success
1556
- const selfCheckIssues = [];
1557
- const panToolsPath = path.join(targetDir, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
1558
- if (!fs.existsSync(panToolsPath)) selfCheckIssues.push('pan-tools.cjs missing');
1559
- const manifestFullPath = path.join(targetDir, MANIFEST_NAME);
1560
- if (fs.existsSync(manifestFullPath)) {
1561
- try {
1562
- const mfst = JSON.parse(fs.readFileSync(manifestFullPath, 'utf8'));
1563
- const fileCount = Object.keys(mfst.files || {}).length;
1564
- if (fileCount < 50) selfCheckIssues.push(`manifest has only ${fileCount} files (expected 150+)`);
1565
- } catch { selfCheckIssues.push('manifest unreadable'); }
1566
- } else {
1567
- selfCheckIssues.push('manifest missing');
1568
- }
1569
- if (selfCheckIssues.length > 0) {
1570
- console.error(`\n ${yellow}⚠ Post-install check found issues:${reset}`);
1571
- for (const issue of selfCheckIssues) console.error(` - ${issue}`);
1572
- console.error(` Run ${cyan}pan-tools validate deployment${reset} for full diagnostics.\n`);
1573
- }
1574
-
1575
- if (isCodex) {
1576
- return { settingsPath: null, settings: null, statuslineCommand: null, runtime };
1577
- }
1578
-
1579
- const updateCheckCommand = isGlobal
1580
- ? buildHookCommand(targetDir, 'pan-check-update.js')
1581
- : 'node ' + dirName + '/hooks/pan-check-update.js';
1582
- const contextMonitorCommand = isGlobal
1583
- ? buildHookCommand(targetDir, 'pan-context-monitor.js')
1584
- : 'node ' + dirName + '/hooks/pan-context-monitor.js';
1585
- const statuslineCommand = isGlobal
1586
- ? buildHookCommand(targetDir, 'pan-statusline.js')
1587
- : 'node ' + dirName + '/hooks/pan-statusline.js';
1588
-
1589
- // Copilot CLI uses config.json with its own hook format
1590
- if (isCopilot) {
1591
- const configPath = path.join(targetDir, 'config.json');
1592
- const config = readSettings(configPath);
1593
-
1594
- if (!config.hooks) {
1595
- config.hooks = {};
1596
- }
1597
-
1598
- // Register sessionStart hook for update checking
1599
- if (!config.hooks.sessionStart) {
1600
- config.hooks.sessionStart = [];
1601
- }
1602
- const hasPanUpdateHook = config.hooks.sessionStart.some(h =>
1603
- h.command && h.command.includes('pan-check-update')
1604
- );
1605
- if (!hasPanUpdateHook) {
1606
- config.hooks.sessionStart.push({
1607
- command: updateCheckCommand
1608
- });
1609
- console.log(` ${green}✓${reset} Configured update check hook`);
1610
- }
1611
-
1612
- // Register postToolUse hook for context monitoring
1613
- if (!config.hooks.postToolUse) {
1614
- config.hooks.postToolUse = [];
1615
- }
1616
- const hasContextHook = config.hooks.postToolUse.some(h =>
1617
- h.command && h.command.includes('pan-context-monitor')
1618
- );
1619
- if (!hasContextHook) {
1620
- config.hooks.postToolUse.push({
1621
- command: contextMonitorCommand
1622
- });
1623
- console.log(` ${green}✓${reset} Configured context window monitor hook`);
1624
- }
1625
-
1626
- writeSettings(configPath, config);
1627
- return { settingsPath: configPath, settings: config, statuslineCommand, runtime };
1628
- }
1629
-
1630
- // Configure statusline and hooks in settings.json
1631
- // Claude Code, Gemini, OpenCode use settings.json
1632
- const settingsPath = path.join(targetDir, 'settings.json');
1633
- const settings = cleanupOrphanedHooks(readSettings(settingsPath));
1634
-
1635
- // Enable experimental agents for Gemini CLI (required for custom sub-agents)
1636
- if (isGemini) {
1637
- if (!settings.experimental) {
1638
- settings.experimental = {};
1639
- }
1640
- if (!settings.experimental.enableAgents) {
1641
- settings.experimental.enableAgents = true;
1642
- console.log(` ${green}✓${reset} Enabled experimental agents`);
1643
- }
1644
- }
1645
-
1646
- // Configure SessionStart hook for update checking (skip for opencode)
1647
- if (!isOpencode) {
1648
- if (!settings.hooks) {
1649
- settings.hooks = {};
1650
- }
1651
- if (!settings.hooks.SessionStart) {
1652
- settings.hooks.SessionStart = [];
1653
- }
1654
-
1655
- const hasPanUpdateHook = settings.hooks.SessionStart.some(entry =>
1656
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-check-update'))
1657
- );
1658
-
1659
- if (!hasPanUpdateHook) {
1660
- settings.hooks.SessionStart.push({
1661
- hooks: [
1662
- {
1663
- type: 'command',
1664
- command: updateCheckCommand
1665
- }
1666
- ]
1667
- });
1668
- console.log(` ${green}✓${reset} Configured update check hook`);
1669
- }
1670
-
1671
- // Configure PostToolUse hook for context window monitoring
1672
- if (!settings.hooks.PostToolUse) {
1673
- settings.hooks.PostToolUse = [];
1674
- }
1675
-
1676
- const hasContextMonitorHook = settings.hooks.PostToolUse.some(entry =>
1677
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-context-monitor'))
1678
- );
1679
-
1680
- if (!hasContextMonitorHook) {
1681
- settings.hooks.PostToolUse.push({
1682
- hooks: [
1683
- {
1684
- type: 'command',
1685
- command: contextMonitorCommand
1686
- }
1687
- ]
1688
- });
1689
- console.log(` ${green}✓${reset} Configured context window monitor hook`);
1690
- }
1691
- }
1692
-
1693
- return { settingsPath, settings, statuslineCommand, runtime };
1694
- }
1695
-
1696
- /**
1697
- * Apply statusline config, then print completion message
1698
- */
1699
- function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) {
1700
- const isOpencode = runtime === 'opencode';
1701
- const isCodex = runtime === 'codex';
1702
- const isCopilot = runtime === 'copilot';
1703
-
1704
- if (shouldInstallStatusline && !isOpencode && !isCodex) {
1705
- if (isCopilot) {
1706
- // Copilot CLI: statusline stored in config.json
1707
- settings.statusLine = {
1708
- command: statuslineCommand
1709
- };
1710
- } else {
1711
- settings.statusLine = {
1712
- type: 'command',
1713
- command: statuslineCommand
1714
- };
1715
- }
1716
- console.log(` ${green}✓${reset} Configured statusline`);
1717
- }
1718
-
1719
- // Write settings/config when runtime supports it
1720
- if (!isCodex) {
1721
- writeSettings(settingsPath, settings);
1722
- }
1723
-
1724
- // Configure OpenCode permissions
1725
- if (isOpencode) {
1726
- configureOpencodePermissions(isGlobal);
1727
- }
1728
-
1729
- let program = 'Claude Code';
1730
- if (runtime === 'opencode') program = 'OpenCode';
1731
- if (runtime === 'gemini') program = 'Gemini';
1732
- if (runtime === 'codex') program = 'Codex';
1733
- if (runtime === 'copilot') program = 'GitHub Copilot CLI';
1734
-
1735
- let command = '/pan:new-project';
1736
- if (runtime === 'opencode') command = '/pan-new-project';
1737
- if (runtime === 'codex') command = '$pan-new-project';
1738
- if (runtime === 'copilot') command = '/pan-new-project';
1739
- console.log(`
1740
- ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
1741
-
1742
- ${cyan}Join the community:${reset} https://discord.gg/pan-wizard
1743
- `);
1744
- }
1745
-
1746
- /**
1747
- * Handle statusline configuration with optional prompt
1748
- */
1749
- function handleStatusline(settings, isInteractive, callback) {
1750
- const hasExisting = settings.statusLine != null;
1751
-
1752
- if (!hasExisting) {
1753
- callback(true);
1754
- return;
1755
- }
1756
-
1757
- if (forceStatusline) {
1758
- callback(true);
1759
- return;
1760
- }
1761
-
1762
- if (!isInteractive) {
1763
- console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`);
1764
- console.log(` Use ${cyan}--force-statusline${reset} to replace\n`);
1765
- callback(false);
1766
- return;
1767
- }
1768
-
1769
- const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)';
1770
-
1771
- const rl = readline.createInterface({
1772
- input: process.stdin,
1773
- output: process.stdout
1774
- });
1775
-
1776
- console.log(`
1777
- ${yellow}⚠${reset} Existing statusline detected\n
1778
- Your current statusline:
1779
- ${dim}command: ${existingCmd}${reset}
1780
-
1781
- PAN includes a statusline showing:
1782
- Model name
1783
- Current task (from todo list)
1784
- • Context window usage (color-coded)
1785
-
1786
- ${cyan}1${reset}) Keep existing
1787
- ${cyan}2${reset}) Replace with PAN statusline
1788
- `);
1789
-
1790
- rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1791
- rl.close();
1792
- const choice = answer.trim() || '1';
1793
- callback(choice === '2');
1794
- });
1795
- }
1796
-
1797
- /**
1798
- * Prompt for runtime selection
1799
- */
1800
- function promptRuntime(callback) {
1801
- const rl = readline.createInterface({
1802
- input: process.stdin,
1803
- output: process.stdout
1804
- });
1805
-
1806
- let answered = false;
1807
-
1808
- rl.on('close', () => {
1809
- if (!answered) {
1810
- answered = true;
1811
- console.log(`\n ${yellow}Installation cancelled${reset}\n`);
1812
- process.exit(0);
1813
- }
1814
- });
1815
-
1816
- console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
1817
- ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models
1818
- ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset}
1819
- ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset}
1820
- ${cyan}5${reset}) Copilot CLI ${dim}(~/.copilot)${reset} - GitHub's terminal agent
1821
- ${cyan}6${reset}) All
1822
- `);
1823
-
1824
- rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1825
- answered = true;
1826
- rl.close();
1827
- const choice = answer.trim() || '1';
1828
- if (choice === '6') {
1829
- callback(['claude', 'opencode', 'gemini', 'codex', 'copilot']);
1830
- } else if (choice === '5') {
1831
- callback(['copilot']);
1832
- } else if (choice === '4') {
1833
- callback(['codex']);
1834
- } else if (choice === '3') {
1835
- callback(['gemini']);
1836
- } else if (choice === '2') {
1837
- callback(['opencode']);
1838
- } else {
1839
- callback(['claude']);
1840
- }
1841
- });
1842
- }
1843
-
1844
- /**
1845
- * Prompt for install location
1846
- */
1847
- function promptLocation(runtimes) {
1848
- if (!process.stdin.isTTY) {
1849
- console.log(` ${yellow}Non-interactive terminal detected, defaulting to local install${reset}\n`);
1850
- installAllRuntimes(runtimes, false, false);
1851
- return;
1852
- }
1853
-
1854
- const rl = readline.createInterface({
1855
- input: process.stdin,
1856
- output: process.stdout
1857
- });
1858
-
1859
- let answered = false;
1860
-
1861
- rl.on('close', () => {
1862
- if (!answered) {
1863
- answered = true;
1864
- console.log(`\n ${yellow}Installation cancelled${reset}\n`);
1865
- process.exit(0);
1866
- }
1867
- });
1868
-
1869
- const pathExamples = runtimes.map(r => {
1870
- const globalPath = getGlobalDir(r, explicitConfigDir);
1871
- return globalPath.replace(os.homedir(), '~');
1872
- }).join(', ');
1873
-
1874
- const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
1875
-
1876
- console.log(` ${yellow}Where would you like to install?${reset}\n\n ${cyan}1${reset}) Local ${dim}(${localExamples})${reset} - this project only
1877
- ${cyan}2${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
1878
- `);
1879
-
1880
- rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1881
- answered = true;
1882
- rl.close();
1883
- const choice = answer.trim() || '1';
1884
- const isGlobal = choice === '2';
1885
- installAllRuntimes(runtimes, isGlobal, true);
1886
- });
1887
- }
1888
-
1889
- /**
1890
- * Install PAN for all selected runtimes
1891
- */
1892
- function installAllRuntimes(runtimes, isGlobal, isInteractive) {
1893
- const results = [];
1894
-
1895
- for (const runtime of runtimes) {
1896
- const result = install(isGlobal, runtime);
1897
- results.push(result);
1898
- }
1899
-
1900
- const statuslineRuntimes = ['claude', 'gemini', 'copilot'];
1901
- const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
1902
-
1903
- const finalize = (shouldInstallStatusline) => {
1904
- for (const result of results) {
1905
- const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
1906
- finishInstall(
1907
- result.settingsPath,
1908
- result.settings,
1909
- result.statuslineCommand,
1910
- useStatusline,
1911
- result.runtime,
1912
- isGlobal
1913
- );
1914
- }
1915
- };
1916
-
1917
- if (primaryStatuslineResult) {
1918
- handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize);
1919
- } else {
1920
- finalize(false);
1921
- }
1922
- }
1923
-
1924
- // Main logic
1925
- if (hasGlobal && hasLocal) {
1926
- console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
1927
- process.exit(1);
1928
- } else if (explicitConfigDir && hasLocal) {
1929
- console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
1930
- process.exit(1);
1931
- } else if (hasUninstall) {
1932
- if (!hasGlobal && !hasLocal) {
1933
- console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
1934
- process.exit(1);
1935
- }
1936
- const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
1937
- for (const runtime of runtimes) {
1938
- uninstall(hasGlobal, runtime);
1939
- }
1940
- } else if (selectedRuntimes.length > 0) {
1941
- if (!hasGlobal && !hasLocal) {
1942
- promptLocation(selectedRuntimes);
1943
- } else {
1944
- installAllRuntimes(selectedRuntimes, hasGlobal, false);
1945
- }
1946
- } else if (hasGlobal || hasLocal) {
1947
- // Default to Claude if no runtime specified but location is
1948
- installAllRuntimes(['claude'], hasGlobal, false);
1949
- } else {
1950
- // Interactive
1951
- if (!process.stdin.isTTY) {
1952
- console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code local install${reset}\n`);
1953
- installAllRuntimes(['claude'], false, false);
1954
- } else {
1955
- promptRuntime((runtimes) => {
1956
- promptLocation(runtimes);
1957
- });
1958
- }
1959
- }
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const crypto = require('crypto');
8
+
9
+ // Extracted pure functions (testable independently)
10
+ const lib = require('./install-lib.cjs');
11
+ const {
12
+ colorNameToHex, claudeToOpencodeTools, claudeToGeminiTools, claudeToCopilotTools,
13
+ getDirName, getConfigDirFromHome, expandTilde, toSingleLine, yamlQuote, buildHookCommand,
14
+ extractFrontmatterAndBody, extractFrontmatterField,
15
+ convertToolName, convertGeminiToolName, convertCopilotToolName,
16
+ convertSlashCommandsToCodexSkillMentions, convertSlashCommandsToCopilotSkillMentions,
17
+ convertClaudeToCodexMarkdown, convertClaudeToCopilotMarkdown,
18
+ convertClaudeToOpencodeFrontmatter, convertClaudeToGeminiToml, convertClaudeToGeminiAgent,
19
+ rewriteAskUserQuestionForCopilot, stripSubTags,
20
+ getCodexSkillAdapterHeader, convertClaudeCommandToCodexSkill,
21
+ getCopilotSkillAdapterHeader, convertClaudeCommandToCopilotSkill, convertClaudeToCopilotAgent,
22
+ processAttribution, parseJsonc,
23
+ detectModelCapabilities, buildClaudeSkillShim, stripThinkingFrontmatter,
24
+ } = lib;
25
+
26
+ // Colors
27
+ const cyan = '\x1b[36m';
28
+ const green = '\x1b[32m';
29
+ const yellow = '\x1b[33m';
30
+ const red = '\x1b[31m';
31
+ const dim = '\x1b[2m';
32
+ const reset = '\x1b[0m';
33
+
34
+ // Get version from package.json
35
+ const pkg = require('../package.json');
36
+
37
+ // Source repo root — prevent installing PAN into its own source directory
38
+ const PAN_SOURCE_ROOT = path.resolve(__dirname, '..');
39
+ // Windows paths are case-insensitive; normalize for comparison
40
+ const normPath = p => process.platform === 'win32' ? p.toLowerCase() : p;
41
+
42
+ // Parse args
43
+ const args = process.argv.slice(2);
44
+ const hasGlobal = args.includes('--global') || args.includes('-g');
45
+ const hasLocal = args.includes('--local') || args.includes('-l');
46
+ const hasOpencode = args.includes('--opencode');
47
+ const hasClaude = args.includes('--claude');
48
+ const hasGemini = args.includes('--gemini');
49
+ const hasCodex = args.includes('--codex');
50
+ const hasCopilot = args.includes('--copilot');
51
+ const hasBoth = args.includes('--both'); // Legacy flag, keeps working
52
+ const hasAll = args.includes('--all');
53
+ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
54
+
55
+ // Runtime selection - can be set by flags or interactive prompt
56
+ let selectedRuntimes = [];
57
+ if (hasAll) {
58
+ selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot'];
59
+ } else if (hasBoth) {
60
+ selectedRuntimes = ['claude', 'opencode'];
61
+ } else {
62
+ if (hasOpencode) selectedRuntimes.push('opencode');
63
+ if (hasClaude) selectedRuntimes.push('claude');
64
+ if (hasGemini) selectedRuntimes.push('gemini');
65
+ if (hasCodex) selectedRuntimes.push('codex');
66
+ if (hasCopilot) selectedRuntimes.push('copilot');
67
+ }
68
+
69
+ /**
70
+ * Get the global config directory for OpenCode
71
+ * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
72
+ * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
73
+ */
74
+ function getOpencodeGlobalDir() {
75
+ // 1. Explicit OPENCODE_CONFIG_DIR env var
76
+ if (process.env.OPENCODE_CONFIG_DIR) {
77
+ return expandTilde(process.env.OPENCODE_CONFIG_DIR);
78
+ }
79
+
80
+ // 2. OPENCODE_CONFIG env var (use its directory)
81
+ if (process.env.OPENCODE_CONFIG) {
82
+ return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
83
+ }
84
+
85
+ // 3. XDG_CONFIG_HOME/opencode
86
+ if (process.env.XDG_CONFIG_HOME) {
87
+ return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
88
+ }
89
+
90
+ // 4. Default: ~/.config/opencode (XDG default)
91
+ return path.join(os.homedir(), '.config', 'opencode');
92
+ }
93
+
94
+ /**
95
+ * Get the global config directory for a runtime
96
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex'
97
+ * @param {string|null} explicitDir - Explicit directory from --config-dir flag
98
+ */
99
+ function getGlobalDir(runtime, explicitDir = null) {
100
+ if (runtime === 'opencode') {
101
+ // For OpenCode, --config-dir overrides env vars
102
+ if (explicitDir) {
103
+ return expandTilde(explicitDir);
104
+ }
105
+ return getOpencodeGlobalDir();
106
+ }
107
+
108
+ if (runtime === 'gemini') {
109
+ // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
110
+ if (explicitDir) {
111
+ return expandTilde(explicitDir);
112
+ }
113
+ if (process.env.GEMINI_CONFIG_DIR) {
114
+ return expandTilde(process.env.GEMINI_CONFIG_DIR);
115
+ }
116
+ return path.join(os.homedir(), '.gemini');
117
+ }
118
+
119
+ if (runtime === 'codex') {
120
+ // Codex: --config-dir > CODEX_HOME > ~/.codex
121
+ if (explicitDir) {
122
+ return expandTilde(explicitDir);
123
+ }
124
+ if (process.env.CODEX_HOME) {
125
+ return expandTilde(process.env.CODEX_HOME);
126
+ }
127
+ return path.join(os.homedir(), '.codex');
128
+ }
129
+
130
+ if (runtime === 'copilot') {
131
+ // Copilot CLI: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
132
+ if (explicitDir) {
133
+ return expandTilde(explicitDir);
134
+ }
135
+ if (process.env.COPILOT_CONFIG_DIR) {
136
+ return expandTilde(process.env.COPILOT_CONFIG_DIR);
137
+ }
138
+ return path.join(os.homedir(), '.copilot');
139
+ }
140
+
141
+ // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
142
+ if (explicitDir) {
143
+ return expandTilde(explicitDir);
144
+ }
145
+ if (process.env.CLAUDE_CONFIG_DIR) {
146
+ return expandTilde(process.env.CLAUDE_CONFIG_DIR);
147
+ }
148
+ return path.join(os.homedir(), '.claude');
149
+ }
150
+
151
+ const banner = '\n' +
152
+ cyan + ' ██████╗ █████╗ ███╗ ██╗\n' +
153
+ ' ██╔══██╗██╔══██╗████╗ ██║\n' +
154
+ ' ██████╔╝███████║██╔██╗ ██║\n' +
155
+ ' ██╔═══╝ ██╔══██║██║╚██╗██║\n' +
156
+ ' ██║ ██║ ██║██║ ╚████║\n' +
157
+ ' ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝' + reset + '\n' +
158
+ '\n' +
159
+ ' PAN Wizard ' + dim + 'v' + pkg.version + reset + '\n' +
160
+ ' A lightweight workflow automation and context engineering\n' +
161
+ ' system for Claude Code, OpenCode, Gemini, Codex, and Copilot CLI.\n';
162
+
163
+ // Parse --config-dir argument
164
+ function parseConfigDirArg() {
165
+ const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
166
+ if (configDirIndex !== -1) {
167
+ const nextArg = args[configDirIndex + 1];
168
+ // Error if --config-dir is provided without a value or next arg is another flag
169
+ if (!nextArg || nextArg.startsWith('-')) {
170
+ console.error(` ${yellow}--config-dir requires a path argument${reset}`);
171
+ process.exit(1);
172
+ }
173
+ return nextArg;
174
+ }
175
+ // Also handle --config-dir=value format
176
+ const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
177
+ if (configDirArg) {
178
+ const value = configDirArg.split('=')[1];
179
+ if (!value) {
180
+ console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
181
+ process.exit(1);
182
+ }
183
+ return value;
184
+ }
185
+ return null;
186
+ }
187
+ const explicitConfigDir = parseConfigDirArg();
188
+ const hasHelp = args.includes('--help') || args.includes('-h');
189
+ const forceStatusline = args.includes('--force-statusline');
190
+
191
+ console.log(banner);
192
+
193
+ // Show help if requested
194
+ if (hasHelp) {
195
+ console.log(` ${yellow}Usage:${reset} npx pan-wizard [options]\n\n ${yellow}Options:${reset}\n ${cyan}-l, --local${reset} Install locally to current directory (default)\n ${cyan}-g, --global${reset} Install globally to config directory\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall PAN (remove all PAN files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime; installs project-level)${reset}\n npx pan-wizard\n\n ${dim}# Install for Claude Code in current project (default, --local implied)${reset}\n npx pan-wizard --claude\n\n ${dim}# Install for all runtimes in current project${reset}\n npx pan-wizard --all --local\n\n ${dim}# Install globally (available in all projects)${reset}\n npx pan-wizard --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx pan-wizard --gemini --global\n\n ${dim}# Install to custom config directory${reset}\n npx pan-wizard --codex --global --config-dir ~/.codex-work\n\n ${dim}# Uninstall PAN from Codex globally${reset}\n npx pan-wizard --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n By default, PAN installs into the current project directory only.\n Use --global to install system-wide (writes to ~/.claude, ~/.gemini, etc.).\n The --config-dir option takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME.\n`);
196
+ process.exit(0);
197
+ }
198
+
199
+ /**
200
+ * Read and parse settings.json, returning empty object if it doesn't exist
201
+ */
202
+ function readSettings(settingsPath) {
203
+ try {
204
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
205
+ } catch {
206
+ return {};
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Write settings.json with proper formatting
212
+ */
213
+ function writeSettings(settingsPath, settings) {
214
+ try {
215
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
216
+ } catch (e) {
217
+ console.error(` ${yellow}⚠${reset} Failed to write settings: ${e.message}`);
218
+ }
219
+ }
220
+
221
+ // Cache for attribution settings (populated once per runtime during install)
222
+ const attributionCache = new Map();
223
+
224
+ /**
225
+ * Get commit attribution setting for a runtime
226
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex'
227
+ * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom
228
+ */
229
+ function getCommitAttribution(runtime) {
230
+ // Return cached value if available
231
+ if (attributionCache.has(runtime)) {
232
+ return attributionCache.get(runtime);
233
+ }
234
+
235
+ let result;
236
+
237
+ if (runtime === 'opencode') {
238
+ const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
239
+ result = config.disable_ai_attribution === true ? null : undefined;
240
+ } else if (runtime === 'gemini') {
241
+ // Gemini: check gemini settings.json for attribution config
242
+ const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
243
+ if (!settings.attribution || settings.attribution.commit === undefined) {
244
+ result = undefined;
245
+ } else if (settings.attribution.commit === '') {
246
+ result = null;
247
+ } else {
248
+ result = settings.attribution.commit;
249
+ }
250
+ } else if (runtime === 'claude') {
251
+ // Claude Code
252
+ const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
253
+ if (!settings.attribution || settings.attribution.commit === undefined) {
254
+ result = undefined;
255
+ } else if (settings.attribution.commit === '') {
256
+ result = null;
257
+ } else {
258
+ result = settings.attribution.commit;
259
+ }
260
+ } else if (runtime === 'copilot') {
261
+ // Copilot CLI: check config.json for attribution setting
262
+ const config = readSettings(path.join(getGlobalDir('copilot', explicitConfigDir), 'config.json'));
263
+ if (!config.attribution || config.attribution.commit === undefined) {
264
+ result = undefined;
265
+ } else if (config.attribution.commit === '') {
266
+ result = null;
267
+ } else {
268
+ result = config.attribution.commit;
269
+ }
270
+ } else {
271
+ // Codex currently has no attribution setting equivalent
272
+ result = undefined;
273
+ }
274
+
275
+ // Cache and return
276
+ attributionCache.set(runtime, result);
277
+ return result;
278
+ }
279
+
280
+ // processAttribution, parseJsonc, and all pure converter functions
281
+ // are provided by install-lib.cjs (see require at top of file).
282
+
283
+ /**
284
+ * Copy commands to a flat structure for OpenCode
285
+ * OpenCode expects: command/pan-help.md (invoked as /pan-help)
286
+ * Source structure: commands/pan/help.md
287
+ *
288
+ * @param {string} srcDir - Source directory (e.g., commands/pan/)
289
+ * @param {string} destDir - Destination directory (e.g., command/)
290
+ * @param {string} prefix - Prefix for filenames (e.g., 'pan')
291
+ * @param {string} pathPrefix - Path prefix for file references
292
+ * @param {string} runtime - Target runtime ('claude' or 'opencode')
293
+ */
294
+ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
295
+ if (!fs.existsSync(srcDir)) {
296
+ return;
297
+ }
298
+
299
+ // Remove old pan-*.md files before copying new ones
300
+ if (fs.existsSync(destDir)) {
301
+ for (const file of fs.readdirSync(destDir)) {
302
+ if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
303
+ try { fs.unlinkSync(path.join(destDir, file)); } catch {}
304
+ }
305
+ }
306
+ } else {
307
+ try { fs.mkdirSync(destDir, { recursive: true }); } catch {}
308
+ }
309
+
310
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
311
+
312
+ for (const entry of entries) {
313
+ const srcPath = path.join(srcDir, entry.name);
314
+
315
+ if (entry.isDirectory()) {
316
+ // Recurse into subdirectories, adding to prefix
317
+ // e.g., commands/pan/debug/start.md -> command/pan-debug-start.md
318
+ copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
319
+ } else if (entry.name.endsWith('.md')) {
320
+ // Flatten: help.md -> pan-help.md
321
+ const baseName = entry.name.replace('.md', '');
322
+ const destName = `${prefix}-${baseName}.md`;
323
+ const destPath = path.join(destDir, destName);
324
+
325
+ let content = fs.readFileSync(srcPath, 'utf8');
326
+ const globalClaudeRegex = /~\/\.claude\//g;
327
+ const localClaudeRegex = /\.\/\.claude\//g;
328
+ const opencodeDirRegex = /~\/\.opencode\//g;
329
+ content = content.replace(globalClaudeRegex, pathPrefix);
330
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
331
+ content = content.replace(opencodeDirRegex, pathPrefix);
332
+ content = processAttribution(content, getCommitAttribution(runtime));
333
+ content = convertClaudeToOpencodeFrontmatter(content);
334
+
335
+ fs.writeFileSync(destPath, content);
336
+ }
337
+ }
338
+ }
339
+
340
+ function listCodexSkillNames(skillsDir, prefix = 'pan-') {
341
+ if (!fs.existsSync(skillsDir)) return [];
342
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
343
+ return entries
344
+ .filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
345
+ .filter(entry => fs.existsSync(path.join(skillsDir, entry.name, 'SKILL.md')))
346
+ .map(entry => entry.name)
347
+ .sort();
348
+ }
349
+
350
+ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) {
351
+ if (!fs.existsSync(srcDir)) {
352
+ return;
353
+ }
354
+
355
+ try { fs.mkdirSync(skillsDir, { recursive: true }); } catch {}
356
+
357
+ // Remove previous PAN Codex skills to avoid stale command skills.
358
+ const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
359
+ for (const entry of existing) {
360
+ if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
361
+ try { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); } catch {}
362
+ }
363
+ }
364
+
365
+ function recurse(currentSrcDir, currentPrefix) {
366
+ const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
367
+
368
+ for (const entry of entries) {
369
+ const srcPath = path.join(currentSrcDir, entry.name);
370
+ if (entry.isDirectory()) {
371
+ recurse(srcPath, `${currentPrefix}-${entry.name}`);
372
+ continue;
373
+ }
374
+
375
+ if (!entry.name.endsWith('.md')) {
376
+ continue;
377
+ }
378
+
379
+ const baseName = entry.name.replace('.md', '');
380
+ const skillName = `${currentPrefix}-${baseName}`;
381
+ const skillDir = path.join(skillsDir, skillName);
382
+ fs.mkdirSync(skillDir, { recursive: true });
383
+
384
+ let content = fs.readFileSync(srcPath, 'utf8');
385
+ const globalClaudeRegex = /~\/\.claude\//g;
386
+ const localClaudeRegex = /\.\/\.claude\//g;
387
+ const codexDirRegex = /~\/\.codex\//g;
388
+ content = content.replace(globalClaudeRegex, pathPrefix);
389
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
390
+ content = content.replace(codexDirRegex, pathPrefix);
391
+ // Codex executes commands literally; no `pan-tools` bin on PATH.
392
+ const panToolsPath = `${pathPrefix}pan-wizard-core/bin/pan-tools.cjs`;
393
+ content = content.replace(/\bpan-tools\b(?=\s+[a-z])/g, `node ${panToolsPath}`);
394
+ content = processAttribution(content, getCommitAttribution(runtime));
395
+ content = convertClaudeCommandToCodexSkill(content, skillName);
396
+
397
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
398
+ }
399
+ }
400
+
401
+ recurse(srcDir, prefix);
402
+ }
403
+
404
+ /**
405
+ * Copy PAN commands as Copilot CLI skills.
406
+ * Creates skills/pan-{name}/SKILL.md directory structure.
407
+ * Similar to copyCommandsAsCodexSkills but uses Copilot CLI converters.
408
+ */
409
+ function copyCommandsAsCopilotSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) {
410
+ if (!fs.existsSync(srcDir)) {
411
+ return;
412
+ }
413
+
414
+ try { fs.mkdirSync(skillsDir, { recursive: true }); } catch {}
415
+
416
+ // Remove previous PAN Copilot skills to avoid stale command skills
417
+ const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
418
+ for (const entry of existing) {
419
+ if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
420
+ try { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); } catch {}
421
+ }
422
+ }
423
+
424
+ function recurse(currentSrcDir, currentPrefix) {
425
+ const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
426
+
427
+ for (const entry of entries) {
428
+ const srcPath = path.join(currentSrcDir, entry.name);
429
+ if (entry.isDirectory()) {
430
+ recurse(srcPath, `${currentPrefix}-${entry.name}`);
431
+ continue;
432
+ }
433
+
434
+ if (!entry.name.endsWith('.md')) {
435
+ continue;
436
+ }
437
+
438
+ const baseName = entry.name.replace('.md', '');
439
+ const skillName = `${currentPrefix}-${baseName}`;
440
+ const skillDir = path.join(skillsDir, skillName);
441
+ fs.mkdirSync(skillDir, { recursive: true });
442
+
443
+ let content = fs.readFileSync(srcPath, 'utf8');
444
+ const globalClaudeRegex = /~\/\.claude\//g;
445
+ const localClaudeRegex = /\.\/\.claude\//g;
446
+ content = content.replace(globalClaudeRegex, pathPrefix);
447
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
448
+ // Copilot CLI executes commands literally; there's no `pan-tools` bin on PATH.
449
+ // Replace bare `pan-tools` invocations with the explicit node + .cjs path.
450
+ const panToolsPath = `${pathPrefix}pan-wizard-core/bin/pan-tools.cjs`;
451
+ content = content.replace(/\bpan-tools\b(?=\s+[a-z])/g, `node ${panToolsPath}`);
452
+ content = processAttribution(content, getCommitAttribution(runtime));
453
+ content = convertClaudeCommandToCopilotSkill(content, skillName);
454
+
455
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
456
+ }
457
+ }
458
+
459
+ recurse(srcDir, prefix);
460
+ }
461
+
462
+ /**
463
+ * Recursively copy directory, replacing paths in .md files
464
+ * Deletes existing destDir first to remove orphaned files from previous versions
465
+ * @param {string} srcDir - Source directory
466
+ * @param {string} destDir - Destination directory
467
+ * @param {string} pathPrefix - Path prefix for file references
468
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot')
469
+ */
470
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) {
471
+ const isOpencode = runtime === 'opencode';
472
+ const isCodex = runtime === 'codex';
473
+ const dirName = getDirName(runtime);
474
+
475
+ // Clean install: remove existing destination to prevent orphaned files
476
+ try {
477
+ if (fs.existsSync(destDir)) {
478
+ fs.rmSync(destDir, { recursive: true });
479
+ }
480
+ fs.mkdirSync(destDir, { recursive: true });
481
+ } catch {}
482
+
483
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
484
+
485
+ for (const entry of entries) {
486
+ const srcPath = path.join(srcDir, entry.name);
487
+ const destPath = path.join(destDir, entry.name);
488
+
489
+ if (entry.isDirectory()) {
490
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand);
491
+ } else if (entry.name.endsWith('.md')) {
492
+ try {
493
+ // Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths
494
+ let content = fs.readFileSync(srcPath, 'utf8');
495
+ const globalClaudeRegex = /~\/\.claude\//g;
496
+ const localClaudeRegex = /\.\/\.claude\//g;
497
+ content = content.replace(globalClaudeRegex, pathPrefix);
498
+ content = content.replace(localClaudeRegex, `./${dirName}/`);
499
+ content = processAttribution(content, getCommitAttribution(runtime));
500
+
501
+ // Convert frontmatter for opencode compatibility
502
+ if (isOpencode) {
503
+ content = convertClaudeToOpencodeFrontmatter(content);
504
+ fs.writeFileSync(destPath, content);
505
+ } else if (runtime === 'gemini') {
506
+ if (isCommand) {
507
+ // Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
508
+ content = stripSubTags(content);
509
+ const tomlContent = convertClaudeToGeminiToml(content);
510
+ // Replace extension with .toml
511
+ const tomlPath = destPath.replace(/\.md$/, '.toml');
512
+ fs.writeFileSync(tomlPath, tomlContent);
513
+ } else {
514
+ fs.writeFileSync(destPath, content);
515
+ }
516
+ } else if (isCodex) {
517
+ content = convertClaudeToCodexMarkdown(content);
518
+ fs.writeFileSync(destPath, content);
519
+ } else if (runtime === 'copilot') {
520
+ content = convertClaudeToCopilotMarkdown(content);
521
+ fs.writeFileSync(destPath, content);
522
+ } else {
523
+ fs.writeFileSync(destPath, content);
524
+ }
525
+ } catch {}
526
+ } else {
527
+ try { fs.copyFileSync(srcPath, destPath); } catch {}
528
+ }
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Clean up orphaned files from previous PAN versions
534
+ */
535
+ function cleanupOrphanedFiles(configDir) {
536
+ const orphanedFiles = [
537
+ 'hooks/pan-notify.sh', // Removed in v1.6.x
538
+ 'hooks/statusline.js', // Renamed to pan-statusline.js in v1.9.0
539
+ ];
540
+
541
+ for (const relPath of orphanedFiles) {
542
+ const fullPath = path.join(configDir, relPath);
543
+ if (fs.existsSync(fullPath)) {
544
+ fs.unlinkSync(fullPath);
545
+ console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
546
+ }
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Clean up orphaned hook registrations from settings.json
552
+ */
553
+ function cleanupOrphanedHooks(settings) {
554
+ const orphanedHookPatterns = [
555
+ 'pan-notify.sh', // Removed in v1.6.x
556
+ 'hooks/statusline.js', // Renamed to pan-statusline.js in v1.9.0
557
+ 'pan-intel-index.js', // Removed in v1.9.2
558
+ 'pan-intel-session.js', // Removed in v1.9.2
559
+ 'pan-intel-prune.js', // Removed in v1.9.2
560
+ ];
561
+
562
+ let cleanedHooks = false;
563
+
564
+ // Check all hook event types (Stop, SessionStart, etc.)
565
+ if (settings.hooks) {
566
+ for (const eventType of Object.keys(settings.hooks)) {
567
+ const hookEntries = settings.hooks[eventType];
568
+ if (Array.isArray(hookEntries)) {
569
+ // Filter out entries that contain orphaned hooks
570
+ const filtered = hookEntries.filter(entry => {
571
+ if (entry.hooks && Array.isArray(entry.hooks)) {
572
+ // Check if any hook in this entry matches orphaned patterns
573
+ const hasOrphaned = entry.hooks.some(h =>
574
+ h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
575
+ );
576
+ if (hasOrphaned) {
577
+ cleanedHooks = true;
578
+ return false; // Remove this entry
579
+ }
580
+ }
581
+ return true; // Keep this entry
582
+ });
583
+ settings.hooks[eventType] = filtered;
584
+ }
585
+ }
586
+ }
587
+
588
+ if (cleanedHooks) {
589
+ console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
590
+ }
591
+
592
+ // Fix #330: Update statusLine if it points to old statusline.js path
593
+ if (settings.statusLine && settings.statusLine.command &&
594
+ settings.statusLine.command.includes('statusline.js') &&
595
+ !settings.statusLine.command.includes('pan-statusline.js')) {
596
+ // Replace old path with new path
597
+ settings.statusLine.command = settings.statusLine.command.replace(
598
+ /statusline\.js/,
599
+ 'pan-statusline.js'
600
+ );
601
+ console.log(` ${green}✓${reset} Updated statusline path (statusline.js pan-statusline.js)`);
602
+ }
603
+
604
+ return settings;
605
+ }
606
+
607
+ /**
608
+ * Uninstall PAN from the specified directory for a specific runtime
609
+ * Removes only PAN-specific files/directories, preserves user content
610
+ * @param {boolean} isGlobal - Whether to uninstall from global or local
611
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
612
+ */
613
+ function uninstall(isGlobal, runtime = 'claude') {
614
+ const isOpencode = runtime === 'opencode';
615
+ const isCodex = runtime === 'codex';
616
+ const isCopilot = runtime === 'copilot';
617
+ const dirName = getDirName(runtime);
618
+
619
+ // Get the target directory based on runtime and install type
620
+ const targetDir = isGlobal
621
+ ? getGlobalDir(runtime, explicitConfigDir)
622
+ : path.join(process.cwd(), dirName);
623
+
624
+ const locationLabel = isGlobal
625
+ ? targetDir.replace(os.homedir(), '~')
626
+ : targetDir.replace(process.cwd(), '.');
627
+
628
+ let runtimeLabel = 'Claude Code';
629
+ if (runtime === 'opencode') runtimeLabel = 'OpenCode';
630
+ if (runtime === 'gemini') runtimeLabel = 'Gemini';
631
+ if (runtime === 'codex') runtimeLabel = 'Codex';
632
+ if (runtime === 'copilot') runtimeLabel = 'GitHub Copilot CLI';
633
+
634
+ // Guard: never uninstall from the PAN source repository itself
635
+ if (normPath(path.resolve(process.cwd())) === normPath(PAN_SOURCE_ROOT)) {
636
+ console.error(`\n ${red}✗${reset} Refusing to uninstall from PAN's own source repository.`);
637
+ console.error(` Run from your target project directory instead.\n`);
638
+ process.exit(1);
639
+ }
640
+
641
+ console.log(` Uninstalling PAN from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
642
+
643
+ // Check if target directory exists
644
+ if (!fs.existsSync(targetDir)) {
645
+ console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
646
+ console.log(` Nothing to uninstall.\n`);
647
+ return;
648
+ }
649
+
650
+ let removedCount = 0;
651
+
652
+ // 1. Remove PAN commands/skills
653
+ if (isOpencode) {
654
+ // OpenCode: remove command/pan-*.md files
655
+ const commandDir = path.join(targetDir, 'command');
656
+ if (fs.existsSync(commandDir)) {
657
+ const files = fs.readdirSync(commandDir);
658
+ for (const file of files) {
659
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
660
+ try { fs.unlinkSync(path.join(commandDir, file)); } catch {}
661
+ removedCount++;
662
+ }
663
+ }
664
+ console.log(` ${green}✓${reset} Removed PAN commands from command/`);
665
+ }
666
+ } else if (isCodex || isCopilot) {
667
+ // Codex & Copilot CLI: remove skills/pan-*/SKILL.md skill directories
668
+ const skillsDir = path.join(targetDir, 'skills');
669
+ if (fs.existsSync(skillsDir)) {
670
+ let skillCount = 0;
671
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
672
+ for (const entry of entries) {
673
+ if (entry.isDirectory() && entry.name.startsWith('pan-')) {
674
+ try { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); } catch {}
675
+ skillCount++;
676
+ }
677
+ }
678
+ if (skillCount > 0) {
679
+ removedCount++;
680
+ console.log(` ${green}✓${reset} Removed ${skillCount} ${isCopilot ? 'Copilot CLI' : 'Codex'} skills`);
681
+ }
682
+ }
683
+ } else {
684
+ // Claude Code & Gemini: remove commands/pan/ directory
685
+ const panCommandsDir = path.join(targetDir, 'commands', 'pan');
686
+ if (fs.existsSync(panCommandsDir)) {
687
+ try { fs.rmSync(panCommandsDir, { recursive: true }); } catch {}
688
+ removedCount++;
689
+ console.log(` ${green}✓${reset} Removed commands/pan/`);
690
+ }
691
+
692
+ // Claude-only: remove skills/pan-*.md shim files (registered at install time)
693
+ if (runtime === 'claude') {
694
+ const skillsDir = path.join(targetDir, 'skills');
695
+ if (fs.existsSync(skillsDir)) {
696
+ let skillCount = 0;
697
+ for (const file of fs.readdirSync(skillsDir)) {
698
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
699
+ try { fs.unlinkSync(path.join(skillsDir, file)); } catch {}
700
+ skillCount++;
701
+ }
702
+ }
703
+ if (skillCount > 0) {
704
+ removedCount++;
705
+ console.log(` ${green}✓${reset} Removed ${skillCount} PAN skill shims`);
706
+ }
707
+ // Remove the skills/ dir only if it's now empty (user may have non-PAN skills)
708
+ try {
709
+ if (fs.readdirSync(skillsDir).length === 0) fs.rmdirSync(skillsDir);
710
+ } catch {}
711
+ }
712
+ }
713
+ }
714
+
715
+ // 2. Remove pan-wizard-core directory
716
+ const panDir = path.join(targetDir, 'pan-wizard-core');
717
+ if (fs.existsSync(panDir)) {
718
+ try { fs.rmSync(panDir, { recursive: true }); } catch {}
719
+ removedCount++;
720
+ console.log(` ${green}✓${reset} Removed pan-wizard-core/`);
721
+ }
722
+
723
+ // 3. Remove PAN agents (pan-*.md files only)
724
+ const agentsDir = path.join(targetDir, 'agents');
725
+ if (fs.existsSync(agentsDir)) {
726
+ const files = fs.readdirSync(agentsDir);
727
+ let agentCount = 0;
728
+ for (const file of files) {
729
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
730
+ try { fs.unlinkSync(path.join(agentsDir, file)); } catch {}
731
+ agentCount++;
732
+ }
733
+ }
734
+ if (agentCount > 0) {
735
+ removedCount++;
736
+ console.log(` ${green}✓${reset} Removed ${agentCount} PAN agents`);
737
+ }
738
+ }
739
+
740
+ // 4. Remove PAN hooks
741
+ const hooksDir = path.join(targetDir, 'hooks');
742
+ if (fs.existsSync(hooksDir)) {
743
+ const panHooks = ['pan-statusline.js', 'pan-check-update.js', 'pan-check-update.sh', 'pan-context-monitor.js', 'pan-cost-logger.js', 'pan-trace-logger.js'];
744
+ let hookCount = 0;
745
+ for (const hook of panHooks) {
746
+ const hookPath = path.join(hooksDir, hook);
747
+ if (fs.existsSync(hookPath)) {
748
+ try { fs.unlinkSync(hookPath); } catch {}
749
+ hookCount++;
750
+ }
751
+ }
752
+ if (hookCount > 0) {
753
+ removedCount++;
754
+ console.log(` ${green}✓${reset} Removed ${hookCount} PAN hooks`);
755
+ }
756
+ }
757
+
758
+ // 5. Remove PAN package.json (CommonJS mode marker)
759
+ const pkgJsonPath = path.join(targetDir, 'package.json');
760
+ if (fs.existsSync(pkgJsonPath)) {
761
+ try {
762
+ const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
763
+ // Only remove if it's our minimal CommonJS marker (handle formatting variations)
764
+ const normalized = content.replace(/\s+/g, '');
765
+ if (normalized === '{"type":"commonjs"}') {
766
+ fs.unlinkSync(pkgJsonPath);
767
+ removedCount++;
768
+ console.log(` ${green}✓${reset} Removed PAN package.json`);
769
+ }
770
+ } catch (e) {
771
+ // Ignore read errors
772
+ }
773
+ }
774
+
775
+ // 6a. Clean up Copilot CLI config.json
776
+ if (isCopilot) {
777
+ const configPath = path.join(targetDir, 'config.json');
778
+ if (fs.existsSync(configPath)) {
779
+ try {
780
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
781
+ let configModified = false;
782
+
783
+ // Remove PAN statusline
784
+ if (config.statusLine && config.statusLine.command &&
785
+ config.statusLine.command.includes('pan-statusline')) {
786
+ delete config.statusLine;
787
+ configModified = true;
788
+ console.log(` ${green}✓${reset} Removed PAN statusline from config`);
789
+ }
790
+
791
+ // Remove PAN hooks from sessionStart
792
+ if (config.hooks && config.hooks.sessionStart) {
793
+ const before = config.hooks.sessionStart.length;
794
+ config.hooks.sessionStart = config.hooks.sessionStart.filter(h =>
795
+ !(h.command && h.command.includes('pan-check-update'))
796
+ );
797
+ if (config.hooks.sessionStart.length < before) {
798
+ configModified = true;
799
+ console.log(` ${green}✓${reset} Removed PAN hooks from config`);
800
+ }
801
+ if (config.hooks.sessionStart.length === 0) {
802
+ delete config.hooks.sessionStart;
803
+ }
804
+ }
805
+
806
+ // Remove PAN hooks from postToolUse
807
+ if (config.hooks && config.hooks.postToolUse) {
808
+ const before = config.hooks.postToolUse.length;
809
+ config.hooks.postToolUse = config.hooks.postToolUse.filter(h =>
810
+ !(h.command && h.command.includes('pan-context-monitor'))
811
+ );
812
+ if (config.hooks.postToolUse.length < before) {
813
+ configModified = true;
814
+ console.log(` ${green}✓${reset} Removed context monitor from config`);
815
+ }
816
+ if (config.hooks.postToolUse.length === 0) {
817
+ delete config.hooks.postToolUse;
818
+ }
819
+ }
820
+
821
+ if (config.hooks && Object.keys(config.hooks).length === 0) {
822
+ delete config.hooks;
823
+ }
824
+
825
+ if (configModified) {
826
+ if (Object.keys(config).length === 0) {
827
+ fs.unlinkSync(configPath);
828
+ console.log(` ${green}✓${reset} Removed empty config.json`);
829
+ } else {
830
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
831
+ }
832
+ removedCount++;
833
+ }
834
+ } catch (e) {
835
+ // Ignore JSON parse errors
836
+ }
837
+ }
838
+ }
839
+
840
+ // 6b. Clean up settings.json (remove PAN hooks and statusline)
841
+ const settingsPath = path.join(targetDir, 'settings.json');
842
+ if (fs.existsSync(settingsPath)) {
843
+ let settings = readSettings(settingsPath);
844
+ let settingsModified = false;
845
+
846
+ // Remove PAN statusline if it references our hook
847
+ if (settings.statusLine && settings.statusLine.command &&
848
+ settings.statusLine.command.includes('pan-statusline')) {
849
+ delete settings.statusLine;
850
+ settingsModified = true;
851
+ console.log(` ${green}✓${reset} Removed PAN statusline from settings`);
852
+ }
853
+
854
+ // Remove PAN hooks from SessionStart
855
+ if (settings.hooks && settings.hooks.SessionStart) {
856
+ const before = settings.hooks.SessionStart.length;
857
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => {
858
+ if (entry.hooks && Array.isArray(entry.hooks)) {
859
+ // Filter out PAN hooks
860
+ const hasPanHook = entry.hooks.some(h =>
861
+ h.command && (h.command.includes('pan-check-update') || h.command.includes('pan-statusline'))
862
+ );
863
+ return !hasPanHook;
864
+ }
865
+ return true;
866
+ });
867
+ if (settings.hooks.SessionStart.length < before) {
868
+ settingsModified = true;
869
+ console.log(` ${green}✓${reset} Removed PAN hooks from settings`);
870
+ }
871
+ // Clean up empty array
872
+ if (settings.hooks.SessionStart.length === 0) {
873
+ delete settings.hooks.SessionStart;
874
+ }
875
+ }
876
+
877
+ // Remove PAN hooks from PostToolUse
878
+ if (settings.hooks && settings.hooks.PostToolUse) {
879
+ const before = settings.hooks.PostToolUse.length;
880
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
881
+ if (entry.hooks && Array.isArray(entry.hooks)) {
882
+ const hasPanHook = entry.hooks.some(h =>
883
+ h.command && h.command.includes('pan-context-monitor')
884
+ );
885
+ return !hasPanHook;
886
+ }
887
+ return true;
888
+ });
889
+ if (settings.hooks.PostToolUse.length < before) {
890
+ settingsModified = true;
891
+ console.log(` ${green}✓${reset} Removed context monitor hook from settings`);
892
+ }
893
+ if (settings.hooks.PostToolUse.length === 0) {
894
+ delete settings.hooks.PostToolUse;
895
+ }
896
+ }
897
+
898
+ // Clean up empty hooks object
899
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
900
+ delete settings.hooks;
901
+ }
902
+
903
+ // Remove Gemini experimental.enableAgents if PAN set it
904
+ if (settings.experimental && settings.experimental.enableAgents === true) {
905
+ delete settings.experimental.enableAgents;
906
+ if (Object.keys(settings.experimental).length === 0) {
907
+ delete settings.experimental;
908
+ }
909
+ settingsModified = true;
910
+ console.log(` ${green}✓${reset} Removed experimental agents flag from settings`);
911
+ }
912
+
913
+ if (settingsModified) {
914
+ // If settings is now empty (only PAN entries existed), remove the file
915
+ if (Object.keys(settings).length === 0) {
916
+ fs.unlinkSync(settingsPath);
917
+ console.log(` ${green}✓${reset} Removed empty settings.json`);
918
+ } else {
919
+ writeSettings(settingsPath, settings);
920
+ }
921
+ removedCount++;
922
+ }
923
+ }
924
+
925
+ // 6. For OpenCode, clean up permissions from opencode.json
926
+ if (isOpencode) {
927
+ // For local uninstalls, clean up ./.opencode/opencode.json
928
+ // For global uninstalls, clean up ~/.config/opencode/opencode.json
929
+ const opencodeConfigDir = isGlobal
930
+ ? getOpencodeGlobalDir()
931
+ : path.join(process.cwd(), '.opencode');
932
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
933
+ if (fs.existsSync(configPath)) {
934
+ try {
935
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
936
+ let modified = false;
937
+
938
+ // Remove PAN permission entries
939
+ if (config.permission) {
940
+ for (const permType of ['read', 'external_directory']) {
941
+ if (config.permission[permType]) {
942
+ const keys = Object.keys(config.permission[permType]);
943
+ for (const key of keys) {
944
+ if (key.includes('pan-wizard-core')) {
945
+ delete config.permission[permType][key];
946
+ modified = true;
947
+ }
948
+ }
949
+ // Clean up empty objects
950
+ if (Object.keys(config.permission[permType]).length === 0) {
951
+ delete config.permission[permType];
952
+ }
953
+ }
954
+ }
955
+ if (Object.keys(config.permission).length === 0) {
956
+ delete config.permission;
957
+ }
958
+ }
959
+
960
+ if (modified) {
961
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
962
+ removedCount++;
963
+ console.log(` ${green}✓${reset} Removed PAN permissions from opencode.json`);
964
+ }
965
+ } catch (e) {
966
+ // Ignore JSON parse errors
967
+ }
968
+ }
969
+ }
970
+
971
+ // 7. Remove pan-file-manifest.json
972
+ const manifestPath = path.join(targetDir, MANIFEST_NAME);
973
+ if (fs.existsSync(manifestPath)) {
974
+ try { fs.unlinkSync(manifestPath); } catch {}
975
+ removedCount++;
976
+ console.log(` ${green}✓${reset} Removed ${MANIFEST_NAME}`);
977
+ }
978
+
979
+ // 8. Clean up empty PAN directories
980
+ const dirsToClean = [
981
+ path.join(targetDir, 'agents'),
982
+ path.join(targetDir, 'hooks'),
983
+ path.join(targetDir, 'skills'),
984
+ path.join(targetDir, 'commands', 'pan'),
985
+ path.join(targetDir, 'commands'),
986
+ ];
987
+ for (const dir of dirsToClean) {
988
+ try {
989
+ if (fs.existsSync(dir)) {
990
+ const remaining = fs.readdirSync(dir);
991
+ if (remaining.length === 0) {
992
+ fs.rmdirSync(dir);
993
+ }
994
+ }
995
+ } catch {}
996
+ }
997
+
998
+ if (removedCount === 0) {
999
+ console.log(` ${yellow}⚠${reset} No PAN files found to remove.`);
1000
+ }
1001
+
1002
+ console.log(`
1003
+ ${green}Done!${reset} PAN has been uninstalled from ${runtimeLabel}.
1004
+ Your other files and settings have been preserved.
1005
+ `);
1006
+ }
1007
+
1008
+
1009
+ /**
1010
+ * Configure OpenCode permissions to allow reading PAN reference docs
1011
+ * This prevents permission prompts when PAN accesses the pan-wizard-core directory
1012
+ * @param {boolean} isGlobal - Whether this is a global or local install
1013
+ */
1014
+ function configureOpencodePermissions(isGlobal = true) {
1015
+ // For local installs, use ./.opencode/opencode.json
1016
+ // For global installs, use ~/.config/opencode/opencode.json
1017
+ const opencodeConfigDir = isGlobal
1018
+ ? getOpencodeGlobalDir()
1019
+ : path.join(process.cwd(), '.opencode');
1020
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1021
+
1022
+ // Ensure config directory exists
1023
+ fs.mkdirSync(opencodeConfigDir, { recursive: true });
1024
+
1025
+ // Read existing config or create empty object
1026
+ let config = {};
1027
+ if (fs.existsSync(configPath)) {
1028
+ try {
1029
+ const content = fs.readFileSync(configPath, 'utf8');
1030
+ config = parseJsonc(content);
1031
+ } catch (e) {
1032
+ // Cannot parse - DO NOT overwrite user's config
1033
+ console.log(` ${yellow}⚠${reset} Could not parse opencode.json - skipping permission config`);
1034
+ console.log(` ${dim}Reason: ${e.message}${reset}`);
1035
+ console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
1036
+ return;
1037
+ }
1038
+ }
1039
+
1040
+ // Ensure permission structure exists
1041
+ if (!config.permission) {
1042
+ config.permission = {};
1043
+ }
1044
+
1045
+ // Build the PAN path using the actual config directory
1046
+ // Use ~ shorthand if it's in the default location, otherwise use full path
1047
+ const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
1048
+ const panPath = opencodeConfigDir === defaultConfigDir
1049
+ ? '~/.config/opencode/pan-wizard/*'
1050
+ : `${opencodeConfigDir.replace(/\\/g, '/')}/pan-wizard-core/*`;
1051
+
1052
+ let modified = false;
1053
+
1054
+ // Configure read permission
1055
+ if (!config.permission.read || typeof config.permission.read !== 'object') {
1056
+ config.permission.read = {};
1057
+ }
1058
+ if (config.permission.read[panPath] !== 'allow') {
1059
+ config.permission.read[panPath] = 'allow';
1060
+ modified = true;
1061
+ }
1062
+
1063
+ // Configure external_directory permission (the safety guard for paths outside project)
1064
+ if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
1065
+ config.permission.external_directory = {};
1066
+ }
1067
+ if (config.permission.external_directory[panPath] !== 'allow') {
1068
+ config.permission.external_directory[panPath] = 'allow';
1069
+ modified = true;
1070
+ }
1071
+
1072
+ if (!modified) {
1073
+ return; // Already configured
1074
+ }
1075
+
1076
+ // Write config back
1077
+ try {
1078
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1079
+ console.log(` ${green}✓${reset} Configured read permission for PAN docs`);
1080
+ } catch (e) {
1081
+ console.error(` ${yellow}⚠${reset} Failed to write config: ${e.message}`);
1082
+ }
1083
+ }
1084
+
1085
+ /**
1086
+ * Verify a directory exists and contains files
1087
+ */
1088
+ function verifyInstalled(dirPath, description) {
1089
+ if (!fs.existsSync(dirPath)) {
1090
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
1091
+ return false;
1092
+ }
1093
+ try {
1094
+ const entries = fs.readdirSync(dirPath);
1095
+ if (entries.length === 0) {
1096
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
1097
+ return false;
1098
+ }
1099
+ } catch (e) {
1100
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
1101
+ return false;
1102
+ }
1103
+ return true;
1104
+ }
1105
+
1106
+ /**
1107
+ * Verify a file exists
1108
+ */
1109
+ function verifyFileInstalled(filePath, description) {
1110
+ if (!fs.existsSync(filePath)) {
1111
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
1112
+ return false;
1113
+ }
1114
+ return true;
1115
+ }
1116
+
1117
+ /**
1118
+ * Install to the specified directory for a specific runtime
1119
+ * @param {boolean} isGlobal - Whether to install globally or locally
1120
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
1121
+ */
1122
+
1123
+ // ──────────────────────────────────────────────────────
1124
+ // Local Patch Persistence
1125
+ // ──────────────────────────────────────────────────────
1126
+
1127
+ const PATCHES_DIR_NAME = 'pan-local-patches';
1128
+ const MANIFEST_NAME = 'pan-file-manifest.json';
1129
+
1130
+ /**
1131
+ * Compute SHA256 hash of file contents
1132
+ */
1133
+ function fileHash(filePath) {
1134
+ try {
1135
+ const content = fs.readFileSync(filePath);
1136
+ return crypto.createHash('sha256').update(content).digest('hex');
1137
+ } catch (e) {
1138
+ return null;
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Recursively collect all files in dir with their hashes
1144
+ */
1145
+ function generateManifest(dir, baseDir) {
1146
+ if (!baseDir) baseDir = dir;
1147
+ const manifest = {};
1148
+ if (!fs.existsSync(dir)) return manifest;
1149
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1150
+ for (const entry of entries) {
1151
+ const fullPath = path.join(dir, entry.name);
1152
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
1153
+ if (entry.isDirectory()) {
1154
+ Object.assign(manifest, generateManifest(fullPath, baseDir));
1155
+ } else {
1156
+ manifest[relPath] = fileHash(fullPath);
1157
+ }
1158
+ }
1159
+ return manifest;
1160
+ }
1161
+
1162
+ /**
1163
+ * Write file manifest after installation for future modification detection
1164
+ */
1165
+ function writeManifest(configDir, runtime = 'claude') {
1166
+ const isOpencode = runtime === 'opencode';
1167
+ const isCodex = runtime === 'codex';
1168
+ const isCopilot = runtime === 'copilot';
1169
+ const panDir = path.join(configDir, 'pan-wizard-core');
1170
+ const commandsDir = path.join(configDir, 'commands', 'pan');
1171
+ const opencodeCommandDir = path.join(configDir, 'command');
1172
+ const codexSkillsDir = path.join(configDir, 'skills');
1173
+ const agentsDir = path.join(configDir, 'agents');
1174
+ const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} };
1175
+
1176
+ const panHashes = generateManifest(panDir);
1177
+ for (const [rel, hash] of Object.entries(panHashes)) {
1178
+ manifest.files['pan-wizard-core/' + rel] = hash;
1179
+ }
1180
+ if (!isOpencode && !isCodex && fs.existsSync(commandsDir)) {
1181
+ const cmdHashes = generateManifest(commandsDir);
1182
+ for (const [rel, hash] of Object.entries(cmdHashes)) {
1183
+ manifest.files['commands/pan/' + rel] = hash;
1184
+ }
1185
+ }
1186
+ if (isOpencode && fs.existsSync(opencodeCommandDir)) {
1187
+ for (const file of fs.readdirSync(opencodeCommandDir)) {
1188
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
1189
+ manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
1190
+ }
1191
+ }
1192
+ }
1193
+ if ((isCodex || isCopilot) && fs.existsSync(codexSkillsDir)) {
1194
+ for (const skillName of listCodexSkillNames(codexSkillsDir)) {
1195
+ const skillRoot = path.join(codexSkillsDir, skillName);
1196
+ const skillHashes = generateManifest(skillRoot);
1197
+ for (const [rel, hash] of Object.entries(skillHashes)) {
1198
+ manifest.files[`skills/${skillName}/${rel}`] = hash;
1199
+ }
1200
+ }
1201
+ }
1202
+ if (fs.existsSync(agentsDir)) {
1203
+ for (const file of fs.readdirSync(agentsDir)) {
1204
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
1205
+ manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
1206
+ }
1207
+ }
1208
+ }
1209
+ // Track hook scripts in manifest for modification detection during upgrades
1210
+ const hooksDir = path.join(configDir, 'hooks');
1211
+ if (fs.existsSync(hooksDir)) {
1212
+ for (const file of fs.readdirSync(hooksDir)) {
1213
+ if (file.startsWith('pan-') && file.endsWith('.js')) {
1214
+ manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ try {
1220
+ fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
1221
+ } catch (e) {
1222
+ console.error(` ${yellow}⚠${reset} Failed to write manifest: ${e.message}`);
1223
+ }
1224
+ return manifest;
1225
+ }
1226
+
1227
+ /**
1228
+ * Detect user-modified PAN files by comparing against install manifest.
1229
+ * Backs up modified files to pan-local-patches/ for reapply after update.
1230
+ */
1231
+ function saveLocalPatches(configDir) {
1232
+ const manifestPath = path.join(configDir, MANIFEST_NAME);
1233
+ if (!fs.existsSync(manifestPath)) return [];
1234
+
1235
+ let manifest;
1236
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
1237
+
1238
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1239
+ const modified = [];
1240
+
1241
+ for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
1242
+ const fullPath = path.join(configDir, relPath);
1243
+ if (!fs.existsSync(fullPath)) continue;
1244
+ const currentHash = fileHash(fullPath);
1245
+ if (currentHash !== originalHash) {
1246
+ const backupPath = path.join(patchesDir, relPath);
1247
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1248
+ fs.copyFileSync(fullPath, backupPath);
1249
+ modified.push(relPath);
1250
+ }
1251
+ }
1252
+
1253
+ if (modified.length > 0) {
1254
+ const meta = {
1255
+ backed_up_at: new Date().toISOString(),
1256
+ from_version: manifest.version,
1257
+ files: modified
1258
+ };
1259
+ fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
1260
+ console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified PAN file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
1261
+ for (const f of modified) {
1262
+ console.log(' ' + dim + f + reset);
1263
+ }
1264
+ }
1265
+ return modified;
1266
+ }
1267
+
1268
+ /**
1269
+ * After install, report backed-up patches for user to reapply.
1270
+ */
1271
+ function reportLocalPatches(configDir, runtime = 'claude') {
1272
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1273
+ const metaPath = path.join(patchesDir, 'backup-meta.json');
1274
+ if (!fs.existsSync(metaPath)) return [];
1275
+
1276
+ let meta;
1277
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
1278
+
1279
+ if (meta.files && meta.files.length > 0) {
1280
+ const reapplyCommand = runtime === 'opencode'
1281
+ ? '/pan-patches'
1282
+ : runtime === 'codex'
1283
+ ? '$pan-patches'
1284
+ : '/pan:patches';
1285
+ console.log('');
1286
+ console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
1287
+ for (const f of meta.files) {
1288
+ console.log(' ' + cyan + f + reset);
1289
+ }
1290
+ console.log('');
1291
+ console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset);
1292
+ console.log(' Run ' + cyan + reapplyCommand + reset + ' to merge them into the new version.');
1293
+ console.log(' Or manually compare and merge the files.');
1294
+ console.log('');
1295
+ }
1296
+ return meta.files || [];
1297
+ }
1298
+
1299
+ function install(isGlobal, runtime = 'claude') {
1300
+ const isOpencode = runtime === 'opencode';
1301
+ const isGemini = runtime === 'gemini';
1302
+ const isCodex = runtime === 'codex';
1303
+ const isCopilot = runtime === 'copilot';
1304
+ const dirName = getDirName(runtime);
1305
+ const src = path.join(__dirname, '..');
1306
+
1307
+ // Get the target directory based on runtime and install type
1308
+ const targetDir = isGlobal
1309
+ ? getGlobalDir(runtime, explicitConfigDir)
1310
+ : path.join(process.cwd(), dirName);
1311
+
1312
+ const locationLabel = isGlobal
1313
+ ? targetDir.replace(os.homedir(), '~')
1314
+ : targetDir.replace(process.cwd(), '.');
1315
+
1316
+ // Path prefix for file references in markdown content
1317
+ // For global installs: use full path
1318
+ // For local installs: use relative
1319
+ const pathPrefix = isGlobal
1320
+ ? `${targetDir.replace(/\\/g, '/')}/`
1321
+ : `./${dirName}/`;
1322
+
1323
+ let runtimeLabel = 'Claude Code';
1324
+ if (isOpencode) runtimeLabel = 'OpenCode';
1325
+ if (isGemini) runtimeLabel = 'Gemini';
1326
+ if (isCodex) runtimeLabel = 'Codex';
1327
+ if (isCopilot) runtimeLabel = 'GitHub Copilot CLI';
1328
+
1329
+ // Guard: never install into the PAN source repository itself
1330
+ if (normPath(path.resolve(process.cwd())) === normPath(PAN_SOURCE_ROOT)) {
1331
+ console.error(`\n ${red}✗${reset} Refusing to install PAN into its own source repository.`);
1332
+ console.error(` Run the installer from your target project directory instead.\n`);
1333
+ console.error(` Example: cd /path/to/my-project && node ${path.resolve(__dirname, 'install.js')} --claude --local\n`);
1334
+ process.exit(1);
1335
+ }
1336
+
1337
+ console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
1338
+
1339
+ // Early writability check fail fast before any file operations
1340
+ try {
1341
+ fs.mkdirSync(targetDir, { recursive: true });
1342
+ const probe = path.join(targetDir, '.pan-write-test');
1343
+ fs.writeFileSync(probe, '');
1344
+ fs.unlinkSync(probe);
1345
+ } catch (e) {
1346
+ console.error(` ${red}✗${reset} Cannot write to ${locationLabel}: ${e.message}`);
1347
+ console.error(` Check directory permissions and try again.`);
1348
+ process.exit(1);
1349
+ }
1350
+
1351
+ // Track installation failures
1352
+ const failures = [];
1353
+
1354
+ // Save any locally modified PAN files before they get wiped
1355
+ saveLocalPatches(targetDir);
1356
+
1357
+ // Clean up orphaned files from previous versions
1358
+ cleanupOrphanedFiles(targetDir);
1359
+
1360
+ // OpenCode uses command/ (flat), Codex uses skills/, Claude/Gemini use commands/pan/
1361
+ try {
1362
+ if (isOpencode) {
1363
+ // OpenCode: flat structure in command/ directory
1364
+ const commandDir = path.join(targetDir, 'command');
1365
+ fs.mkdirSync(commandDir, { recursive: true });
1366
+
1367
+ // Copy commands/pan/*.md as command/pan-*.md (flatten structure)
1368
+ const panSrc = path.join(src, 'commands', 'pan');
1369
+ copyFlattenedCommands(panSrc, commandDir, 'pan', pathPrefix, runtime);
1370
+ if (verifyInstalled(commandDir, 'command/pan-*')) {
1371
+ const count = fs.readdirSync(commandDir).filter(f => f.startsWith('pan-')).length;
1372
+ console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
1373
+ } else {
1374
+ failures.push('command/pan-*');
1375
+ }
1376
+ } else if (isCodex) {
1377
+ const skillsDir = path.join(targetDir, 'skills');
1378
+ const panSrc = path.join(src, 'commands', 'pan');
1379
+ copyCommandsAsCodexSkills(panSrc, skillsDir, 'pan', pathPrefix, runtime);
1380
+ const installedSkillNames = listCodexSkillNames(skillsDir);
1381
+ if (installedSkillNames.length > 0) {
1382
+ console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`);
1383
+ } else {
1384
+ failures.push('skills/pan-*');
1385
+ }
1386
+ } else if (isCopilot) {
1387
+ const skillsDir = path.join(targetDir, 'skills');
1388
+ const panSrc = path.join(src, 'commands', 'pan');
1389
+ copyCommandsAsCopilotSkills(panSrc, skillsDir, 'pan', pathPrefix, runtime);
1390
+ const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
1391
+ .filter(e => e.isDirectory() && e.name.startsWith('pan-'));
1392
+ if (installedSkills.length > 0) {
1393
+ console.log(` ${green}✓${reset} Installed ${installedSkills.length} skills to skills/`);
1394
+ } else {
1395
+ failures.push('skills/pan-*');
1396
+ }
1397
+ } else {
1398
+ // Claude Code & Gemini: nested structure in commands/ directory
1399
+ const commandsDir = path.join(targetDir, 'commands');
1400
+ fs.mkdirSync(commandsDir, { recursive: true });
1401
+
1402
+ const panSrc = path.join(src, 'commands', 'pan');
1403
+ const panDest = path.join(commandsDir, 'pan');
1404
+ copyWithPathReplacement(panSrc, panDest, pathPrefix, runtime, true);
1405
+ if (verifyInstalled(panDest, 'commands/pan')) {
1406
+ console.log(` ${green}✓${reset} Installed commands/pan`);
1407
+ } else {
1408
+ failures.push('commands/pan');
1409
+ }
1410
+
1411
+ // E-5: Claude native skill shims — register each PAN command as a skill
1412
+ // so Claude Code's native skill discovery surfaces them. Gemini doesn't
1413
+ // use the skills/ directory, so only generate for Claude.
1414
+ if (runtime === 'claude') {
1415
+ try {
1416
+ const skillsDir = path.join(targetDir, 'skills');
1417
+ fs.mkdirSync(skillsDir, { recursive: true });
1418
+ let shimCount = 0;
1419
+ for (const file of fs.readdirSync(panDest)) {
1420
+ if (!file.endsWith('.md')) continue;
1421
+ const commandName = file.slice(0, -3);
1422
+ const commandBody = fs.readFileSync(path.join(panDest, file), 'utf-8');
1423
+ const description = lib.extractFrontmatterField(commandBody, 'description')
1424
+ || `PAN command: ${commandName}`;
1425
+ const shim = buildClaudeSkillShim({ commandName, description });
1426
+ fs.writeFileSync(path.join(skillsDir, `pan-${commandName}.md`), shim, 'utf-8');
1427
+ shimCount += 1;
1428
+ }
1429
+ if (shimCount > 0) {
1430
+ console.log(` ${green}✓${reset} Registered ${shimCount} commands as skills/pan-*.md`);
1431
+ }
1432
+ } catch (e) {
1433
+ console.error(` ${yellow}⚠${reset} Skill shim registration skipped: ${e.message}`);
1434
+ }
1435
+ }
1436
+ }
1437
+ } catch (e) {
1438
+ console.error(` ${yellow}✗${reset} Commands install failed: ${e.message}`);
1439
+ failures.push('commands');
1440
+ }
1441
+
1442
+ // Copy pan-wizard-core skill with path replacement
1443
+ try {
1444
+ const skillSrc = path.join(src, 'pan-wizard-core');
1445
+ const skillDest = path.join(targetDir, 'pan-wizard-core');
1446
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime);
1447
+ if (verifyInstalled(skillDest, 'pan-wizard-core')) {
1448
+ console.log(` ${green}✓${reset} Installed pan-wizard-core`);
1449
+ } else {
1450
+ failures.push('pan-wizard-core');
1451
+ }
1452
+ } catch (e) {
1453
+ console.error(` ${yellow}✗${reset} pan-wizard-core install failed: ${e.message}`);
1454
+ failures.push('pan-wizard-core');
1455
+ }
1456
+
1457
+ // Copy agents to agents directory
1458
+ try {
1459
+ const agentsSrc = path.join(src, 'agents');
1460
+ if (fs.existsSync(agentsSrc)) {
1461
+ const agentsDest = path.join(targetDir, 'agents');
1462
+ fs.mkdirSync(agentsDest, { recursive: true });
1463
+
1464
+ // Remove old PAN agents (pan-*.md and pan-*.agent.md) before copying new ones
1465
+ if (fs.existsSync(agentsDest)) {
1466
+ for (const file of fs.readdirSync(agentsDest)) {
1467
+ if (file.startsWith('pan-') && file.endsWith('.md')) {
1468
+ fs.unlinkSync(path.join(agentsDest, file));
1469
+ }
1470
+ }
1471
+ }
1472
+
1473
+ // Copy new agents
1474
+ const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
1475
+ for (const entry of agentEntries) {
1476
+ if (entry.isFile() && entry.name.endsWith('.md')) {
1477
+ let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
1478
+ // Always replace ~/.claude/ as it is the source of truth in the repo
1479
+ const dirRegex = /~\/\.claude\//g;
1480
+ content = content.replace(dirRegex, pathPrefix);
1481
+ content = processAttribution(content, getCommitAttribution(runtime));
1482
+ // E-3 (per-runtime): strip unsupported thinking frontmatter and inject
1483
+ // prose preamble for runtimes without native extended thinking.
1484
+ content = stripThinkingFrontmatter(content, runtime);
1485
+ // Convert frontmatter for runtime compatibility
1486
+ if (isOpencode) {
1487
+ content = convertClaudeToOpencodeFrontmatter(content);
1488
+ } else if (isGemini) {
1489
+ content = convertClaudeToGeminiAgent(content);
1490
+ } else if (isCodex) {
1491
+ content = convertClaudeToCodexMarkdown(content);
1492
+ } else if (isCopilot) {
1493
+ content = convertClaudeToCopilotAgent(content);
1494
+ }
1495
+ // Copilot CLI uses .agent.md extension; others use .md
1496
+ const destName = isCopilot
1497
+ ? entry.name.replace(/\.md$/, '.agent.md')
1498
+ : entry.name;
1499
+ fs.writeFileSync(path.join(agentsDest, destName), content);
1500
+ }
1501
+ }
1502
+ if (verifyInstalled(agentsDest, 'agents')) {
1503
+ console.log(` ${green}✓${reset} Installed agents`);
1504
+ } else {
1505
+ failures.push('agents');
1506
+ }
1507
+ }
1508
+ } catch (e) {
1509
+ console.error(` ${yellow}✗${reset} Agents install failed: ${e.message}`);
1510
+ failures.push('agents');
1511
+ }
1512
+
1513
+ // Copy CHANGELOG.md
1514
+ try {
1515
+ const changelogSrc = path.join(src, 'CHANGELOG.md');
1516
+ const changelogDest = path.join(targetDir, 'pan-wizard-core', 'CHANGELOG.md');
1517
+ if (fs.existsSync(changelogSrc)) {
1518
+ fs.copyFileSync(changelogSrc, changelogDest);
1519
+ if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
1520
+ console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
1521
+ } else {
1522
+ failures.push('CHANGELOG.md');
1523
+ }
1524
+ }
1525
+ } catch (e) {
1526
+ console.error(` ${yellow}✗${reset} CHANGELOG install failed: ${e.message}`);
1527
+ failures.push('CHANGELOG.md');
1528
+ }
1529
+
1530
+ // Write VERSION file (with upgrade/same/downgrade detection)
1531
+ try {
1532
+ const versionDest = path.join(targetDir, 'pan-wizard-core', 'VERSION');
1533
+ let versionMsg = `${pkg.version}`;
1534
+ try {
1535
+ const prev = fs.readFileSync(versionDest, 'utf8').trim();
1536
+ if (prev && prev !== pkg.version) {
1537
+ versionMsg = `${prev} ${pkg.version}`;
1538
+ } else if (prev === pkg.version) {
1539
+ versionMsg = `${pkg.version} (reinstall)`;
1540
+ }
1541
+ } catch (_) { /* first install */ }
1542
+ fs.writeFileSync(versionDest, pkg.version + '\n');
1543
+ if (verifyFileInstalled(versionDest, 'VERSION')) {
1544
+ console.log(` ${green}✓${reset} Wrote VERSION (${versionMsg})`);
1545
+ } else {
1546
+ failures.push('VERSION');
1547
+ }
1548
+ } catch (e) {
1549
+ console.error(` ${yellow}✗${reset} VERSION write failed: ${e.message}`);
1550
+ failures.push('VERSION');
1551
+ }
1552
+
1553
+ if (!isCodex) {
1554
+ // Write package.json to force CommonJS mode for PAN scripts
1555
+ // Prevents "require is not defined" errors when project has "type": "module"
1556
+ // Node.js walks up looking for package.json - this stops inheritance from project
1557
+ try {
1558
+ const pkgJsonDest = path.join(targetDir, 'package.json');
1559
+ fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
1560
+ console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
1561
+ } catch (e) {
1562
+ console.error(` ${yellow}✗${reset} package.json write failed: ${e.message}`);
1563
+ failures.push('package.json');
1564
+ }
1565
+ }
1566
+
1567
+ if (!isCodex && !isOpencode) {
1568
+ // Copy hooks from dist/ (bundled with dependencies)
1569
+ // Hooks are only supported by Claude Code, Gemini, and Copilot CLI
1570
+ // Template paths for the target runtime (replaces '.claude' with correct config dir)
1571
+ try {
1572
+ const hooksSrc = path.join(src, 'hooks', 'dist');
1573
+ if (fs.existsSync(hooksSrc)) {
1574
+ const hooksDest = path.join(targetDir, 'hooks');
1575
+ fs.mkdirSync(hooksDest, { recursive: true });
1576
+ const hookEntries = fs.readdirSync(hooksSrc);
1577
+ const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
1578
+ for (const entry of hookEntries) {
1579
+ const srcFile = path.join(hooksSrc, entry);
1580
+ if (fs.statSync(srcFile).isFile()) {
1581
+ const destFile = path.join(hooksDest, entry);
1582
+ // Template .js files to replace '.claude' with runtime-specific config dir
1583
+ if (entry.endsWith('.js')) {
1584
+ let content = fs.readFileSync(srcFile, 'utf8');
1585
+ content = content.replace(/'\.claude'/g, configDirReplacement);
1586
+ fs.writeFileSync(destFile, content);
1587
+ } else {
1588
+ fs.copyFileSync(srcFile, destFile);
1589
+ }
1590
+ }
1591
+ }
1592
+ if (verifyInstalled(hooksDest, 'hooks')) {
1593
+ console.log(` ${green}✓${reset} Installed hooks (bundled)`);
1594
+ } else {
1595
+ failures.push('hooks');
1596
+ }
1597
+ }
1598
+ } catch (e) {
1599
+ console.error(` ${yellow}✗${reset} Hooks install failed: ${e.message}`);
1600
+ failures.push('hooks');
1601
+ }
1602
+ }
1603
+
1604
+ if (failures.length > 0) {
1605
+ console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
1606
+ process.exit(1);
1607
+ }
1608
+
1609
+ // Write file manifest for future modification detection
1610
+ writeManifest(targetDir, runtime);
1611
+ console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
1612
+
1613
+ // Report any backed-up local patches
1614
+ reportLocalPatches(targetDir, runtime);
1615
+
1616
+ // Post-install self-check: verify critical files exist before declaring success
1617
+ const selfCheckIssues = [];
1618
+ const panToolsPath = path.join(targetDir, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
1619
+ if (!fs.existsSync(panToolsPath)) selfCheckIssues.push('pan-tools.cjs missing');
1620
+ const manifestFullPath = path.join(targetDir, MANIFEST_NAME);
1621
+ if (fs.existsSync(manifestFullPath)) {
1622
+ try {
1623
+ const mfst = JSON.parse(fs.readFileSync(manifestFullPath, 'utf8'));
1624
+ const fileCount = Object.keys(mfst.files || {}).length;
1625
+ if (fileCount < 50) selfCheckIssues.push(`manifest has only ${fileCount} files (expected 150+)`);
1626
+ } catch { selfCheckIssues.push('manifest unreadable'); }
1627
+ } else {
1628
+ selfCheckIssues.push('manifest missing');
1629
+ }
1630
+ if (selfCheckIssues.length > 0) {
1631
+ console.error(`\n ${yellow}⚠ Post-install check found issues:${reset}`);
1632
+ for (const issue of selfCheckIssues) console.error(` - ${issue}`);
1633
+ console.error(` Run ${cyan}pan-tools validate deployment${reset} for full diagnostics.\n`);
1634
+ }
1635
+
1636
+ if (isCodex) {
1637
+ return { settingsPath: null, settings: null, statuslineCommand: null, runtime };
1638
+ }
1639
+
1640
+ const updateCheckCommand = isGlobal
1641
+ ? buildHookCommand(targetDir, 'pan-check-update.js')
1642
+ : 'node ' + dirName + '/hooks/pan-check-update.js';
1643
+ const contextMonitorCommand = isGlobal
1644
+ ? buildHookCommand(targetDir, 'pan-context-monitor.js')
1645
+ : 'node ' + dirName + '/hooks/pan-context-monitor.js';
1646
+ const statuslineCommand = isGlobal
1647
+ ? buildHookCommand(targetDir, 'pan-statusline.js')
1648
+ : 'node ' + dirName + '/hooks/pan-statusline.js';
1649
+ const costLoggerCommand = isGlobal
1650
+ ? buildHookCommand(targetDir, 'pan-cost-logger.js')
1651
+ : 'node ' + dirName + '/hooks/pan-cost-logger.js';
1652
+ const traceLoggerCommand = isGlobal
1653
+ ? buildHookCommand(targetDir, 'pan-trace-logger.js')
1654
+ : 'node ' + dirName + '/hooks/pan-trace-logger.js';
1655
+
1656
+ // Copilot CLI uses config.json with its own hook format
1657
+ if (isCopilot) {
1658
+ const configPath = path.join(targetDir, 'config.json');
1659
+ const config = readSettings(configPath);
1660
+
1661
+ if (!config.hooks) {
1662
+ config.hooks = {};
1663
+ }
1664
+
1665
+ // Register sessionStart hook for update checking
1666
+ if (!config.hooks.sessionStart) {
1667
+ config.hooks.sessionStart = [];
1668
+ }
1669
+ const hasPanUpdateHook = config.hooks.sessionStart.some(h =>
1670
+ h.command && h.command.includes('pan-check-update')
1671
+ );
1672
+ if (!hasPanUpdateHook) {
1673
+ config.hooks.sessionStart.push({
1674
+ command: updateCheckCommand
1675
+ });
1676
+ console.log(` ${green}✓${reset} Configured update check hook`);
1677
+ }
1678
+
1679
+ // Register postToolUse hook for context monitoring
1680
+ if (!config.hooks.postToolUse) {
1681
+ config.hooks.postToolUse = [];
1682
+ }
1683
+ const hasContextHook = config.hooks.postToolUse.some(h =>
1684
+ h.command && h.command.includes('pan-context-monitor')
1685
+ );
1686
+ if (!hasContextHook) {
1687
+ config.hooks.postToolUse.push({
1688
+ command: contextMonitorCommand
1689
+ });
1690
+ console.log(` ${green}✓${reset} Configured context window monitor hook`);
1691
+ }
1692
+
1693
+ writeSettings(configPath, config);
1694
+ return { settingsPath: configPath, settings: config, statuslineCommand, runtime };
1695
+ }
1696
+
1697
+ // Configure statusline and hooks in settings.json
1698
+ // Claude Code, Gemini, OpenCode use settings.json
1699
+ const settingsPath = path.join(targetDir, 'settings.json');
1700
+ const settings = cleanupOrphanedHooks(readSettings(settingsPath));
1701
+
1702
+ // Enable experimental agents for Gemini CLI (required for custom sub-agents)
1703
+ if (isGemini) {
1704
+ if (!settings.experimental) {
1705
+ settings.experimental = {};
1706
+ }
1707
+ if (!settings.experimental.enableAgents) {
1708
+ settings.experimental.enableAgents = true;
1709
+ console.log(` ${green}✓${reset} Enabled experimental agents`);
1710
+ }
1711
+ }
1712
+
1713
+ // Configure SessionStart hook for update checking (skip for opencode)
1714
+ if (!isOpencode) {
1715
+ if (!settings.hooks) {
1716
+ settings.hooks = {};
1717
+ }
1718
+ if (!settings.hooks.SessionStart) {
1719
+ settings.hooks.SessionStart = [];
1720
+ }
1721
+
1722
+ const hasPanUpdateHook = settings.hooks.SessionStart.some(entry =>
1723
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-check-update'))
1724
+ );
1725
+
1726
+ if (!hasPanUpdateHook) {
1727
+ settings.hooks.SessionStart.push({
1728
+ hooks: [
1729
+ {
1730
+ type: 'command',
1731
+ command: updateCheckCommand
1732
+ }
1733
+ ]
1734
+ });
1735
+ console.log(` ${green}✓${reset} Configured update check hook`);
1736
+ }
1737
+
1738
+ // Configure PostToolUse hook for context window monitoring
1739
+ if (!settings.hooks.PostToolUse) {
1740
+ settings.hooks.PostToolUse = [];
1741
+ }
1742
+
1743
+ const hasContextMonitorHook = settings.hooks.PostToolUse.some(entry =>
1744
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-context-monitor'))
1745
+ );
1746
+
1747
+ if (!hasContextMonitorHook) {
1748
+ settings.hooks.PostToolUse.push({
1749
+ hooks: [
1750
+ {
1751
+ type: 'command',
1752
+ command: contextMonitorCommand
1753
+ }
1754
+ ]
1755
+ });
1756
+ console.log(` ${green}✓${reset} Configured context window monitor hook`);
1757
+ }
1758
+
1759
+ // v3.4+: SubagentStop hook for automatic cost logging.
1760
+ // Gemini + OpenCode may not implement SubagentStop; we still register
1761
+ // the entry — hosts that don't fire the event simply never trigger it.
1762
+ if (!settings.hooks.SubagentStop) {
1763
+ settings.hooks.SubagentStop = [];
1764
+ }
1765
+ const hasCostLoggerHook = settings.hooks.SubagentStop.some(entry =>
1766
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-cost-logger'))
1767
+ );
1768
+ if (!hasCostLoggerHook) {
1769
+ settings.hooks.SubagentStop.push({
1770
+ hooks: [
1771
+ {
1772
+ type: 'command',
1773
+ command: costLoggerCommand
1774
+ }
1775
+ ]
1776
+ });
1777
+ console.log(` ${green}✓${reset} Configured cost logger hook`);
1778
+ }
1779
+
1780
+ // v3.5+: SubagentStop hook for circular optimization tracing.
1781
+ // Logs agent completion events to the active trace session (if one is running).
1782
+ const hasTraceLoggerHook = settings.hooks.SubagentStop.some(entry =>
1783
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('pan-trace-logger'))
1784
+ );
1785
+ if (!hasTraceLoggerHook) {
1786
+ settings.hooks.SubagentStop.push({
1787
+ hooks: [
1788
+ {
1789
+ type: 'command',
1790
+ command: traceLoggerCommand
1791
+ }
1792
+ ]
1793
+ });
1794
+ console.log(` ${green}✓${reset} Configured trace logger hook`);
1795
+ }
1796
+ }
1797
+
1798
+ return { settingsPath, settings, statuslineCommand, runtime };
1799
+ }
1800
+
1801
+ /**
1802
+ * Apply statusline config, then print completion message
1803
+ */
1804
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) {
1805
+ const isOpencode = runtime === 'opencode';
1806
+ const isCodex = runtime === 'codex';
1807
+ const isCopilot = runtime === 'copilot';
1808
+
1809
+ if (shouldInstallStatusline && !isOpencode && !isCodex) {
1810
+ if (isCopilot) {
1811
+ // Copilot CLI: statusline stored in config.json
1812
+ settings.statusLine = {
1813
+ command: statuslineCommand
1814
+ };
1815
+ } else {
1816
+ settings.statusLine = {
1817
+ type: 'command',
1818
+ command: statuslineCommand
1819
+ };
1820
+ }
1821
+ console.log(` ${green}✓${reset} Configured statusline`);
1822
+ }
1823
+
1824
+ // Write settings/config when runtime supports it
1825
+ if (!isCodex) {
1826
+ writeSettings(settingsPath, settings);
1827
+ }
1828
+
1829
+ // Configure OpenCode permissions
1830
+ if (isOpencode) {
1831
+ configureOpencodePermissions(isGlobal);
1832
+ }
1833
+
1834
+ // E-9: Opus 4.7 capability detection — warn if user's default model lacks
1835
+ // features Spec A relies on (1M ctx, extended thinking, prompt caching).
1836
+ if (!args.includes('--skip-warnings')) {
1837
+ try {
1838
+ const settingsPath = path.join(targetDir, 'settings.json');
1839
+ const settingsRaw = fs.readFileSync(settingsPath, 'utf-8');
1840
+ const settings = JSON.parse(settingsRaw);
1841
+ const modelField = settings && settings.model;
1842
+ if (typeof modelField === 'string' && modelField.trim()) {
1843
+ const caps = detectModelCapabilities(modelField);
1844
+ if (!caps.has_1m_ctx || !caps.has_thinking) {
1845
+ const missing = [
1846
+ !caps.has_1m_ctx ? '1M context (E-2 map-codebase single-shot)' : null,
1847
+ !caps.has_thinking ? 'extended thinking (E-3, E-10, E-11)' : null,
1848
+ ].filter(Boolean).join(', ');
1849
+ console.log(`
1850
+ ${yellow}ℹ${reset} PAN 2.10+ is tuned for Opus 4.7. Default model "${modelField}" lacks: ${missing}.
1851
+ Features degrade gracefully, but upgrade to claude-opus-4-7 for best results.`);
1852
+ }
1853
+ }
1854
+ } catch {
1855
+ // No settings.json, no model field, or JSON parse error — skip silently.
1856
+ }
1857
+ }
1858
+
1859
+ let program = 'Claude Code';
1860
+ if (runtime === 'opencode') program = 'OpenCode';
1861
+ if (runtime === 'gemini') program = 'Gemini';
1862
+ if (runtime === 'codex') program = 'Codex';
1863
+ if (runtime === 'copilot') program = 'GitHub Copilot CLI';
1864
+
1865
+ let command = '/pan:new-project';
1866
+ if (runtime === 'opencode') command = '/pan-new-project';
1867
+ if (runtime === 'codex') command = '$pan-new-project';
1868
+ if (runtime === 'copilot') command = '/pan-new-project';
1869
+ console.log(`
1870
+ ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
1871
+
1872
+ ${cyan}Join the community:${reset} https://discord.gg/pan-wizard
1873
+ `);
1874
+ }
1875
+
1876
+ /**
1877
+ * Handle statusline configuration with optional prompt
1878
+ */
1879
+ function handleStatusline(settings, isInteractive, callback) {
1880
+ const hasExisting = settings.statusLine != null;
1881
+
1882
+ if (!hasExisting) {
1883
+ callback(true);
1884
+ return;
1885
+ }
1886
+
1887
+ if (forceStatusline) {
1888
+ callback(true);
1889
+ return;
1890
+ }
1891
+
1892
+ if (!isInteractive) {
1893
+ console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`);
1894
+ console.log(` Use ${cyan}--force-statusline${reset} to replace\n`);
1895
+ callback(false);
1896
+ return;
1897
+ }
1898
+
1899
+ const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)';
1900
+
1901
+ const rl = readline.createInterface({
1902
+ input: process.stdin,
1903
+ output: process.stdout
1904
+ });
1905
+
1906
+ console.log(`
1907
+ ${yellow}⚠${reset} Existing statusline detected\n
1908
+ Your current statusline:
1909
+ ${dim}command: ${existingCmd}${reset}
1910
+
1911
+ PAN includes a statusline showing:
1912
+ • Model name
1913
+ • Current task (from todo list)
1914
+ • Context window usage (color-coded)
1915
+
1916
+ ${cyan}1${reset}) Keep existing
1917
+ ${cyan}2${reset}) Replace with PAN statusline
1918
+ `);
1919
+
1920
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1921
+ rl.close();
1922
+ const choice = answer.trim() || '1';
1923
+ callback(choice === '2');
1924
+ });
1925
+ }
1926
+
1927
+ /**
1928
+ * Prompt for runtime selection
1929
+ */
1930
+ function promptRuntime(callback) {
1931
+ const rl = readline.createInterface({
1932
+ input: process.stdin,
1933
+ output: process.stdout
1934
+ });
1935
+
1936
+ let answered = false;
1937
+
1938
+ rl.on('close', () => {
1939
+ if (!answered) {
1940
+ answered = true;
1941
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
1942
+ process.exit(0);
1943
+ }
1944
+ });
1945
+
1946
+ console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
1947
+ ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models
1948
+ ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset}
1949
+ ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset}
1950
+ ${cyan}5${reset}) Copilot CLI ${dim}(~/.copilot)${reset} - GitHub's terminal agent
1951
+ ${cyan}6${reset}) All
1952
+ `);
1953
+
1954
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
1955
+ answered = true;
1956
+ rl.close();
1957
+ const choice = answer.trim() || '1';
1958
+ if (choice === '6') {
1959
+ callback(['claude', 'opencode', 'gemini', 'codex', 'copilot']);
1960
+ } else if (choice === '5') {
1961
+ callback(['copilot']);
1962
+ } else if (choice === '4') {
1963
+ callback(['codex']);
1964
+ } else if (choice === '3') {
1965
+ callback(['gemini']);
1966
+ } else if (choice === '2') {
1967
+ callback(['opencode']);
1968
+ } else {
1969
+ callback(['claude']);
1970
+ }
1971
+ });
1972
+ }
1973
+
1974
+ /**
1975
+ * Install PAN for all selected runtimes
1976
+ */
1977
+ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
1978
+ const results = [];
1979
+
1980
+ for (const runtime of runtimes) {
1981
+ const result = install(isGlobal, runtime);
1982
+ results.push(result);
1983
+ }
1984
+
1985
+ const statuslineRuntimes = ['claude', 'gemini', 'copilot'];
1986
+ const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
1987
+
1988
+ const finalize = (shouldInstallStatusline) => {
1989
+ for (const result of results) {
1990
+ const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
1991
+ finishInstall(
1992
+ result.settingsPath,
1993
+ result.settings,
1994
+ result.statuslineCommand,
1995
+ useStatusline,
1996
+ result.runtime,
1997
+ isGlobal
1998
+ );
1999
+ }
2000
+ };
2001
+
2002
+ if (primaryStatuslineResult) {
2003
+ handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize);
2004
+ } else {
2005
+ finalize(false);
2006
+ }
2007
+ }
2008
+
2009
+ // Main logic
2010
+ if (hasGlobal && hasLocal) {
2011
+ console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
2012
+ process.exit(1);
2013
+ } else if (explicitConfigDir && hasLocal) {
2014
+ console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
2015
+ process.exit(1);
2016
+ } else if (hasUninstall) {
2017
+ if (!hasGlobal && !hasLocal) {
2018
+ console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
2019
+ process.exit(1);
2020
+ }
2021
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
2022
+ for (const runtime of runtimes) {
2023
+ uninstall(hasGlobal, runtime);
2024
+ }
2025
+ } else if (selectedRuntimes.length > 0) {
2026
+ if (!hasGlobal && !hasLocal) {
2027
+ // Default: project-level install. Use --global to install for all projects.
2028
+ console.log(` ${dim}Defaulting to project-level install (use --global for user-level).${reset}\n`);
2029
+ installAllRuntimes(selectedRuntimes, false, false);
2030
+ } else {
2031
+ installAllRuntimes(selectedRuntimes, hasGlobal, false);
2032
+ }
2033
+ } else if (hasGlobal || hasLocal) {
2034
+ // Default to Claude if no runtime specified but location is
2035
+ installAllRuntimes(['claude'], hasGlobal, false);
2036
+ } else {
2037
+ // Interactive
2038
+ if (!process.stdin.isTTY) {
2039
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code project-level install${reset}\n`);
2040
+ installAllRuntimes(['claude'], false, false);
2041
+ } else {
2042
+ promptRuntime((runtimes) => {
2043
+ // Default: project-level install. Pass --global on CLI for user-level.
2044
+ console.log(` ${dim}Defaulting to project-level install (use --global for user-level).${reset}\n`);
2045
+ installAllRuntimes(runtimes, false, true);
2046
+ });
2047
+ }
2048
+ }