@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +209 -144
- package/agents/system-reviewer.md +13 -0
- package/cli.js +284 -399
- package/commands/execute.md +22 -3
- package/commands/guide.md +6 -0
- package/commands/init-project.md +9 -7
- package/commands/plan-feature.md +16 -0
- package/commands/rca.md +7 -2
- package/global-rules.md +18 -50
- package/hooks/session-prime.js +39 -12
- package/package.json +1 -1
- package/skills/brainstorm/SKILL.md +5 -0
- package/skills/code-review/SKILL.md +3 -1
- package/skills/debug/SKILL.md +8 -0
- package/skills/execution-report/SKILL.md +1 -1
- package/skills/git/commit.md +20 -3
- package/skills/git/flow-detection.md +68 -0
- package/skills/git/pr.md +46 -16
- package/skills/verify/SKILL.md +10 -0
- package/skills/worktree/SKILL.md +83 -38
- package/commands/end-to-end.md +0 -67
- package/commands/git-commit.md +0 -76
- 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
|
|
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
|
-
|
|
61
|
-
|
|
104
|
+
safeCopy(src, dest);
|
|
105
|
+
logInstalled(label, exists);
|
|
62
106
|
}
|
|
63
107
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
log(`\nAborted.\n`);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
145
|
+
function removeLegacyFiles() {
|
|
146
|
+
let removed = [];
|
|
136
147
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
243
|
-
for (const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
await installSkills();
|
|
255
|
-
await installAgents();
|
|
256
|
-
await installHooks();
|
|
237
|
+
return removed;
|
|
257
238
|
}
|
|
258
239
|
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
294
|
-
for (const
|
|
295
|
-
|
|
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
|
|
300
|
-
|
|
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
|
-
|
|
275
|
+
const removed = removeLegacyFiles();
|
|
304
276
|
|
|
305
|
-
|
|
306
|
-
|
|
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(
|
|
318
|
-
for (const
|
|
319
|
-
|
|
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
|
|
325
|
-
|
|
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
|
|
292
|
+
const itemsToRemove = [];
|
|
332
293
|
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
344
|
-
const
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
301
|
+
log(`The following will be removed:`);
|
|
302
|
+
for (const { label } of itemsToRemove) {
|
|
303
|
+
log(` ${RED}✕${RESET} ${label}`);
|
|
372
304
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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(
|
|
322
|
+
log(`\nAborted.\n`);
|
|
399
323
|
return;
|
|
400
324
|
}
|
|
401
325
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
for (const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
419
|
-
|
|
335
|
+
const removed = removeLegacyFiles();
|
|
336
|
+
for (const item of removed) {
|
|
337
|
+
logRemoved(item);
|
|
338
|
+
}
|
|
420
339
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 (
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
368
|
+
const existing = new Set(settings.permissions.allow);
|
|
369
|
+
const toAdd = HOPLA_PERMISSIONS.filter((p) => !existing.has(p));
|
|
482
370
|
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
510
|
-
}
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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);
|