chisle 2.0.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/bin/install.js ADDED
@@ -0,0 +1,485 @@
1
+ #!/usr/bin/env node
2
+ // chisle — cross-platform installer.
3
+ //
4
+ // Detects the AI coding agents on this machine and installs Chisle for each:
5
+ // - Claude Code → plugin (marketplace add + install), fallback to standalone
6
+ // hooks + settings.json merge + statusline badge
7
+ // - Gemini CLI → gemini extensions install
8
+ // - Codex → fenced ruleset appended to ~/.codex/AGENTS.md
9
+ // - Cursor/Windsurf/Cline/Kiro/Copilot → project rule file dropped into CWD
10
+ //
11
+ // Usage:
12
+ // npx chisle auto-detect + install
13
+ // npx chisle --list show detected agents, install nothing
14
+ // npx chisle --only claude install for one agent
15
+ // npx chisle --dry-run print actions, change nothing
16
+ // npx chisle --uninstall remove what we installed
17
+ // npx chisle --help
18
+ //
19
+ // Pure stdlib, zero runtime deps.
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const cp = require('child_process');
27
+
28
+ const SETTINGS = require('./lib/settings');
29
+
30
+ const REPO = 'JayPokale/Chisle';
31
+ const IS_WIN = process.platform === 'win32';
32
+
33
+ // Repo root = parent of bin/. Installed package or local clone both work.
34
+ const REPO_ROOT = path.resolve(__dirname, '..');
35
+
36
+ // ── Provider matrix ─────────────────────────────────────────────────────────
37
+ // scope: 'global' installs once for the whole machine/user.
38
+ // 'project' writes a per-repo rule file into the current directory
39
+ // (that's how Cursor/Cline/Copilot rules actually work).
40
+ const PROVIDERS = [
41
+ { id: 'claude', label: 'Claude Code', scope: 'global', detect: 'cmd:claude' },
42
+ { id: 'gemini', label: 'Gemini CLI', scope: 'global', detect: 'cmd:gemini' },
43
+ { id: 'codex', label: 'Codex CLI', scope: 'global', detect: 'cmd:codex||dir:~/.codex' },
44
+ { id: 'cursor', label: 'Cursor', scope: 'project', detect: 'cmd:cursor||dir:~/.cursor',
45
+ rule: '.cursor/rules/chisle.mdc' },
46
+ { id: 'windsurf', label: 'Windsurf', scope: 'project', detect: 'cmd:windsurf||dir:~/.windsurf||dir:~/.codeium/windsurf',
47
+ rule: '.windsurf/rules/chisle.md' },
48
+ { id: 'cline', label: 'Cline', scope: 'project', detect: 'vscode-ext:cline',
49
+ rule: '.clinerules/chisle.md' },
50
+ { id: 'kiro', label: 'Kiro', scope: 'project', detect: 'cmd:kiro||dir:~/.kiro',
51
+ rule: '.kiro/steering/chisle.md' },
52
+ { id: 'copilot', label: 'GitHub Copilot',scope: 'project', detect: 'vscode-ext:github.copilot||vscode-ext:github.copilot-chat',
53
+ rule: '.github/copilot-instructions.md' },
54
+ ];
55
+
56
+ // ── argv ────────────────────────────────────────────────────────────────────
57
+ function parseArgs(argv) {
58
+ const opts = { dryRun: false, force: false, list: false, uninstall: false,
59
+ noColor: false, help: false, only: [], configDir: null };
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const a = argv[i];
62
+ switch (a) {
63
+ case '--dry-run': opts.dryRun = true; break;
64
+ case '--force': opts.force = true; break;
65
+ case '--list': opts.list = true; break;
66
+ case '--uninstall': case '-u': opts.uninstall = true; break;
67
+ case '--no-color': opts.noColor = true; break;
68
+ case '-h': case '--help': opts.help = true; break;
69
+ case '--': break; // npx sometimes forwards the literal end-of-options marker
70
+ case '--only': {
71
+ const v = argv[++i];
72
+ if (!v) die('error: --only requires an agent id');
73
+ opts.only.push(v);
74
+ break;
75
+ }
76
+ case '--config-dir': {
77
+ const v = argv[++i];
78
+ if (!v || v.startsWith('--')) die('error: --config-dir requires a path');
79
+ opts.configDir = expandHome(v);
80
+ break;
81
+ }
82
+ default: die(`error: unknown flag: ${a}\nrun 'npx chisle --help' for usage`);
83
+ }
84
+ }
85
+ if (opts.only.length) {
86
+ const known = new Set(PROVIDERS.map(p => p.id));
87
+ for (const id of opts.only) if (!known.has(id)) die(`error: unknown agent: ${id}\n see 'npx chisle --list'`);
88
+ }
89
+ return opts;
90
+ }
91
+
92
+ function die(msg) { process.stderr.write(msg + '\n'); process.exit(2); }
93
+
94
+ // ── color ───────────────────────────────────────────────────────────────────
95
+ function makeChalk(noColor) {
96
+ const use = !noColor && process.stdout.isTTY && !process.env.NO_COLOR;
97
+ const wrap = (c) => (s) => use ? `\x1b[${c}m${s}\x1b[0m` : s;
98
+ return { orange: wrap('38;5;172'), dim: wrap('2'), red: wrap('31'), green: wrap('32'), yellow: wrap('33') };
99
+ }
100
+
101
+ // ── detection ───────────────────────────────────────────────────────────────
102
+ function expandHome(p) { return p.replace(/^~/, os.homedir()).replace(/^\$HOME/, os.homedir()); }
103
+
104
+ function hasCmd(cmd) {
105
+ try {
106
+ if (IS_WIN) return cp.spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0;
107
+ return cp.spawnSync('sh', ['-c', `command -v '${cmd.replace(/'/g, "")}'`], { stdio: 'ignore' }).status === 0;
108
+ } catch (_) { return false; }
109
+ }
110
+
111
+ function safeStat(p, method) { try { return fs.statSync(p)[method](); } catch (_) { return false; } }
112
+
113
+ function vscodeExt(needle) {
114
+ const roots = ['.vscode/extensions', '.vscode-server/extensions', '.cursor/extensions', '.windsurf/extensions']
115
+ .map(r => path.join(os.homedir(), r));
116
+ const re = new RegExp(needle.replace(/\./g, '\\.'), 'i');
117
+ for (const r of roots) {
118
+ if (!fs.existsSync(r)) continue;
119
+ try { if (fs.readdirSync(r).some(e => re.test(e))) return true; } catch (_) {}
120
+ }
121
+ return false;
122
+ }
123
+
124
+ function detectMatch(spec) {
125
+ for (const clause of spec.split('||')) {
126
+ const c = clause.trim();
127
+ const colon = c.indexOf(':');
128
+ const kind = c.slice(0, colon), val = expandHome(c.slice(colon + 1));
129
+ let ok = false;
130
+ if (kind === 'cmd') ok = hasCmd(val);
131
+ else if (kind === 'dir') ok = safeStat(val, 'isDirectory');
132
+ else if (kind === 'file') ok = safeStat(val, 'isFile');
133
+ else if (kind === 'vscode-ext') ok = vscodeExt(val);
134
+ if (ok) return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ // ── spawn helpers ───────────────────────────────────────────────────────────
140
+ function quoteWin(a) {
141
+ if (!IS_WIN) return a;
142
+ if (a === '' || /[\s"]/.test(a)) return '"' + String(a).replace(/\\(?=\\*"|$)/g, '\\\\').replace(/"/g, '\\"') + '"';
143
+ return a;
144
+ }
145
+ function spawnX(cmd, args, o) {
146
+ if (IS_WIN) return cp.spawnSync(`${cmd} ${args.map(quoteWin).join(' ')}`, [], Object.assign({ shell: true }, o || {}));
147
+ return cp.spawnSync(cmd, args, o || {});
148
+ }
149
+ function run(cmd, args, dry) {
150
+ if (dry) { process.stdout.write(` would run: ${cmd} ${args.join(' ')}\n`); return { status: 0 }; }
151
+ process.stdout.write(` $ ${cmd} ${args.join(' ')}\n`);
152
+ return spawnX(cmd, args, { stdio: 'inherit' });
153
+ }
154
+ function capture(cmd, args) {
155
+ try { return spawnX(cmd, args, { encoding: 'utf8' }); } catch (_) { return { status: 1, stdout: '', stderr: '' }; }
156
+ }
157
+
158
+ // ── config dir ──────────────────────────────────────────────────────────────
159
+ function claudeDir(opts) {
160
+ if (opts.configDir) return opts.configDir;
161
+ if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR;
162
+ return path.join(os.homedir(), '.claude');
163
+ }
164
+
165
+ // ── Claude Code ─────────────────────────────────────────────────────────────
166
+ function installClaude(ctx) {
167
+ const { say, note, warn, opts, results } = ctx;
168
+ results.detected++;
169
+ say('→ Claude Code detected');
170
+
171
+ let pluginOK = false;
172
+ if (hasCmd('claude')) {
173
+ let already = false;
174
+ if (!opts.force) {
175
+ const r = capture('claude', ['plugin', 'list']);
176
+ if (r.status === 0 && /chisle/i.test(r.stdout || '')) already = true;
177
+ }
178
+ if (already) {
179
+ note(' chisle plugin already installed (use --force to reinstall)');
180
+ results.skipped.push(['claude', 'plugin already installed']);
181
+ pluginOK = true;
182
+ } else {
183
+ const r1 = run('claude', ['plugin', 'marketplace', 'add', REPO], opts.dryRun);
184
+ const r2 = run('claude', ['plugin', 'install', 'chisle@chisle'], opts.dryRun);
185
+ if ((r1.status || 0) === 0 && (r2.status || 0) === 0) { results.installed.push('claude'); pluginOK = true; }
186
+ else warn(' claude plugin install failed — falling back to standalone hooks');
187
+ }
188
+ } else {
189
+ note(' claude CLI not on PATH — wiring standalone hooks in settings.json');
190
+ }
191
+
192
+ if (!pluginOK) {
193
+ const r = installClaudeHooks(ctx);
194
+ if (r === 'ok') results.installed.push('claude-hooks');
195
+ else if (r === 'skip') results.skipped.push(['claude-hooks', 'already wired']);
196
+ else results.failed.push(['claude-hooks', r]);
197
+ } else {
198
+ note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit + PostToolUse');
199
+ }
200
+ process.stdout.write('\n');
201
+ }
202
+
203
+ // Standalone wiring — copy hooks into <configDir>/chisle-hooks/ and merge
204
+ // settings.json. Used when the plugin path is unavailable.
205
+ function installClaudeHooks(ctx) {
206
+ const { opts, warn, note } = ctx;
207
+ const cfg = claudeDir(opts);
208
+ const hooksSrc = path.join(REPO_ROOT, 'hooks');
209
+ const hooksDst = path.join(cfg, 'chisle-hooks');
210
+ const settingsPath = path.join(cfg, 'settings.json');
211
+ const HOOK_FILES = ['package.json', 'chisle-config.js', 'chisle-activate.js',
212
+ 'chisle-mode-tracker.js', 'chisle-compress-output.js',
213
+ 'chisle-statusline.sh', 'chisle-statusline.ps1'];
214
+
215
+ if (opts.dryRun) {
216
+ note(` would copy ${HOOK_FILES.length} hook files → ${hooksDst}`);
217
+ note(` would merge SessionStart + UserPromptSubmit + PostToolUse + statusline → ${settingsPath}`);
218
+ return 'ok';
219
+ }
220
+
221
+ fs.mkdirSync(hooksDst, { recursive: true });
222
+ for (const f of HOOK_FILES) {
223
+ const s = path.join(hooksSrc, f);
224
+ if (fs.existsSync(s)) fs.copyFileSync(s, path.join(hooksDst, f));
225
+ }
226
+ try { fs.chmodSync(path.join(hooksDst, 'chisle-statusline.sh'), 0o755); } catch (_) {}
227
+
228
+ const settings = SETTINGS.readSettings(settingsPath);
229
+ if (settings === null) { warn(' settings.json unparseable; not touching it.'); return 'settings.json unparseable'; }
230
+
231
+ const bak = settingsPath + '.bak';
232
+ if (fs.existsSync(settingsPath) && !fs.existsSync(bak)) { try { fs.copyFileSync(settingsPath, bak); } catch (_) {} }
233
+
234
+ const node = process.execPath;
235
+ const activate = path.join(hooksDst, 'chisle-activate.js');
236
+ const tracker = path.join(hooksDst, 'chisle-mode-tracker.js');
237
+ const compress = path.join(hooksDst, 'chisle-compress-output.js');
238
+
239
+ SETTINGS.addCommandHook(settings, 'SessionStart',
240
+ { command: `"${node}" "${activate}"`, marker: 'chisle-activate', timeout: 5, statusMessage: 'Loading chisle mode...' });
241
+ SETTINGS.addCommandHook(settings, 'UserPromptSubmit',
242
+ { command: `"${node}" "${tracker}"`, marker: 'chisle-mode-tracker', timeout: 5, statusMessage: 'Tracking chisle mode...' });
243
+ SETTINGS.addCommandHook(settings, 'PostToolUse',
244
+ { command: `"${node}" "${compress}"`, marker: 'chisle-compress-output', timeout: 10,
245
+ matcher: 'Bash|Agent|WebFetch|WebSearch|Grep|Glob|mcp__.*', statusMessage: 'Compressing tool output...' });
246
+
247
+ const psHost = IS_WIN && hasCmd('pwsh') ? 'pwsh' : (IS_WIN ? 'powershell' : null);
248
+ const slCmd = IS_WIN
249
+ ? `${psHost} -NoProfile -ExecutionPolicy Bypass -File "${path.join(hooksDst, 'chisle-statusline.ps1')}"`
250
+ : `bash "${path.join(hooksDst, 'chisle-statusline.sh')}"`;
251
+ if (!settings.statusLine) {
252
+ settings.statusLine = { type: 'command', command: slCmd };
253
+ process.stdout.write(' statusline badge configured.\n');
254
+ } else {
255
+ const existing = typeof settings.statusLine === 'string' ? settings.statusLine : (settings.statusLine.command || '');
256
+ if (existing.includes('chisle-statusline')) process.stdout.write(' statusline badge already configured.\n');
257
+ else process.stdout.write(' NOTE: existing statusline detected — CHISLE badge NOT added (see docs/install-windows.md).\n');
258
+ }
259
+
260
+ SETTINGS.validateHookFields(settings);
261
+ SETTINGS.writeSettings(settingsPath, settings);
262
+ process.stdout.write(` hooks wired in ${settingsPath}\n`);
263
+ return 'ok';
264
+ }
265
+
266
+ // ── Gemini ──────────────────────────────────────────────────────────────────
267
+ function installGemini(ctx) {
268
+ const { say, note, opts, results } = ctx;
269
+ results.detected++;
270
+ say('→ Gemini CLI detected');
271
+ if (!opts.force) {
272
+ const r = capture('gemini', ['extensions', 'list']);
273
+ if (r.status === 0 && /chisle/i.test(r.stdout || '')) {
274
+ note(' chisle extension already installed (use --force)');
275
+ results.skipped.push(['gemini', 'already installed']); process.stdout.write('\n'); return;
276
+ }
277
+ }
278
+ const r = run('gemini', ['extensions', 'install', `https://github.com/${REPO}`], opts.dryRun);
279
+ if ((r.status || 0) === 0) results.installed.push('gemini');
280
+ else results.failed.push(['gemini', 'gemini extensions install failed']);
281
+ process.stdout.write('\n');
282
+ }
283
+
284
+ // ── Codex (fenced ruleset in ~/.codex/AGENTS.md) ────────────────────────────
285
+ const FENCE_BEGIN = '<!-- chisle-begin -->';
286
+ const FENCE_END = '<!-- chisle-end -->';
287
+
288
+ function installCodex(ctx) {
289
+ const { say, note, opts, results } = ctx;
290
+ results.detected++;
291
+ say('→ Codex detected');
292
+ const target = path.join(os.homedir(), '.codex', 'AGENTS.md');
293
+ const body = fs.readFileSync(path.join(REPO_ROOT, 'AGENTS.md'), 'utf8').trimEnd() + '\n';
294
+ const block = `${FENCE_BEGIN}\n${body}${FENCE_END}\n`;
295
+
296
+ if (opts.dryRun) { note(` would write chisle ruleset → ${target}`); results.installed.push('codex'); process.stdout.write('\n'); return; }
297
+
298
+ try {
299
+ fs.mkdirSync(path.dirname(target), { recursive: true });
300
+ let existing = '';
301
+ try { existing = fs.readFileSync(target, 'utf8'); } catch (_) {}
302
+ if (existing.includes(FENCE_BEGIN)) {
303
+ if (opts.force) {
304
+ const rewritten = existing.replace(new RegExp(`${FENCE_BEGIN}[\\s\\S]*?${FENCE_END}\\n?`), block);
305
+ fs.writeFileSync(target, rewritten, { mode: 0o644 });
306
+ process.stdout.write(` refreshed ruleset in ${target}\n`);
307
+ } else { note(` ${target} already contains chisle ruleset (--force to refresh)`); }
308
+ results.skipped.push(['codex', 'already present']);
309
+ } else {
310
+ const sep = existing && !existing.endsWith('\n\n') ? (existing.endsWith('\n') ? '\n' : '\n\n') : '';
311
+ fs.writeFileSync(target, existing + sep + block, { mode: 0o644 });
312
+ process.stdout.write(` installed: ${target}\n`);
313
+ results.installed.push('codex');
314
+ }
315
+ } catch (e) { results.failed.push(['codex', (e && e.message) || 'write failed']); }
316
+ process.stdout.write('\n');
317
+ }
318
+
319
+ // ── Project-scoped rule agents (Cursor/Windsurf/Cline/Kiro/Copilot) ─────────
320
+ function installProjectRule(ctx, prov) {
321
+ const { say, note, warn, opts, results } = ctx;
322
+ results.detected++;
323
+ say(`→ ${prov.label} detected`);
324
+ const src = path.join(REPO_ROOT, prov.rule);
325
+ const dst = path.join(process.cwd(), prov.rule);
326
+
327
+ if (!fs.existsSync(src)) { warn(` missing source rule ${prov.rule} (run scripts/build-rules.js)`); results.failed.push([prov.id, 'source rule missing']); process.stdout.write('\n'); return; }
328
+
329
+ if (opts.dryRun) { note(` would write ${dst} (project-scoped)`); results.installed.push(prov.id); process.stdout.write('\n'); return; }
330
+
331
+ if (fs.existsSync(dst) && !opts.force) {
332
+ note(` ${prov.rule} already in this project (--force to overwrite)`);
333
+ results.skipped.push([prov.id, 'already in project']); process.stdout.write('\n'); return;
334
+ }
335
+ try {
336
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
337
+ fs.copyFileSync(src, dst);
338
+ process.stdout.write(` installed: ${dst} (project-scoped)\n`);
339
+ results.installed.push(prov.id);
340
+ } catch (e) { results.failed.push([prov.id, (e && e.message) || 'copy failed']); }
341
+ process.stdout.write('\n');
342
+ }
343
+
344
+ // ── uninstall ───────────────────────────────────────────────────────────────
345
+ function uninstall(ctx) {
346
+ const { say, note, opts, c } = ctx;
347
+ say(c.orange('Chisle uninstall'));
348
+ let touched = 0;
349
+
350
+ // Claude plugin
351
+ if (hasCmd('claude') && !opts.dryRun) {
352
+ const r = capture('claude', ['plugin', 'list']);
353
+ if (r.status === 0 && /chisle/i.test(r.stdout || '')) {
354
+ run('claude', ['plugin', 'uninstall', 'chisle@chisle'], opts.dryRun); touched++;
355
+ }
356
+ }
357
+
358
+ // Claude standalone hooks + statusline
359
+ const cfg = claudeDir(opts);
360
+ const settingsPath = path.join(cfg, 'settings.json');
361
+ const settings = SETTINGS.readSettings(settingsPath);
362
+ if (settings) {
363
+ const removed = SETTINGS.removeHooks(settings, 'chisle-');
364
+ let slRemoved = false;
365
+ if (settings.statusLine && typeof settings.statusLine.command === 'string'
366
+ && settings.statusLine.command.includes('chisle-statusline')) { delete settings.statusLine; slRemoved = true; }
367
+ if (removed > 0 || slRemoved) {
368
+ if (!opts.dryRun) { SETTINGS.validateHookFields(settings); SETTINGS.writeSettings(settingsPath, settings); }
369
+ note(` removed ${removed} hook entr${removed === 1 ? 'y' : 'ies'}${slRemoved ? ' + statusline' : ''} from settings.json`);
370
+ touched++;
371
+ }
372
+ }
373
+ const hooksDst = path.join(cfg, 'chisle-hooks');
374
+ if (fs.existsSync(hooksDst)) { if (!opts.dryRun) fs.rmSync(hooksDst, { recursive: true, force: true }); note(` removed ${hooksDst}`); touched++; }
375
+
376
+ // Flag files
377
+ for (const f of ['.chisle-active', '.chisle-session-turns', '.chisle-statusline-suffix',
378
+ '.chisle-compress-stats.json', '.chisle-compress-last.json', '.chisle-update-check.json']) {
379
+ const p = path.join(cfg, f);
380
+ if (fs.existsSync(p)) { if (!opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} } note(` removed ${p}`); touched++; }
381
+ }
382
+
383
+ // Codex fenced block
384
+ const codexMd = path.join(os.homedir(), '.codex', 'AGENTS.md');
385
+ if (fs.existsSync(codexMd)) {
386
+ const txt = fs.readFileSync(codexMd, 'utf8');
387
+ if (txt.includes(FENCE_BEGIN)) {
388
+ const stripped = txt.replace(new RegExp(`\\n?${FENCE_BEGIN}[\\s\\S]*?${FENCE_END}\\n?`), '\n').replace(/\n{3,}/g, '\n\n');
389
+ if (!opts.dryRun) fs.writeFileSync(codexMd, stripped, { mode: 0o644 });
390
+ note(` removed chisle block from ${codexMd}`); touched++;
391
+ }
392
+ }
393
+
394
+ note('');
395
+ note('Project-scoped rule files (.cursor/, .windsurf/, .clinerules/, .kiro/, .github/copilot-instructions.md)');
396
+ note('live in your project repos — remove them per-project with git if you added them there.');
397
+ say(touched ? c.green(`\nUninstalled. ${touched} item(s) cleaned.`) : c.yellow('\nNothing to uninstall.'));
398
+ }
399
+
400
+ // ── help / banner ───────────────────────────────────────────────────────────
401
+ function printHelp(c) {
402
+ process.stdout.write(`${c.orange('chisle')} — maximum-efficiency dev mode installer
403
+
404
+ Usage:
405
+ npx chisle [flags]
406
+
407
+ Flags:
408
+ --list Detect agents and print them; install nothing
409
+ --only <id> Install only for one agent (repeatable)
410
+ --dry-run Print actions, change nothing
411
+ --force Reinstall / overwrite even if already present
412
+ --uninstall, -u Remove what chisle installed
413
+ --config-dir <p> Override Claude config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)
414
+ --no-color Disable ANSI color
415
+ --help, -h This help
416
+
417
+ Agents: ${PROVIDERS.map(p => p.id).join(', ')}
418
+
419
+ Examples:
420
+ npx chisle # auto-detect + install
421
+ npx chisle --only claude # just Claude Code
422
+ npx chisle --dry-run # preview
423
+ `);
424
+ }
425
+
426
+ function printList(c) {
427
+ process.stdout.write(c.orange('chisle') + ' — detected agents:\n\n');
428
+ for (const p of PROVIDERS) {
429
+ const found = detectMatch(p.detect);
430
+ const mark = found ? c.green('✓') : c.dim('·');
431
+ const scope = p.scope === 'project' ? c.dim(' (project-scoped)') : '';
432
+ process.stdout.write(` ${mark} ${p.label}${scope}\n`);
433
+ }
434
+ process.stdout.write('\nRun ' + c.orange('npx chisle') + ' to install for the detected (✓) agents.\n');
435
+ }
436
+
437
+ // ── main ────────────────────────────────────────────────────────────────────
438
+ function main() {
439
+ const opts = parseArgs(process.argv.slice(2));
440
+ const c = makeChalk(opts.noColor);
441
+
442
+ if (opts.help) return printHelp(c);
443
+ if (opts.list) return printList(c);
444
+
445
+ const results = { detected: 0, installed: [], skipped: [], failed: [] };
446
+ const say = (s) => process.stdout.write(s + '\n');
447
+ const note = (s) => process.stdout.write(c.dim(s) + '\n');
448
+ const warn = (s) => process.stdout.write(c.yellow(s) + '\n');
449
+ const ctx = { say, note, warn, opts, results, c };
450
+
451
+ if (opts.uninstall) return uninstall(ctx);
452
+
453
+ say(c.orange('╭─ Chisle installer ─╮'));
454
+ say(c.dim(opts.dryRun ? ' (dry run — nothing will change)' : ' maximum signal, minimum noise'));
455
+ say('');
456
+
457
+ const targets = PROVIDERS.filter(p => opts.only.length ? opts.only.includes(p.id) : detectMatch(p.detect));
458
+ if (targets.length === 0) {
459
+ warn('No supported agents detected.');
460
+ note('Run `npx chisle --list` to see what we look for, or `--only <id>` to force one.');
461
+ return;
462
+ }
463
+
464
+ for (const p of targets) {
465
+ if (p.id === 'claude') installClaude(ctx);
466
+ else if (p.id === 'gemini') installGemini(ctx);
467
+ else if (p.id === 'codex') installCodex(ctx);
468
+ else installProjectRule(ctx, p);
469
+ }
470
+
471
+ // Summary
472
+ say(c.orange('── summary ──'));
473
+ say(` detected: ${results.detected}`);
474
+ if (results.installed.length) say(c.green(` installed: ${results.installed.join(', ')}`));
475
+ if (results.skipped.length) say(c.dim(` skipped: ${results.skipped.map(s => s[0]).join(', ')}`));
476
+ if (results.failed.length) say(c.red(` failed: ${results.failed.map(s => s[0] + ' (' + s[1] + ')').join(', ')}`));
477
+ say('');
478
+ if (!opts.dryRun && results.installed.length) {
479
+ say(c.orange('Done.') + ' Restart your agent. Type ' + c.orange('/chisle') + ' (Claude Code) or just start coding.');
480
+ note('If Chisle earns its keep, a star helps others find it: https://github.com/JayPokale/Chisle');
481
+ }
482
+ process.exit(results.failed.length ? 1 : 0);
483
+ }
484
+
485
+ main();
@@ -0,0 +1,148 @@
1
+ // chisle — JSONC-tolerant settings.json read/write + defensive hook validation.
2
+ //
3
+ // Claude Code applies strict Zod to settings.json: one malformed hook silently
4
+ // discards the WHOLE file. So every mutation validates before write, and reads
5
+ // tolerate the // and /* */ comments users sometimes add.
6
+ //
7
+ // Public API:
8
+ // readSettings(path) → object, {}, or null on hard parse failure
9
+ // writeSettings(path, obj) → atomic write, mode 0600
10
+ // stripJsonComments(src) → comment + trailing-comma stripped
11
+ // validateHookFields(settings) → mutates: drops malformed hook entries
12
+ // hasHook(settings, ev, marker) → idempotency probe
13
+ // addCommandHook(settings, ev, opts) → no-op if marker already present
14
+ // removeHooks(settings, marker) → uninstall helper
15
+ //
16
+ // Pure stdlib, CommonJS, Node ≥18.
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const crypto = require('crypto');
23
+
24
+ // ── stripJsonComments ──────────────────────────────────────────────────────
25
+ // State machine tracking string + escape state so a comment-looking sequence
26
+ // inside a quoted string survives. Trailing commas swept in a final pass.
27
+ function stripJsonComments(src) {
28
+ if (typeof src !== 'string') return src;
29
+ let out = '';
30
+ let i = 0;
31
+ const n = src.length;
32
+ let inString = false, stringChar = '', inLine = false, inBlock = false;
33
+ while (i < n) {
34
+ const c = src[i];
35
+ const next = i + 1 < n ? src[i + 1] : '';
36
+ if (inLine) { if (c === '\n') { inLine = false; out += c; } i++; continue; }
37
+ if (inBlock) { if (c === '*' && next === '/') { inBlock = false; i += 2; continue; } i++; continue; }
38
+ if (inString) {
39
+ out += c;
40
+ if (c === '\\') { if (i + 1 < n) { out += src[i + 1]; i += 2; continue; } }
41
+ if (c === stringChar) inString = false;
42
+ i++; continue;
43
+ }
44
+ if (c === '"' || c === "'") { inString = true; stringChar = c; out += c; i++; continue; }
45
+ if (c === '/' && next === '/') { inLine = true; i += 2; continue; }
46
+ if (c === '/' && next === '*') { inBlock = true; i += 2; continue; }
47
+ out += c; i++;
48
+ }
49
+ return out.replace(/,(\s*[}\]])/g, '$1');
50
+ }
51
+
52
+ // ── readSettings ───────────────────────────────────────────────────────────
53
+ // Strict JSON fast path → JSONC fallback → null (never silently clobber a
54
+ // recoverable file with {}).
55
+ function readSettings(p) {
56
+ if (!fs.existsSync(p)) return {};
57
+ let raw;
58
+ try { raw = fs.readFileSync(p, 'utf8'); }
59
+ catch (e) { process.stderr.write(`chisle: cannot read ${p}: ${e.message}\n`); return null; }
60
+ if (!raw.trim()) return {};
61
+ try { return JSON.parse(raw.replace(/^/, '')); } catch (_) {}
62
+ try { return JSON.parse(stripJsonComments(raw.replace(/^/, ''))); }
63
+ catch (e) {
64
+ process.stderr.write(`chisle: warning — ${p} is not valid JSON/JSONC: ${e.message}\n`);
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // ── writeSettings ──────────────────────────────────────────────────────────
70
+ // Atomic: temp + rename. mode 0600 (settings often holds tokens).
71
+ function writeSettings(p, obj) {
72
+ const dir = path.dirname(p);
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ const tmp = path.join(dir, `.${path.basename(p)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
75
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
76
+ fs.renameSync(tmp, p);
77
+ }
78
+
79
+ // ── validateHookFields ─────────────────────────────────────────────────────
80
+ // Mutate-to-valid before write. Drops any entry Claude Code's Zod would reject.
81
+ function validateHookFields(settings) {
82
+ if (!settings || typeof settings !== 'object') return settings;
83
+ if (!settings.hooks || typeof settings.hooks !== 'object') return settings;
84
+ for (const ev of Object.keys(settings.hooks)) {
85
+ const arr = settings.hooks[ev];
86
+ if (!Array.isArray(arr)) { delete settings.hooks[ev]; continue; }
87
+ settings.hooks[ev] = arr.filter(entry => {
88
+ if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return false;
89
+ entry.hooks = entry.hooks.filter(h => {
90
+ if (!h || typeof h !== 'object') return false;
91
+ if (h.type === 'command') return typeof h.command === 'string' && h.command.length > 0;
92
+ return false;
93
+ });
94
+ return entry.hooks.length > 0;
95
+ });
96
+ if (settings.hooks[ev].length === 0) delete settings.hooks[ev];
97
+ }
98
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
99
+ return settings;
100
+ }
101
+
102
+ // ── idempotency probe ──────────────────────────────────────────────────────
103
+ function hasHook(settings, event, marker) {
104
+ const arr = settings && settings.hooks && settings.hooks[event];
105
+ if (!Array.isArray(arr)) return false;
106
+ return arr.some(e => e && Array.isArray(e.hooks) &&
107
+ e.hooks.some(h => h && typeof h.command === 'string' && h.command.includes(marker)));
108
+ }
109
+
110
+ // ── addCommandHook ─────────────────────────────────────────────────────────
111
+ function addCommandHook(settings, event, opts) {
112
+ if (!settings.hooks) settings.hooks = {};
113
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
114
+ const marker = opts.marker || opts.command;
115
+ if (hasHook(settings, event, marker)) return false;
116
+ const hook = { type: 'command', command: opts.command };
117
+ if (typeof opts.timeout === 'number') hook.timeout = opts.timeout;
118
+ if (typeof opts.statusMessage === 'string') hook.statusMessage = opts.statusMessage;
119
+ const entry = { hooks: [hook] };
120
+ if (typeof opts.matcher === 'string') entry.matcher = opts.matcher;
121
+ settings.hooks[event].push(entry);
122
+ return true;
123
+ }
124
+
125
+ // ── removeHooks (uninstall) ────────────────────────────────────────────────
126
+ function removeHooks(settings, marker) {
127
+ if (!settings || !settings.hooks) return 0;
128
+ validateHookFields(settings);
129
+ if (!settings.hooks) return 0;
130
+ let removed = 0;
131
+ for (const ev of Object.keys(settings.hooks)) {
132
+ if (!Array.isArray(settings.hooks[ev])) { delete settings.hooks[ev]; continue; }
133
+ const before = settings.hooks[ev].length;
134
+ settings.hooks[ev] = settings.hooks[ev].filter(entry => {
135
+ if (!entry || !Array.isArray(entry.hooks)) return true;
136
+ return !entry.hooks.some(h => h && typeof h.command === 'string' && h.command.includes(marker));
137
+ });
138
+ removed += before - settings.hooks[ev].length;
139
+ if (settings.hooks[ev].length === 0) delete settings.hooks[ev];
140
+ }
141
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
142
+ return removed;
143
+ }
144
+
145
+ module.exports = {
146
+ stripJsonComments, readSettings, writeSettings,
147
+ validateHookFields, hasHook, addCommandHook, removeHooks,
148
+ };
@@ -0,0 +1,5 @@
1
+ # chisle-audit — one-shot efficiency audit across both axes
2
+ [command]
3
+ name = "chisle-audit"
4
+ description = "Audit a diff, file, or repo for BOTH over-engineered code and bloated prose/docs. Ranked report, biggest cut first. Changes nothing."
5
+ argument-hint = "[path | repo | empty for current diff]"
@@ -0,0 +1,4 @@
1
+ # chisle-help — quick reference card
2
+ [command]
3
+ name = "chisle-help"
4
+ description = "Show Chisle quick reference: modes, levels, commands."
@@ -0,0 +1,5 @@
1
+ # chisle-review — review a diff/file for over-engineering
2
+ [command]
3
+ name = "chisle-review"
4
+ description = "Review the current diff or a file for bloat, speculative abstractions, and reinvented stdlib."
5
+ argument-hint = "[file or empty for current diff]"
@@ -0,0 +1,8 @@
1
+ # chisle slash command
2
+ # Maximum-efficiency dev mode
3
+ # Usage: /chisle [lite|full|ultra]
4
+
5
+ [command]
6
+ name = "chisle"
7
+ description = "Activate chisle mode: zero-fluff prose + YAGNI ladder code decisions. Levels: lite, full (default), ultra."
8
+ argument-hint = "[lite|full|ultra]"