@hopla/claude-setup 1.12.1 → 1.14.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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +209 -144
  4. package/agents/system-reviewer.md +13 -0
  5. package/cli.js +301 -399
  6. package/commands/code-review-fix.md +1 -1
  7. package/commands/create-prd.md +1 -1
  8. package/commands/execute.md +27 -8
  9. package/commands/guide.md +14 -8
  10. package/commands/guides/ai-optimized-codebase.md +1 -1
  11. package/commands/guides/mcp-integration.md +3 -3
  12. package/commands/guides/remote-coding.md +3 -3
  13. package/commands/guides/review-checklist.md +5 -5
  14. package/commands/guides/scaling-beyond-engineering.md +6 -9
  15. package/commands/init-project.md +12 -10
  16. package/commands/plan-feature.md +18 -2
  17. package/commands/rca.md +9 -4
  18. package/commands/review-plan.md +2 -2
  19. package/commands/validate.md +2 -2
  20. package/global-rules.md +18 -50
  21. package/hooks/env-protect.js +39 -8
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/session-prime.js +39 -12
  24. package/hooks/tsc-check.js +21 -10
  25. package/package.json +1 -1
  26. package/skills/brainstorm/SKILL.md +7 -2
  27. package/skills/code-review/SKILL.md +4 -2
  28. package/skills/debug/SKILL.md +8 -0
  29. package/skills/execution-report/SKILL.md +2 -2
  30. package/skills/git/commit.md +20 -3
  31. package/skills/git/flow-detection.md +68 -0
  32. package/skills/git/pr.md +46 -16
  33. package/skills/prime/SKILL.md +1 -1
  34. package/skills/subagent-execution/SKILL.md +1 -1
  35. package/skills/tdd/SKILL.md +1 -1
  36. package/skills/verify/SKILL.md +11 -1
  37. package/skills/worktree/SKILL.md +83 -38
  38. package/commands/end-to-end.md +0 -67
  39. package/commands/git-commit.md +0 -76
  40. package/commands/git-pr.md +0 -138
package/cli.js CHANGED
@@ -7,19 +7,27 @@ import readline from "readline";
7
7
 
8
8
  const FORCE = process.argv.includes("--force");
9
9
  const UNINSTALL = process.argv.includes("--uninstall");
10
- const PLANNING = process.argv.includes("--planning");
10
+ const MIGRATE = process.argv.includes("--migrate");
11
11
  const VERSION = process.argv.includes("--version") || process.argv.includes("-v");
12
+ const DRY_RUN = process.argv.includes("--dry-run");
12
13
 
13
14
  if (VERSION) {
14
15
  const pkg = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"));
15
16
  console.log(`@hopla/claude-setup v${pkg.version}`);
16
17
  process.exit(0);
17
18
  }
19
+
18
20
  const CLAUDE_DIR = path.join(os.homedir(), ".claude");
19
21
  const COMMANDS_DIR = path.join(CLAUDE_DIR, "commands");
20
22
  const SKILLS_DIR = path.join(CLAUDE_DIR, "skills");
21
23
  const HOOKS_DIR = path.join(CLAUDE_DIR, "hooks");
22
24
  const AGENTS_DIR = path.join(CLAUDE_DIR, "agents");
25
+ const PLUGINS_DIR = path.join(CLAUDE_DIR, "plugins");
26
+ const MARKETPLACE_CACHE = path.join(PLUGINS_DIR, "marketplaces", "hopla-marketplace");
27
+ const SETTINGS_FILES = [
28
+ path.join(CLAUDE_DIR, "settings.json"),
29
+ path.join(CLAUDE_DIR, "settings.local.json"),
30
+ ];
23
31
  const REPO_ROOT = import.meta.dirname;
24
32
 
25
33
  const GREEN = "\x1b[32m";
@@ -33,6 +41,59 @@ function log(msg) {
33
41
  console.log(msg);
34
42
  }
35
43
 
44
+ function dryTag() {
45
+ return DRY_RUN ? ` ${CYAN}[dry-run]${RESET}` : "";
46
+ }
47
+
48
+ function safeRm(target, opts = {}) {
49
+ if (DRY_RUN) return;
50
+ fs.rmSync(target, opts);
51
+ }
52
+
53
+ // Atomic write: stage to a tmp file and rename over the target.
54
+ // Protects ~/.claude/settings.json from corruption if the process crashes mid-write.
55
+ function safeWrite(target, content) {
56
+ if (DRY_RUN) return;
57
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
58
+ try {
59
+ fs.writeFileSync(tmp, content);
60
+ fs.renameSync(tmp, target);
61
+ } catch (err) {
62
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
63
+ throw err;
64
+ }
65
+ }
66
+
67
+ // Atomic copy: copy to a tmp path and rename over the destination.
68
+ function safeCopy(src, dest) {
69
+ if (DRY_RUN) return;
70
+ const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
71
+ try {
72
+ fs.copyFileSync(src, tmp);
73
+ fs.renameSync(tmp, dest);
74
+ } catch (err) {
75
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
76
+ throw err;
77
+ }
78
+ }
79
+
80
+ function safeMkdir(dir, opts) {
81
+ if (DRY_RUN) return;
82
+ fs.mkdirSync(dir, opts);
83
+ }
84
+
85
+ function logRemoved(label) {
86
+ const verb = DRY_RUN ? "Would remove" : "Removed";
87
+ log(` ${RED}✕${RESET} ${verb}: ${label}`);
88
+ }
89
+
90
+ function logInstalled(label, exists) {
91
+ const verb = DRY_RUN
92
+ ? (exists ? "Would update" : "Would install")
93
+ : (exists ? "Updated" : "Installed");
94
+ log(` ${GREEN}✓${RESET} ${verb}: ${label}`);
95
+ }
96
+
36
97
  async function confirm(question) {
37
98
  if (FORCE) return true;
38
99
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -57,486 +118,327 @@ async function installFile(src, dest, label) {
57
118
  }
58
119
  }
59
120
 
60
- fs.copyFileSync(src, dest);
61
- log(` ${GREEN}✓${RESET} ${exists ? "Updated" : "Installed"}: ${label}`);
121
+ safeCopy(src, dest);
122
+ logInstalled(label, exists);
62
123
  }
63
124
 
64
- function removeFile(dest, label) {
65
- if (fs.existsSync(dest)) {
66
- fs.rmSync(dest);
67
- log(` ${RED}✕${RESET} Removed: ${label}`);
68
- } else {
69
- log(` ${YELLOW}↷${RESET} Not found: ${label}`);
70
- }
71
- }
125
+ // Hooks installed by previous CLI versions (v1.4.0 through v1.11.x)
126
+ const LEGACY_HOOK_COMMANDS = [
127
+ "tsc-check.js",
128
+ "env-protect.js",
129
+ "session-prime.js",
130
+ ];
72
131
 
73
- async function uninstall() {
74
- log(`\n${BOLD}@hopla/claude-setup${RESET} Uninstall\n`);
132
+ // Agents installed directly by v1.11.0 and v1.12.0 (no hopla- prefix)
133
+ // Must be cleaned up so the plugin-provided versions are the only source of truth
134
+ const LEGACY_AGENT_FILES = [
135
+ "code-reviewer.md",
136
+ "codebase-researcher.md",
137
+ "system-reviewer.md",
138
+ ];
75
139
 
76
- const srcEntries = fs.readdirSync(path.join(REPO_ROOT, "commands"));
77
- const srcFiles = srcEntries.filter((f) => {
78
- if (f.startsWith(".")) return false;
79
- return fs.statSync(path.join(REPO_ROOT, "commands", f)).isFile();
80
- });
81
- const srcDirs = srcEntries.filter((f) =>
82
- fs.statSync(path.join(REPO_ROOT, "commands", f)).isDirectory()
83
- );
140
+ // Permissions added by the current CLI
141
+ const HOPLA_PERMISSIONS = [
142
+ "Bash(git *)",
143
+ "Bash(cd *)",
144
+ "Bash(ls *)",
145
+ "Bash(find *)",
146
+ "Bash(cat *)",
147
+ "Bash(head *)",
148
+ "Bash(tail *)",
149
+ "Bash(echo *)",
150
+ ];
84
151
 
85
- // Map source files to their installed names (with hopla- prefix)
86
- const installedNames = new Set(srcFiles.map((f) => withPrefix(f)));
87
-
88
- // Also include any hopla-* files in ~/.claude/commands/ not in current source
89
- // (leftovers from previous versions)
90
- const installedHoplaFiles = fs.existsSync(COMMANDS_DIR)
91
- ? fs.readdirSync(COMMANDS_DIR).filter((f) =>
92
- f.startsWith("hopla-") && fs.statSync(path.join(COMMANDS_DIR, f)).isFile() && !installedNames.has(f)
93
- )
94
- : [];
95
-
96
- const itemsToRemove = [
97
- { dest: path.join(CLAUDE_DIR, "CLAUDE.md"), label: "~/.claude/CLAUDE.md", isDir: false },
98
- ...srcFiles.map((file) => ({
99
- dest: path.join(COMMANDS_DIR, withPrefix(file)),
100
- label: `~/.claude/commands/${withPrefix(file)}`,
101
- isDir: false,
102
- })),
103
- ...installedHoplaFiles.map((file) => ({
104
- dest: path.join(COMMANDS_DIR, file),
105
- label: `~/.claude/commands/${file}`,
106
- isDir: false,
107
- })),
108
- ...srcDirs.map((dir) => ({
109
- dest: path.join(COMMANDS_DIR, dir),
110
- label: `~/.claude/commands/${dir}/`,
111
- isDir: true,
112
- })),
113
- ];
114
-
115
- // Also remove skills, agents, and hooks installed by hopla
116
- if (fs.existsSync(SKILLS_DIR)) {
117
- itemsToRemove.push({ dest: SKILLS_DIR, label: "~/.claude/skills/", isDir: true });
118
- }
119
- if (fs.existsSync(AGENTS_DIR)) {
120
- itemsToRemove.push({ dest: AGENTS_DIR, label: "~/.claude/agents/", isDir: true });
121
- }
122
- if (fs.existsSync(HOOKS_DIR)) {
123
- itemsToRemove.push({ dest: HOOKS_DIR, label: "~/.claude/hooks/", isDir: true });
124
- }
152
+ // Permissions from older CLI versions (e.g. PLANNING_PERMISSIONS from v1.11.0)
153
+ // Included in cleanup so settings.json does not accumulate obsolete entries
154
+ const LEGACY_PERMISSIONS = [
155
+ "Bash(git branch*)",
156
+ "Bash(git log*)",
157
+ "Bash(git status*)",
158
+ ];
125
159
 
126
- log(`The following will be removed:`);
127
- for (const { label } of itemsToRemove) {
128
- log(` ${RED}✕${RESET} ${label}`);
129
- }
160
+ const ALL_HOPLA_PERMISSIONS = new Set([...HOPLA_PERMISSIONS, ...LEGACY_PERMISSIONS]);
130
161
 
131
- const ok = await confirm(`\nContinue? (y/N) `);
132
- if (!ok) {
133
- log(`\nAborted.\n`);
134
- return;
135
- }
162
+ function removeLegacyFiles() {
163
+ let removed = [];
136
164
 
137
- log("");
138
- for (const { dest, label, isDir } of itemsToRemove) {
139
- if (fs.existsSync(dest)) {
140
- fs.rmSync(dest, { recursive: isDir });
141
- log(` ${RED}✕${RESET} Removed: ${label}`);
142
- } else {
143
- log(` ${YELLOW}↷${RESET} Not found: ${label}`);
165
+ // hopla-* commands
166
+ if (fs.existsSync(COMMANDS_DIR)) {
167
+ for (const file of fs.readdirSync(COMMANDS_DIR)) {
168
+ const filePath = path.join(COMMANDS_DIR, file);
169
+ if (file.startsWith("hopla-") && fs.statSync(filePath).isFile()) {
170
+ safeRm(filePath);
171
+ removed.push(`~/.claude/commands/${file}`);
172
+ }
144
173
  }
145
174
  }
146
175
 
147
- log(`\n${GREEN}${BOLD}Done!${RESET} Files removed.\n`);
148
- }
149
-
150
- function removeStaleCommands(currentCommandFiles) {
151
- if (!fs.existsSync(COMMANDS_DIR)) return;
152
- const currentSet = new Set(currentCommandFiles.map((f) => withPrefix(f)));
153
- const removed = [];
154
- for (const file of fs.readdirSync(COMMANDS_DIR)) {
155
- const filePath = path.join(COMMANDS_DIR, file);
156
- if (!fs.statSync(filePath).isFile()) continue;
157
- if (file.startsWith("hopla-") && !currentSet.has(file)) {
158
- fs.rmSync(filePath);
159
- removed.push(file);
176
+ // hopla-* skills
177
+ if (fs.existsSync(SKILLS_DIR)) {
178
+ for (const entry of fs.readdirSync(SKILLS_DIR)) {
179
+ const entryPath = path.join(SKILLS_DIR, entry);
180
+ if (entry.startsWith("hopla-") && fs.statSync(entryPath).isDirectory()) {
181
+ safeRm(entryPath, { recursive: true });
182
+ removed.push(`~/.claude/skills/${entry}/`);
183
+ }
160
184
  }
161
185
  }
162
- if (removed.length > 0) {
163
- log(`${CYAN}Removing stale commands from previous versions...${RESET}`);
164
- for (const file of removed) {
165
- log(` ${YELLOW}↷${RESET} Removed: ~/.claude/commands/${file}`);
186
+
187
+ // hopla hook files
188
+ if (fs.existsSync(HOOKS_DIR)) {
189
+ for (const hookFile of LEGACY_HOOK_COMMANDS) {
190
+ const hookPath = path.join(HOOKS_DIR, hookFile);
191
+ if (fs.existsSync(hookPath)) {
192
+ safeRm(hookPath);
193
+ removed.push(`~/.claude/hooks/${hookFile}`);
194
+ }
195
+ }
196
+ if (!DRY_RUN) {
197
+ try {
198
+ const remaining = fs.readdirSync(HOOKS_DIR);
199
+ if (remaining.length === 0) fs.rmSync(HOOKS_DIR, { recursive: true });
200
+ } catch { /* ignore */ }
166
201
  }
167
- log("");
168
202
  }
169
- }
170
-
171
- const PLANNING_COMMANDS = [
172
- "init-project.md",
173
- "create-prd.md",
174
- "plan-feature.md",
175
- "review-plan.md",
176
- "git-commit.md",
177
- "git-pr.md",
178
- "guide.md",
179
- ];
180
-
181
- // CLI channel adds hopla- prefix so skills/commands keep their namespace
182
- function withPrefix(name) {
183
- return `hopla-${name}`;
184
- }
185
-
186
- async function install() {
187
- const modeLabel = PLANNING ? "Planning Mode (Robert)" : "Full Install";
188
- log(`\n${BOLD}@hopla/claude-setup${RESET} — Agentic Coding System ${CYAN}[${modeLabel}]${RESET}\n`);
189
-
190
- // Create directories if needed
191
- fs.mkdirSync(CLAUDE_DIR, { recursive: true });
192
- fs.mkdirSync(COMMANDS_DIR, { recursive: true });
193
-
194
- // Determine which command files will be installed
195
- const allCommandEntries = fs.readdirSync(path.join(REPO_ROOT, "commands"));
196
- const allCommandFiles = allCommandEntries.filter((f) => {
197
- if (f.startsWith(".")) return false;
198
- const stat = fs.statSync(path.join(REPO_ROOT, "commands", f));
199
- return stat.isFile();
200
- });
201
- const allCommandDirs = allCommandEntries.filter((f) => {
202
- const stat = fs.statSync(path.join(REPO_ROOT, "commands", f));
203
- return stat.isDirectory();
204
- });
205
- const commandFiles = PLANNING
206
- ? allCommandFiles.filter((f) => PLANNING_COMMANDS.includes(f))
207
- : allCommandFiles;
208
203
 
209
- // Remove stale hopla-* commands not in the current version
210
- removeStaleCommands(commandFiles);
211
-
212
- log(`${CYAN}Installing global rules...${RESET}`);
213
- await installFile(
214
- path.join(REPO_ROOT, "global-rules.md"),
215
- path.join(CLAUDE_DIR, "CLAUDE.md"),
216
- "~/.claude/CLAUDE.md"
217
- );
218
-
219
- log(`\n${CYAN}Installing commands...${RESET}`);
220
- for (const file of commandFiles.sort()) {
221
- const destFile = withPrefix(file);
222
- await installFile(
223
- path.join(REPO_ROOT, "commands", file),
224
- path.join(COMMANDS_DIR, destFile),
225
- `~/.claude/commands/${destFile}`
226
- );
227
- }
228
- // Install subdirectories (e.g. guides/)
229
- for (const dir of allCommandDirs.sort()) {
230
- const srcDir = path.join(REPO_ROOT, "commands", dir);
231
- const destDir = path.join(COMMANDS_DIR, dir);
232
- fs.mkdirSync(destDir, { recursive: true });
233
- for (const file of fs.readdirSync(srcDir).sort()) {
234
- await installFile(
235
- path.join(srcDir, file),
236
- path.join(destDir, file),
237
- `~/.claude/commands/${dir}/${file}`
238
- );
204
+ // Legacy agents (v1.11.0 / v1.12.0 installed these directly)
205
+ if (fs.existsSync(AGENTS_DIR)) {
206
+ for (const agentFile of LEGACY_AGENT_FILES) {
207
+ const agentPath = path.join(AGENTS_DIR, agentFile);
208
+ if (fs.existsSync(agentPath)) {
209
+ safeRm(agentPath);
210
+ removed.push(`~/.claude/agents/${agentFile}`);
211
+ }
212
+ }
213
+ if (!DRY_RUN) {
214
+ try {
215
+ const remaining = fs.readdirSync(AGENTS_DIR);
216
+ if (remaining.length === 0) fs.rmSync(AGENTS_DIR, { recursive: true });
217
+ } catch { /* ignore */ }
239
218
  }
240
219
  }
241
220
 
242
- log(`\n${GREEN}${BOLD}Done!${RESET} Commands available in any Claude Code session:\n`);
243
- for (const file of commandFiles.sort()) {
244
- const name = withPrefix(file).replace(".md", "");
245
- log(` ${CYAN}/${name}${RESET}`);
246
- }
247
- if (PLANNING) {
248
- log(`\n${YELLOW}Planning mode:${RESET} Only planning commands installed. Run without ${BOLD}--planning${RESET} for the full set.\n`);
249
- } else {
250
- log(`\nRun with ${BOLD}--force${RESET} to overwrite all files without prompting.\n`);
221
+ // hopla hook entries from settings.json AND settings.local.json
222
+ for (const settingsPath of SETTINGS_FILES) {
223
+ if (!fs.existsSync(settingsPath)) continue;
224
+ try {
225
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
226
+ let changed = false;
227
+
228
+ if (settings.hooks) {
229
+ for (const [event, matchers] of Object.entries(settings.hooks)) {
230
+ if (!Array.isArray(matchers)) continue;
231
+ const filtered = matchers.filter((m) => {
232
+ if (!m.hooks || !Array.isArray(m.hooks)) return true;
233
+ const isHopla = m.hooks.every((h) =>
234
+ LEGACY_HOOK_COMMANDS.some((cmd) => h.command && h.command.includes(cmd))
235
+ );
236
+ return !isHopla;
237
+ });
238
+ if (filtered.length !== matchers.length) {
239
+ settings.hooks[event] = filtered;
240
+ if (filtered.length === 0) delete settings.hooks[event];
241
+ changed = true;
242
+ }
243
+ }
244
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
245
+ }
246
+
247
+ if (changed) {
248
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
249
+ removed.push(`hooks from ${path.basename(settingsPath)}`);
250
+ }
251
+ } catch { /* ignore parse errors */ }
251
252
  }
252
253
 
253
- await setupPermissions();
254
- await installSkills();
255
- await installAgents();
256
- await installHooks();
254
+ return removed;
257
255
  }
258
256
 
259
- // Skills to install in planning mode (subset)
260
- const PLANNING_SKILLS = ["prime", "brainstorm"];
261
-
262
- async function installSkills() {
263
- const skillsSrcDir = path.join(REPO_ROOT, "skills");
264
- if (!fs.existsSync(skillsSrcDir)) return;
265
-
266
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
267
-
268
- const skillDirs = fs.readdirSync(skillsSrcDir).filter((entry) => {
269
- return fs.statSync(path.join(skillsSrcDir, entry)).isDirectory();
270
- });
271
-
272
- const skillsToInstall = PLANNING
273
- ? skillDirs.filter((d) => PLANNING_SKILLS.includes(d))
274
- : skillDirs;
275
-
276
- if (skillsToInstall.length === 0) return;
277
-
278
- log(`\n${CYAN}Installing skills...${RESET}`);
279
- for (const skillName of skillsToInstall.sort()) {
280
- const srcDir = path.join(skillsSrcDir, skillName);
281
- const destName = withPrefix(skillName);
282
- const destDir = path.join(SKILLS_DIR, destName);
283
- fs.mkdirSync(destDir, { recursive: true });
284
- for (const file of fs.readdirSync(srcDir).sort()) {
285
- await installFile(
286
- path.join(srcDir, file),
287
- path.join(destDir, file),
288
- `~/.claude/skills/${destName}/${file}`
257
+ function removeHoplaPermissions() {
258
+ const removed = [];
259
+ for (const settingsPath of SETTINGS_FILES) {
260
+ if (!fs.existsSync(settingsPath)) continue;
261
+ try {
262
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
263
+ if (!settings.permissions || !Array.isArray(settings.permissions.allow)) continue;
264
+ const before = settings.permissions.allow.length;
265
+ settings.permissions.allow = settings.permissions.allow.filter(
266
+ (p) => !ALL_HOPLA_PERMISSIONS.has(p)
289
267
  );
290
- }
268
+ if (settings.permissions.allow.length !== before) {
269
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
270
+ removed.push(`permissions from ${path.basename(settingsPath)}`);
271
+ }
272
+ } catch { /* ignore */ }
291
273
  }
274
+ return removed;
275
+ }
292
276
 
293
- log(`\n${GREEN}${BOLD}Skills installed!${RESET} Auto-activate without a slash command:\n`);
294
- for (const skillName of skillsToInstall.sort()) {
295
- log(` ${CYAN}${withPrefix(skillName)}${RESET}`);
277
+ function detectPlugin() {
278
+ for (const settingsPath of SETTINGS_FILES) {
279
+ if (!fs.existsSync(settingsPath)) continue;
280
+ try {
281
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
282
+ const plugins = settings.enabledPlugins || {};
283
+ if (Object.keys(plugins).some((key) => key.startsWith("hopla@"))) return true;
284
+ } catch { /* ignore */ }
296
285
  }
286
+ return false;
297
287
  }
298
288
 
299
- async function installAgents() {
300
- const agentsSrcDir = path.join(REPO_ROOT, "agents");
301
- if (!fs.existsSync(agentsSrcDir)) return;
289
+ async function migrate() {
290
+ log(`\n${BOLD}@hopla/claude-setup${RESET} Migrate (remove legacy CLI duplicates)${dryTag()}\n`);
302
291
 
303
- fs.mkdirSync(AGENTS_DIR, { recursive: true });
292
+ const removed = removeLegacyFiles();
304
293
 
305
- const agentFiles = fs.readdirSync(agentsSrcDir).filter((f) => f.endsWith(".md"));
306
- if (agentFiles.length === 0) return;
307
-
308
- log(`\n${CYAN}Installing agents...${RESET}`);
309
- for (const file of agentFiles.sort()) {
310
- await installFile(
311
- path.join(agentsSrcDir, file),
312
- path.join(AGENTS_DIR, file),
313
- `~/.claude/agents/${file}`
314
- );
294
+ if (removed.length === 0) {
295
+ log(`${GREEN}✓${RESET} No legacy files found. Nothing to clean up.\n`);
296
+ return;
315
297
  }
316
298
 
317
- log(`\n${GREEN}${BOLD}Agents installed!${RESET} Available for use:\n`);
318
- for (const file of agentFiles.sort()) {
319
- const name = file.replace(".md", "");
320
- log(` ${CYAN}${name}${RESET}`);
299
+ log(`${CYAN}${DRY_RUN ? "Would remove" : "Removed"} legacy CLI files:${RESET}`);
300
+ for (const item of removed) {
301
+ logRemoved(item);
321
302
  }
303
+ log(`\n${GREEN}${BOLD}Done!${RESET} ${DRY_RUN ? "Dry-run complete — no files were changed." : "Legacy duplicates removed. The plugin now handles commands, skills, agents, and hooks."}\n`);
322
304
  }
323
305
 
324
- async function installHooks() {
325
- const hooksSrcDir = path.join(REPO_ROOT, "hooks");
326
- if (!fs.existsSync(hooksSrcDir)) return;
327
-
328
- const hookFiles = fs.readdirSync(hooksSrcDir).filter((f) => f.endsWith(".js"));
329
- if (hookFiles.length === 0) return;
306
+ async function uninstall() {
307
+ log(`\n${BOLD}@hopla/claude-setup${RESET} Uninstall${dryTag()}\n`);
330
308
 
331
- const settingsPath = path.join(CLAUDE_DIR, "settings.json");
309
+ const itemsToRemove = [];
332
310
 
333
- // Read existing settings
334
- let settings = {};
335
- if (fs.existsSync(settingsPath)) {
336
- try {
337
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
338
- } catch {
339
- // keep defaults
340
- }
311
+ if (fs.existsSync(path.join(CLAUDE_DIR, "CLAUDE.md"))) {
312
+ itemsToRemove.push({ path: path.join(CLAUDE_DIR, "CLAUDE.md"), label: "~/.claude/CLAUDE.md", isDir: false });
341
313
  }
342
314
 
343
- const existingHooks = settings.hooks || {};
344
- const tscHookCmd = `${HOOKS_DIR}/tsc-check.js`;
345
- const envHookCmd = `${HOOKS_DIR}/env-protect.js`;
346
- const sessionHookCmd = `${HOOKS_DIR}/session-prime.js`;
347
-
348
- // Check what's already configured
349
- const postToolUseHooks = existingHooks.PostToolUse || [];
350
- const preToolUseHooks = existingHooks.PreToolUse || [];
351
- const sessionStartHooks = existingHooks.SessionStart || [];
352
-
353
- const hasTsc = postToolUseHooks.some((h) =>
354
- h.hooks?.some((hh) => hh.command === tscHookCmd)
355
- );
356
- const hasEnv = preToolUseHooks.some((h) =>
357
- h.hooks?.some((hh) => hh.command === envHookCmd)
358
- );
359
- const hasSession = sessionStartHooks.some((h) =>
360
- h.hooks?.some((hh) => hh.command === sessionHookCmd)
361
- );
362
-
363
- const toAdd = [];
364
- if (!hasTsc) toAdd.push(`PostToolUse(Write|Edit|MultiEdit) → tsc-check.js`);
365
- if (!hasEnv) toAdd.push(`PreToolUse(Read|Grep) → env-protect.js`);
366
-
367
- log(`\n${CYAN}Configuring hooks...${RESET}`);
315
+ const pluginActive = detectPlugin();
316
+ const hasMarketplaceCache = fs.existsSync(MARKETPLACE_CACHE);
368
317
 
369
- if (toAdd.length === 0 && hasSession) {
370
- log(`${GREEN}✓${RESET} Hooks already configured.\n`);
371
- return;
318
+ log(`The following will be removed:`);
319
+ for (const { label } of itemsToRemove) {
320
+ log(` ${RED}✕${RESET} ${label}`);
372
321
  }
373
-
374
- if (toAdd.length > 0) {
375
- log(` The following hooks will be added to ~/.claude/settings.json:\n`);
376
- for (const h of toAdd) {
377
- log(` ${CYAN}+${RESET} ${h}`);
322
+ log(` ${YELLOW}+${RESET} Legacy hopla-* commands, skills, hooks, agents (if any)`);
323
+ log(` ${YELLOW}+${RESET} Hopla permissions from settings.json and settings.local.json`);
324
+
325
+ if (pluginActive || hasMarketplaceCache) {
326
+ log(`\n${YELLOW}⚠${RESET} Claude Code plugin artifacts detected — this CLI cannot remove them:`);
327
+ if (pluginActive) {
328
+ log(` • Plugin ${BOLD}hopla@hopla-marketplace${RESET} is enabled.`);
329
+ log(` Run inside Claude Code: ${CYAN}/plugin uninstall hopla@hopla-marketplace${RESET}`);
330
+ }
331
+ if (hasMarketplaceCache) {
332
+ log(` • Marketplace cache: ${CYAN}${MARKETPLACE_CACHE}${RESET}`);
333
+ log(` Remove manually: ${CYAN}rm -rf "${MARKETPLACE_CACHE}"${RESET}`);
378
334
  }
379
335
  }
380
336
 
381
- // Ask about session-prime separately (opt-in)
382
- let installSessionPrime = false;
383
- if (!hasSession) {
384
- log(`\n ${YELLOW}Optional:${RESET} session-prime.js auto-loads project context on session start.`);
385
- installSessionPrime = await confirm(` Enable session-prime hook? (y/N) `);
386
- }
387
-
388
- if (toAdd.length === 0 && !installSessionPrime) {
389
- log(` ${YELLOW}↷${RESET} Skipped hooks — you can configure ~/.claude/settings.json manually\n`);
390
- return;
391
- }
392
-
393
- const ok = toAdd.length > 0
394
- ? await confirm(`\n Install these hooks? (y/N) `)
395
- : true;
396
-
337
+ const ok = await confirm(`\nContinue? (y/N) `);
397
338
  if (!ok) {
398
- log(` ${YELLOW}↷${RESET} Skipped hooks — you can configure ~/.claude/settings.json manually\n`);
339
+ log(`\nAborted.\n`);
399
340
  return;
400
341
  }
401
342
 
402
- // Copy hook files
403
- fs.mkdirSync(HOOKS_DIR, { recursive: true });
404
- for (const file of hookFiles) {
405
- await installFile(
406
- path.join(hooksSrcDir, file),
407
- path.join(HOOKS_DIR, file),
408
- `~/.claude/hooks/${file}`
409
- );
410
- // Make executable
411
- try {
412
- fs.chmodSync(path.join(HOOKS_DIR, file), 0o755);
413
- } catch {
414
- // Non-critical
343
+ log("");
344
+
345
+ for (const { path: itemPath, label, isDir } of itemsToRemove) {
346
+ if (fs.existsSync(itemPath)) {
347
+ safeRm(itemPath, { recursive: isDir });
348
+ logRemoved(label);
415
349
  }
416
350
  }
417
351
 
418
- // Build updated hooks config
419
- if (!settings.hooks) settings.hooks = {};
352
+ const removed = removeLegacyFiles();
353
+ for (const item of removed) {
354
+ logRemoved(item);
355
+ }
420
356
 
421
- if (!hasTsc) {
422
- if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
423
- settings.hooks.PostToolUse.push({
424
- matcher: "Write|Edit|MultiEdit",
425
- hooks: [{ type: "command", command: tscHookCmd }],
426
- });
357
+ const permsRemoved = removeHoplaPermissions();
358
+ for (const item of permsRemoved) {
359
+ logRemoved(item);
427
360
  }
428
- if (!hasEnv) {
429
- if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
430
- settings.hooks.PreToolUse.push({
431
- matcher: "Read|Grep",
432
- hooks: [{ type: "command", command: envHookCmd }],
433
- });
361
+
362
+ log(`\n${GREEN}${BOLD}Done!${RESET} ${DRY_RUN ? "Dry-run complete — no files were changed." : "CLI-managed files removed."}\n`);
363
+
364
+ if (pluginActive) {
365
+ log(`${YELLOW}⚠${RESET} Reminder: the plugin is still active. Run ${CYAN}/plugin uninstall hopla@hopla-marketplace${RESET} inside Claude Code to finish the uninstall.\n`);
434
366
  }
435
- if (installSessionPrime && !hasSession) {
436
- if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
437
- settings.hooks.SessionStart.push({
438
- matcher: "startup",
439
- hooks: [{ type: "command", command: sessionHookCmd }],
440
- });
367
+ if (hasMarketplaceCache) {
368
+ log(`${YELLOW}⚠${RESET} Marketplace cache remains at: ${CYAN}${MARKETPLACE_CACHE}${RESET}`);
369
+ log(` Remove with: ${CYAN}rm -rf "${MARKETPLACE_CACHE}"${RESET}\n`);
441
370
  }
442
-
443
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
444
- log(` ${GREEN}✓${RESET} Hooks configured.\n`);
445
371
  }
446
372
 
447
- const HOPLA_PERMISSIONS = [
448
- "Bash(git *)",
449
- "Bash(cd *)",
450
- "Bash(ls *)",
451
- "Bash(find *)",
452
- "Bash(cat *)",
453
- "Bash(head *)",
454
- "Bash(tail *)",
455
- "Bash(echo *)",
456
- ];
457
-
458
- const PLANNING_PERMISSIONS = [
459
- "Bash(git branch*)",
460
- "Bash(git log*)",
461
- "Bash(git status*)",
462
- ];
463
-
464
- const ALL_HOPLA_PERMISSIONS = new Set([...HOPLA_PERMISSIONS, ...PLANNING_PERMISSIONS]);
465
-
466
373
  async function setupPermissions() {
467
374
  const settingsPath = path.join(CLAUDE_DIR, "settings.json");
468
375
 
469
- // Read existing settings
470
376
  let settings = { permissions: { allow: [] } };
471
377
  if (fs.existsSync(settingsPath)) {
472
378
  try {
473
379
  settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
474
- } catch {
475
- // If parsing fails, keep defaults
476
- }
380
+ } catch { /* keep defaults */ }
477
381
  }
478
382
  if (!settings.permissions) settings.permissions = {};
479
383
  if (!settings.permissions.allow) settings.permissions.allow = [];
480
384
 
481
- const targetPermissions = PLANNING ? PLANNING_PERMISSIONS : HOPLA_PERMISSIONS;
385
+ const existing = new Set(settings.permissions.allow);
386
+ const toAdd = HOPLA_PERMISSIONS.filter((p) => !existing.has(p));
482
387
 
483
- if (PLANNING) {
484
- // Replace: remove all hopla-owned permissions, then add planning-only ones
485
- const cleaned = settings.permissions.allow.filter((p) => !ALL_HOPLA_PERMISSIONS.has(p));
486
- const toAdd = targetPermissions.filter((p) => !cleaned.includes(p));
487
- const toRemove = settings.permissions.allow.filter((p) => ALL_HOPLA_PERMISSIONS.has(p) && !targetPermissions.includes(p));
388
+ if (toAdd.length === 0) {
389
+ log(`${GREEN}✓${RESET} Permissions already configured.\n`);
390
+ return;
391
+ }
488
392
 
489
- if (toAdd.length === 0 && toRemove.length === 0) {
490
- log(`${GREEN}✓${RESET} Permissions already configured.\n`);
491
- return;
492
- }
393
+ log(`${CYAN}Configuring permissions...${RESET}`);
394
+ log(` The following will be added to ~/.claude/settings.json:\n`);
395
+ for (const p of toAdd) {
396
+ log(` ${CYAN}+${RESET} ${p}`);
397
+ }
493
398
 
494
- log(`${CYAN}Configuring permissions (planning mode)...${RESET}`);
495
- log(` The following changes will be made to ~/.claude/settings.json:\n`);
496
- for (const p of toRemove) {
497
- log(` ${RED}-${RESET} ${p}`);
498
- }
499
- for (const p of toAdd) {
500
- log(` ${CYAN}+${RESET} ${p}`);
501
- }
399
+ const ok = await confirm(`\n Add these permissions? (y/N) `);
400
+ if (!ok) {
401
+ log(` ${YELLOW}↷${RESET} Skipped you can add them manually to ~/.claude/settings.json\n`);
402
+ return;
403
+ }
502
404
 
503
- const ok = await confirm(`\n Apply these permission changes? (y/N) `);
504
- if (!ok) {
505
- log(` ${YELLOW}↷${RESET} Skipped — you can edit ~/.claude/settings.json manually\n`);
506
- return;
507
- }
405
+ settings.permissions.allow = [...settings.permissions.allow, ...toAdd];
406
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
407
+ log(` ${GREEN}✓${RESET} Permissions configured.\n`);
408
+ }
508
409
 
509
- settings.permissions.allow = [...cleaned, ...targetPermissions];
510
- } else {
511
- // Merge: add missing ones
512
- const existing = new Set(settings.permissions.allow);
513
- const toAdd = targetPermissions.filter((p) => !existing.has(p));
410
+ async function install() {
411
+ log(`\n${BOLD}@hopla/claude-setup${RESET} Global Rules Setup${dryTag()}\n`);
514
412
 
515
- if (toAdd.length === 0) {
516
- log(`${GREEN}✓${RESET} Permissions already configured.\n`);
517
- return;
518
- }
413
+ if (detectPlugin()) {
414
+ log(`${CYAN}ℹ${RESET} Plugin detected — commands, skills, agents, and hooks are managed by the plugin.`);
415
+ log(` This CLI only installs global rules (~/.claude/CLAUDE.md) and permissions.\n`);
416
+ }
519
417
 
520
- log(`${CYAN}Configuring permissions...${RESET}`);
521
- log(` The following will be added to ~/.claude/settings.json:\n`);
522
- for (const p of toAdd) {
523
- log(` ${CYAN}+${RESET} ${p}`);
418
+ const legacyRemoved = removeLegacyFiles();
419
+ if (legacyRemoved.length > 0) {
420
+ log(`${CYAN}${DRY_RUN ? "Would clean up" : "Cleaned up"} legacy CLI files:${RESET}`);
421
+ for (const item of legacyRemoved) {
422
+ log(` ${YELLOW}↷${RESET} ${DRY_RUN ? "Would remove" : "Removed"}: ${item}`);
524
423
  }
424
+ log("");
425
+ }
525
426
 
526
- const ok = await confirm(`\n Add these permissions? (y/N) `);
527
- if (!ok) {
528
- log(` ${YELLOW}↷${RESET} Skipped — you can add them manually to ~/.claude/settings.json\n`);
529
- return;
530
- }
427
+ safeMkdir(CLAUDE_DIR, { recursive: true });
531
428
 
532
- settings.permissions.allow = [...settings.permissions.allow, ...toAdd];
533
- }
429
+ log(`${CYAN}Installing global rules...${RESET}`);
430
+ await installFile(
431
+ path.join(REPO_ROOT, "global-rules.md"),
432
+ path.join(CLAUDE_DIR, "CLAUDE.md"),
433
+ "~/.claude/CLAUDE.md"
434
+ );
534
435
 
535
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
536
- log(` ${GREEN}✓${RESET} Permissions configured.\n`);
436
+ log(`\n${GREEN}${BOLD}Done!${RESET} ${DRY_RUN ? "Dry-run complete — no files were changed." : "Global rules installed."}\n`);
437
+
438
+ await setupPermissions();
537
439
  }
538
440
 
539
- const run = UNINSTALL ? uninstall : install;
441
+ const run = UNINSTALL ? uninstall : (MIGRATE ? migrate : install);
540
442
  run().catch((err) => {
541
443
  console.error("Failed:", err.message);
542
444
  process.exit(1);