pan-wizard 2.9.0 → 3.4.1

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