@hopla/claude-setup 1.12.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,42 @@ 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
+ function safeWrite(target, content) {
54
+ if (DRY_RUN) return;
55
+ fs.writeFileSync(target, content);
56
+ }
57
+
58
+ function safeCopy(src, dest) {
59
+ if (DRY_RUN) return;
60
+ fs.copyFileSync(src, dest);
61
+ }
62
+
63
+ function safeMkdir(dir, opts) {
64
+ if (DRY_RUN) return;
65
+ fs.mkdirSync(dir, opts);
66
+ }
67
+
68
+ function logRemoved(label) {
69
+ const verb = DRY_RUN ? "Would remove" : "Removed";
70
+ log(` ${RED}✕${RESET} ${verb}: ${label}`);
71
+ }
72
+
73
+ function logInstalled(label, exists) {
74
+ const verb = DRY_RUN
75
+ ? (exists ? "Would update" : "Would install")
76
+ : (exists ? "Updated" : "Installed");
77
+ log(` ${GREEN}✓${RESET} ${verb}: ${label}`);
78
+ }
79
+
36
80
  async function confirm(question) {
37
81
  if (FORCE) return true;
38
82
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -57,486 +101,327 @@ async function installFile(src, dest, label) {
57
101
  }
58
102
  }
59
103
 
60
- fs.copyFileSync(src, dest);
61
- log(` ${GREEN}✓${RESET} ${exists ? "Updated" : "Installed"}: ${label}`);
104
+ safeCopy(src, dest);
105
+ logInstalled(label, exists);
62
106
  }
63
107
 
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
- }
108
+ // Hooks installed by previous CLI versions (v1.4.0 through v1.11.x)
109
+ const LEGACY_HOOK_COMMANDS = [
110
+ "tsc-check.js",
111
+ "env-protect.js",
112
+ "session-prime.js",
113
+ ];
72
114
 
73
- async function uninstall() {
74
- log(`\n${BOLD}@hopla/claude-setup${RESET} Uninstall\n`);
115
+ // Agents installed directly by v1.11.0 and v1.12.0 (no hopla- prefix)
116
+ // Must be cleaned up so the plugin-provided versions are the only source of truth
117
+ const LEGACY_AGENT_FILES = [
118
+ "code-reviewer.md",
119
+ "codebase-researcher.md",
120
+ "system-reviewer.md",
121
+ ];
75
122
 
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
- );
123
+ // Permissions added by the current CLI
124
+ const HOPLA_PERMISSIONS = [
125
+ "Bash(git *)",
126
+ "Bash(cd *)",
127
+ "Bash(ls *)",
128
+ "Bash(find *)",
129
+ "Bash(cat *)",
130
+ "Bash(head *)",
131
+ "Bash(tail *)",
132
+ "Bash(echo *)",
133
+ ];
84
134
 
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
- }
135
+ // Permissions from older CLI versions (e.g. PLANNING_PERMISSIONS from v1.11.0)
136
+ // Included in cleanup so settings.json does not accumulate obsolete entries
137
+ const LEGACY_PERMISSIONS = [
138
+ "Bash(git branch*)",
139
+ "Bash(git log*)",
140
+ "Bash(git status*)",
141
+ ];
125
142
 
126
- log(`The following will be removed:`);
127
- for (const { label } of itemsToRemove) {
128
- log(` ${RED}✕${RESET} ${label}`);
129
- }
143
+ const ALL_HOPLA_PERMISSIONS = new Set([...HOPLA_PERMISSIONS, ...LEGACY_PERMISSIONS]);
130
144
 
131
- const ok = await confirm(`\nContinue? (y/N) `);
132
- if (!ok) {
133
- log(`\nAborted.\n`);
134
- return;
135
- }
145
+ function removeLegacyFiles() {
146
+ let removed = [];
136
147
 
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}`);
148
+ // hopla-* commands
149
+ if (fs.existsSync(COMMANDS_DIR)) {
150
+ for (const file of fs.readdirSync(COMMANDS_DIR)) {
151
+ const filePath = path.join(COMMANDS_DIR, file);
152
+ if (file.startsWith("hopla-") && fs.statSync(filePath).isFile()) {
153
+ safeRm(filePath);
154
+ removed.push(`~/.claude/commands/${file}`);
155
+ }
144
156
  }
145
157
  }
146
158
 
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);
159
+ // hopla-* skills
160
+ if (fs.existsSync(SKILLS_DIR)) {
161
+ for (const entry of fs.readdirSync(SKILLS_DIR)) {
162
+ const entryPath = path.join(SKILLS_DIR, entry);
163
+ if (entry.startsWith("hopla-") && fs.statSync(entryPath).isDirectory()) {
164
+ safeRm(entryPath, { recursive: true });
165
+ removed.push(`~/.claude/skills/${entry}/`);
166
+ }
160
167
  }
161
168
  }
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}`);
169
+
170
+ // hopla hook files
171
+ if (fs.existsSync(HOOKS_DIR)) {
172
+ for (const hookFile of LEGACY_HOOK_COMMANDS) {
173
+ const hookPath = path.join(HOOKS_DIR, hookFile);
174
+ if (fs.existsSync(hookPath)) {
175
+ safeRm(hookPath);
176
+ removed.push(`~/.claude/hooks/${hookFile}`);
177
+ }
178
+ }
179
+ if (!DRY_RUN) {
180
+ try {
181
+ const remaining = fs.readdirSync(HOOKS_DIR);
182
+ if (remaining.length === 0) fs.rmSync(HOOKS_DIR, { recursive: true });
183
+ } catch { /* ignore */ }
166
184
  }
167
- log("");
168
185
  }
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
186
 
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
- );
187
+ // Legacy agents (v1.11.0 / v1.12.0 installed these directly)
188
+ if (fs.existsSync(AGENTS_DIR)) {
189
+ for (const agentFile of LEGACY_AGENT_FILES) {
190
+ const agentPath = path.join(AGENTS_DIR, agentFile);
191
+ if (fs.existsSync(agentPath)) {
192
+ safeRm(agentPath);
193
+ removed.push(`~/.claude/agents/${agentFile}`);
194
+ }
195
+ }
196
+ if (!DRY_RUN) {
197
+ try {
198
+ const remaining = fs.readdirSync(AGENTS_DIR);
199
+ if (remaining.length === 0) fs.rmSync(AGENTS_DIR, { recursive: true });
200
+ } catch { /* ignore */ }
239
201
  }
240
202
  }
241
203
 
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`);
204
+ // hopla hook entries from settings.json AND settings.local.json
205
+ for (const settingsPath of SETTINGS_FILES) {
206
+ if (!fs.existsSync(settingsPath)) continue;
207
+ try {
208
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
209
+ let changed = false;
210
+
211
+ if (settings.hooks) {
212
+ for (const [event, matchers] of Object.entries(settings.hooks)) {
213
+ if (!Array.isArray(matchers)) continue;
214
+ const filtered = matchers.filter((m) => {
215
+ if (!m.hooks || !Array.isArray(m.hooks)) return true;
216
+ const isHopla = m.hooks.every((h) =>
217
+ LEGACY_HOOK_COMMANDS.some((cmd) => h.command && h.command.includes(cmd))
218
+ );
219
+ return !isHopla;
220
+ });
221
+ if (filtered.length !== matchers.length) {
222
+ settings.hooks[event] = filtered;
223
+ if (filtered.length === 0) delete settings.hooks[event];
224
+ changed = true;
225
+ }
226
+ }
227
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
228
+ }
229
+
230
+ if (changed) {
231
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
232
+ removed.push(`hooks from ${path.basename(settingsPath)}`);
233
+ }
234
+ } catch { /* ignore parse errors */ }
251
235
  }
252
236
 
253
- await setupPermissions();
254
- await installSkills();
255
- await installAgents();
256
- await installHooks();
237
+ return removed;
257
238
  }
258
239
 
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}`
240
+ function removeHoplaPermissions() {
241
+ const removed = [];
242
+ for (const settingsPath of SETTINGS_FILES) {
243
+ if (!fs.existsSync(settingsPath)) continue;
244
+ try {
245
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
246
+ if (!settings.permissions || !Array.isArray(settings.permissions.allow)) continue;
247
+ const before = settings.permissions.allow.length;
248
+ settings.permissions.allow = settings.permissions.allow.filter(
249
+ (p) => !ALL_HOPLA_PERMISSIONS.has(p)
289
250
  );
290
- }
251
+ if (settings.permissions.allow.length !== before) {
252
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
253
+ removed.push(`permissions from ${path.basename(settingsPath)}`);
254
+ }
255
+ } catch { /* ignore */ }
291
256
  }
257
+ return removed;
258
+ }
292
259
 
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}`);
260
+ function detectPlugin() {
261
+ for (const settingsPath of SETTINGS_FILES) {
262
+ if (!fs.existsSync(settingsPath)) continue;
263
+ try {
264
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
265
+ const plugins = settings.enabledPlugins || {};
266
+ if (Object.keys(plugins).some((key) => key.startsWith("hopla@"))) return true;
267
+ } catch { /* ignore */ }
296
268
  }
269
+ return false;
297
270
  }
298
271
 
299
- async function installAgents() {
300
- const agentsSrcDir = path.join(REPO_ROOT, "agents");
301
- if (!fs.existsSync(agentsSrcDir)) return;
272
+ async function migrate() {
273
+ log(`\n${BOLD}@hopla/claude-setup${RESET} Migrate (remove legacy CLI duplicates)${dryTag()}\n`);
302
274
 
303
- fs.mkdirSync(AGENTS_DIR, { recursive: true });
275
+ const removed = removeLegacyFiles();
304
276
 
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
- );
277
+ if (removed.length === 0) {
278
+ log(`${GREEN}✓${RESET} No legacy files found. Nothing to clean up.\n`);
279
+ return;
315
280
  }
316
281
 
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}`);
282
+ log(`${CYAN}${DRY_RUN ? "Would remove" : "Removed"} legacy CLI files:${RESET}`);
283
+ for (const item of removed) {
284
+ logRemoved(item);
321
285
  }
286
+ 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
287
  }
323
288
 
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;
289
+ async function uninstall() {
290
+ log(`\n${BOLD}@hopla/claude-setup${RESET} Uninstall${dryTag()}\n`);
330
291
 
331
- const settingsPath = path.join(CLAUDE_DIR, "settings.json");
292
+ const itemsToRemove = [];
332
293
 
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
- }
294
+ if (fs.existsSync(path.join(CLAUDE_DIR, "CLAUDE.md"))) {
295
+ itemsToRemove.push({ path: path.join(CLAUDE_DIR, "CLAUDE.md"), label: "~/.claude/CLAUDE.md", isDir: false });
341
296
  }
342
297
 
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}`);
298
+ const pluginActive = detectPlugin();
299
+ const hasMarketplaceCache = fs.existsSync(MARKETPLACE_CACHE);
368
300
 
369
- if (toAdd.length === 0 && hasSession) {
370
- log(`${GREEN}✓${RESET} Hooks already configured.\n`);
371
- return;
301
+ log(`The following will be removed:`);
302
+ for (const { label } of itemsToRemove) {
303
+ log(` ${RED}✕${RESET} ${label}`);
372
304
  }
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}`);
305
+ log(` ${YELLOW}+${RESET} Legacy hopla-* commands, skills, hooks, agents (if any)`);
306
+ log(` ${YELLOW}+${RESET} Hopla permissions from settings.json and settings.local.json`);
307
+
308
+ if (pluginActive || hasMarketplaceCache) {
309
+ log(`\n${YELLOW}⚠${RESET} Claude Code plugin artifacts detected — this CLI cannot remove them:`);
310
+ if (pluginActive) {
311
+ log(` • Plugin ${BOLD}hopla@hopla-marketplace${RESET} is enabled.`);
312
+ log(` Run inside Claude Code: ${CYAN}/plugin uninstall hopla@hopla-marketplace${RESET}`);
313
+ }
314
+ if (hasMarketplaceCache) {
315
+ log(` • Marketplace cache: ${CYAN}${MARKETPLACE_CACHE}${RESET}`);
316
+ log(` Remove manually: ${CYAN}rm -rf "${MARKETPLACE_CACHE}"${RESET}`);
378
317
  }
379
318
  }
380
319
 
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
-
320
+ const ok = await confirm(`\nContinue? (y/N) `);
397
321
  if (!ok) {
398
- log(` ${YELLOW}↷${RESET} Skipped hooks — you can configure ~/.claude/settings.json manually\n`);
322
+ log(`\nAborted.\n`);
399
323
  return;
400
324
  }
401
325
 
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
326
+ log("");
327
+
328
+ for (const { path: itemPath, label, isDir } of itemsToRemove) {
329
+ if (fs.existsSync(itemPath)) {
330
+ safeRm(itemPath, { recursive: isDir });
331
+ logRemoved(label);
415
332
  }
416
333
  }
417
334
 
418
- // Build updated hooks config
419
- if (!settings.hooks) settings.hooks = {};
335
+ const removed = removeLegacyFiles();
336
+ for (const item of removed) {
337
+ logRemoved(item);
338
+ }
420
339
 
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
- });
340
+ const permsRemoved = removeHoplaPermissions();
341
+ for (const item of permsRemoved) {
342
+ logRemoved(item);
427
343
  }
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
- });
344
+
345
+ log(`\n${GREEN}${BOLD}Done!${RESET} ${DRY_RUN ? "Dry-run complete — no files were changed." : "CLI-managed files removed."}\n`);
346
+
347
+ if (pluginActive) {
348
+ 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
349
  }
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
- });
350
+ if (hasMarketplaceCache) {
351
+ log(`${YELLOW}⚠${RESET} Marketplace cache remains at: ${CYAN}${MARKETPLACE_CACHE}${RESET}`);
352
+ log(` Remove with: ${CYAN}rm -rf "${MARKETPLACE_CACHE}"${RESET}\n`);
441
353
  }
442
-
443
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
444
- log(` ${GREEN}✓${RESET} Hooks configured.\n`);
445
354
  }
446
355
 
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
356
  async function setupPermissions() {
467
357
  const settingsPath = path.join(CLAUDE_DIR, "settings.json");
468
358
 
469
- // Read existing settings
470
359
  let settings = { permissions: { allow: [] } };
471
360
  if (fs.existsSync(settingsPath)) {
472
361
  try {
473
362
  settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
474
- } catch {
475
- // If parsing fails, keep defaults
476
- }
363
+ } catch { /* keep defaults */ }
477
364
  }
478
365
  if (!settings.permissions) settings.permissions = {};
479
366
  if (!settings.permissions.allow) settings.permissions.allow = [];
480
367
 
481
- const targetPermissions = PLANNING ? PLANNING_PERMISSIONS : HOPLA_PERMISSIONS;
368
+ const existing = new Set(settings.permissions.allow);
369
+ const toAdd = HOPLA_PERMISSIONS.filter((p) => !existing.has(p));
482
370
 
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));
371
+ if (toAdd.length === 0) {
372
+ log(`${GREEN}✓${RESET} Permissions already configured.\n`);
373
+ return;
374
+ }
488
375
 
489
- if (toAdd.length === 0 && toRemove.length === 0) {
490
- log(`${GREEN}✓${RESET} Permissions already configured.\n`);
491
- return;
492
- }
376
+ log(`${CYAN}Configuring permissions...${RESET}`);
377
+ log(` The following will be added to ~/.claude/settings.json:\n`);
378
+ for (const p of toAdd) {
379
+ log(` ${CYAN}+${RESET} ${p}`);
380
+ }
493
381
 
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
- }
382
+ const ok = await confirm(`\n Add these permissions? (y/N) `);
383
+ if (!ok) {
384
+ log(` ${YELLOW}↷${RESET} Skipped you can add them manually to ~/.claude/settings.json\n`);
385
+ return;
386
+ }
502
387
 
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
- }
388
+ settings.permissions.allow = [...settings.permissions.allow, ...toAdd];
389
+ safeWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
390
+ log(` ${GREEN}✓${RESET} Permissions configured.\n`);
391
+ }
508
392
 
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));
393
+ async function install() {
394
+ log(`\n${BOLD}@hopla/claude-setup${RESET} Global Rules Setup${dryTag()}\n`);
514
395
 
515
- if (toAdd.length === 0) {
516
- log(`${GREEN}✓${RESET} Permissions already configured.\n`);
517
- return;
518
- }
396
+ if (detectPlugin()) {
397
+ log(`${CYAN}ℹ${RESET} Plugin detected — commands, skills, agents, and hooks are managed by the plugin.`);
398
+ log(` This CLI only installs global rules (~/.claude/CLAUDE.md) and permissions.\n`);
399
+ }
519
400
 
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}`);
401
+ const legacyRemoved = removeLegacyFiles();
402
+ if (legacyRemoved.length > 0) {
403
+ log(`${CYAN}${DRY_RUN ? "Would clean up" : "Cleaned up"} legacy CLI files:${RESET}`);
404
+ for (const item of legacyRemoved) {
405
+ log(` ${YELLOW}↷${RESET} ${DRY_RUN ? "Would remove" : "Removed"}: ${item}`);
524
406
  }
407
+ log("");
408
+ }
525
409
 
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
- }
410
+ safeMkdir(CLAUDE_DIR, { recursive: true });
531
411
 
532
- settings.permissions.allow = [...settings.permissions.allow, ...toAdd];
533
- }
412
+ log(`${CYAN}Installing global rules...${RESET}`);
413
+ await installFile(
414
+ path.join(REPO_ROOT, "global-rules.md"),
415
+ path.join(CLAUDE_DIR, "CLAUDE.md"),
416
+ "~/.claude/CLAUDE.md"
417
+ );
534
418
 
535
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
536
- log(` ${GREEN}✓${RESET} Permissions configured.\n`);
419
+ log(`\n${GREEN}${BOLD}Done!${RESET} ${DRY_RUN ? "Dry-run complete — no files were changed." : "Global rules installed."}\n`);
420
+
421
+ await setupPermissions();
537
422
  }
538
423
 
539
- const run = UNINSTALL ? uninstall : install;
424
+ const run = UNINSTALL ? uninstall : (MIGRATE ? migrate : install);
540
425
  run().catch((err) => {
541
426
  console.error("Failed:", err.message);
542
427
  process.exit(1);