@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.
- 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 +301 -399
- package/commands/code-review-fix.md +1 -1
- package/commands/create-prd.md +1 -1
- package/commands/execute.md +27 -8
- package/commands/guide.md +14 -8
- package/commands/guides/ai-optimized-codebase.md +1 -1
- package/commands/guides/mcp-integration.md +3 -3
- package/commands/guides/remote-coding.md +3 -3
- package/commands/guides/review-checklist.md +5 -5
- package/commands/guides/scaling-beyond-engineering.md +6 -9
- package/commands/init-project.md +12 -10
- package/commands/plan-feature.md +18 -2
- package/commands/rca.md +9 -4
- package/commands/review-plan.md +2 -2
- package/commands/validate.md +2 -2
- package/global-rules.md +18 -50
- package/hooks/env-protect.js +39 -8
- package/hooks/hooks.json +1 -1
- package/hooks/session-prime.js +39 -12
- package/hooks/tsc-check.js +21 -10
- package/package.json +1 -1
- package/skills/brainstorm/SKILL.md +7 -2
- package/skills/code-review/SKILL.md +4 -2
- package/skills/debug/SKILL.md +8 -0
- package/skills/execution-report/SKILL.md +2 -2
- package/skills/git/commit.md +20 -3
- package/skills/git/flow-detection.md +68 -0
- package/skills/git/pr.md +46 -16
- package/skills/prime/SKILL.md +1 -1
- package/skills/subagent-execution/SKILL.md +1 -1
- package/skills/tdd/SKILL.md +1 -1
- package/skills/verify/SKILL.md +11 -1
- 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,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
|
-
|
|
61
|
-
|
|
121
|
+
safeCopy(src, dest);
|
|
122
|
+
logInstalled(label, exists);
|
|
62
123
|
}
|
|
63
124
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
log(`\nAborted.\n`);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
162
|
+
function removeLegacyFiles() {
|
|
163
|
+
let removed = [];
|
|
136
164
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
//
|
|
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
|
-
);
|
|
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
|
-
|
|
243
|
-
for (const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
await installSkills();
|
|
255
|
-
await installAgents();
|
|
256
|
-
await installHooks();
|
|
254
|
+
return removed;
|
|
257
255
|
}
|
|
258
256
|
|
|
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}`
|
|
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
|
-
|
|
294
|
-
for (const
|
|
295
|
-
|
|
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
|
|
300
|
-
|
|
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
|
-
|
|
292
|
+
const removed = removeLegacyFiles();
|
|
304
293
|
|
|
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
|
-
);
|
|
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(
|
|
318
|
-
for (const
|
|
319
|
-
|
|
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
|
|
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;
|
|
306
|
+
async function uninstall() {
|
|
307
|
+
log(`\n${BOLD}@hopla/claude-setup${RESET} — Uninstall${dryTag()}\n`);
|
|
330
308
|
|
|
331
|
-
const
|
|
309
|
+
const itemsToRemove = [];
|
|
332
310
|
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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}`);
|
|
315
|
+
const pluginActive = detectPlugin();
|
|
316
|
+
const hasMarketplaceCache = fs.existsSync(MARKETPLACE_CACHE);
|
|
368
317
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
318
|
+
log(`The following will be removed:`);
|
|
319
|
+
for (const { label } of itemsToRemove) {
|
|
320
|
+
log(` ${RED}✕${RESET} ${label}`);
|
|
372
321
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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(
|
|
339
|
+
log(`\nAborted.\n`);
|
|
399
340
|
return;
|
|
400
341
|
}
|
|
401
342
|
|
|
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
|
|
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
|
-
|
|
419
|
-
|
|
352
|
+
const removed = removeLegacyFiles();
|
|
353
|
+
for (const item of removed) {
|
|
354
|
+
logRemoved(item);
|
|
355
|
+
}
|
|
420
356
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 (
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
385
|
+
const existing = new Set(settings.permissions.allow);
|
|
386
|
+
const toAdd = HOPLA_PERMISSIONS.filter((p) => !existing.has(p));
|
|
482
387
|
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
510
|
-
}
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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);
|