chisle 3.7.0 → 3.8.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 CHANGED
@@ -7,7 +7,8 @@
7
7
  // - Pi → Pi package (extension + skill)
8
8
  // - Gemini CLI → gemini extensions install
9
9
  // - Codex → fenced ruleset appended to ~/.codex/AGENTS.md
10
- // - OpenCode → fenced ruleset in ~/.config/opencode/AGENTS.md + skills copy
10
+ // - Copilot CLI → fenced ruleset + postToolUse compression hook in ~/.copilot
11
+ // - OpenCode → fenced ruleset + skills + plugin in its global config root
11
12
  // - Hermes → skills copy in ~/.hermes/skills (Agent Skills standard)
12
13
  // - Cursor/Windsurf/Cline/Kiro/Copilot/Antigravity → project rule file dropped into CWD
13
14
  //
@@ -17,6 +18,7 @@
17
18
  // npx chisle --only claude install for one agent
18
19
  // npx chisle --dry-run print actions, change nothing
19
20
  // npx chisle --uninstall remove what we installed
21
+ // npx chisle mcp wrap route stdio MCP servers through the compressor
20
22
  // npx chisle --help
21
23
  //
22
24
  // Pure stdlib, zero runtime deps.
@@ -29,6 +31,8 @@ const path = require('path');
29
31
  const cp = require('child_process');
30
32
 
31
33
  const SETTINGS = require('./lib/settings');
34
+ const MCP = require('./lib/mcp');
35
+ const { getOpencodeDir, getOpencodeStateDir } = require('../hooks/chisle-config');
32
36
 
33
37
  const REPO = 'JayPokale/Chisle';
34
38
  const IS_WIN = process.platform === 'win32';
@@ -45,7 +49,8 @@ const PROVIDERS = [
45
49
  { id: 'pi', label: 'Pi', scope: 'global', detect: 'cmd:pi' },
46
50
  { id: 'gemini', label: 'Gemini CLI', scope: 'global', detect: 'cmd:gemini' },
47
51
  { id: 'codex', label: 'Codex CLI', scope: 'global', detect: 'cmd:codex||dir:~/.codex' },
48
- { id: 'opencode', label: 'OpenCode', scope: 'global', detect: 'cmd:opencode||dir:~/.config/opencode' },
52
+ { id: 'copilot-cli', label: 'Copilot CLI', scope: 'global', detect: 'cmd:copilot' },
53
+ { id: 'opencode', label: 'OpenCode', scope: 'global', detect: 'cmd:opencode' },
49
54
  { id: 'hermes', label: 'Hermes Agent', scope: 'global', detect: 'cmd:hermes||dir:~/.hermes' },
50
55
  { id: 'cursor', label: 'Cursor', scope: 'project', detect: 'cmd:cursor||dir:~/.cursor',
51
56
  rule: '.cursor/rules/chisle.mdc' },
@@ -150,6 +155,11 @@ function detectMatch(spec) {
150
155
  return false;
151
156
  }
152
157
 
158
+ function providerDetected(provider) {
159
+ return detectMatch(provider.detect) ||
160
+ (provider.id === 'opencode' && safeStat(opencodeDir(), 'isDirectory'));
161
+ }
162
+
153
163
  // ── spawn helpers ───────────────────────────────────────────────────────────
154
164
  function quoteWin(a) {
155
165
  if (!IS_WIN) return a;
@@ -188,6 +198,11 @@ function claudeDir(opts) {
188
198
  // (tests, containers); mirrors the CLAUDE_CONFIG_DIR precedent for Claude.
189
199
  function homeDir() { return process.env.CHISLE_HOME || os.homedir(); }
190
200
 
201
+ function opencodeDir() { return getOpencodeDir({ home: homeDir() }); }
202
+ // Copilot CLI reads hooks, instructions and MCP config under COPILOT_HOME.
203
+ function copilotDir() { return process.env.COPILOT_HOME || path.join(homeDir(), '.copilot'); }
204
+ function opencodeStateDir() { return getOpencodeStateDir({ home: homeDir() }); }
205
+
191
206
  // ── what is already installed ───────────────────────────────────────────────
192
207
  // Every install path skips when Chisle is already present, which is right for
193
208
  // `npx chisle` and useless for an upgrade: the run reports success and changes
@@ -225,13 +240,15 @@ function installedFor(id, opts) {
225
240
  }
226
241
  case 'opencode': {
227
242
  try {
228
- const md = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
243
+ const md = path.join(opencodeDir(), 'AGENTS.md');
229
244
  if (fs.existsSync(md) && fs.readFileSync(md, 'utf8').includes(FENCE_BEGIN)) return true;
230
245
  } catch (_) {}
231
- return fs.existsSync(path.join(homeDir(), '.config', 'opencode', 'skills', 'chisle', 'SKILL.md'));
246
+ return fs.existsSync(path.join(opencodeDir(), 'skills', 'chisle', 'SKILL.md'));
232
247
  }
233
248
  case 'hermes':
234
249
  return fs.existsSync(path.join(homeDir(), '.hermes', 'skills', 'chisle', 'SKILL.md'));
250
+ case 'copilot-cli':
251
+ return fs.existsSync(path.join(copilotDir(), 'hooks', 'chisle.json'));
235
252
  default: {
236
253
  const prov = PROVIDERS.find(x => x.id === id);
237
254
  return !!(prov && prov.rule && fs.existsSync(path.join(process.cwd(), prov.rule)));
@@ -510,13 +527,13 @@ function installCodex(ctx) {
510
527
  }
511
528
 
512
529
  // ── OpenCode ─────────────────────────────────────────────────────────────
513
- // Global ruleset (~/.config/opencode/AGENTS.md) carries the always-on output
514
- // axis next to existing user instructions; the bundled skills copy into
515
- // ~/.config/opencode/skills for on-demand loading via the native skill tool
530
+ // Global AGENTS.md carries the always-on output axis next to existing user
531
+ // instructions; the bundled skills copy under the same OpenCode config root
532
+ // for on-demand loading via the native skill tool
516
533
  // (the global skills dir is scanned by default, so skills.paths needs no
517
534
  // edit and no second system message is added).
518
535
  // Copies the OpenCode compression plugin and the shared compressor core it
519
- // requires into ~/.config/opencode/plugins/. OpenCode auto-discovers any *.js
536
+ // requires into the configured OpenCode plugins directory. It auto-discovers *.js
520
537
  // or *.ts file in that dir at startup (not *.mjs), and its config dir is
521
538
  // type:module, so the ESM source ships as chisle.js. Returns the number of
522
539
  // files written. Never called on a dry run (installOpencode returns early),
@@ -527,8 +544,8 @@ function installCodex(ctx) {
527
544
  // dies on its first `require` with a ReferenceError that takes the whole
528
545
  // plugin with it (loadFrom only swallows MODULE_NOT_FOUND). Bun is lenient
529
546
  // enough not to care; Node is not, so pin it rather than rely on the runtime.
530
- function copyOpencodePlugin() {
531
- const pluginDir = path.join(homeDir(), '.config', 'opencode', 'plugins');
547
+ function copyOpencodePlugin(root) {
548
+ const pluginDir = path.join(root, 'plugins');
532
549
  const hooksDir = path.join(pluginDir, 'chisle-hooks');
533
550
  const files = [
534
551
  ['.opencode/plugins/chisle.mjs', path.join(pluginDir, 'chisle.js')],
@@ -550,9 +567,10 @@ function installOpencode(ctx) {
550
567
  const { say, note, opts, results } = ctx;
551
568
  results.detected++;
552
569
  say('\u2192 OpenCode detected');
553
- const target = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
554
- const skillsDst = path.join(homeDir(), '.config', 'opencode', 'skills');
555
- const pluginDst = path.join(homeDir(), '.config', 'opencode', 'plugins');
570
+ const root = opencodeDir();
571
+ const target = path.join(root, 'AGENTS.md');
572
+ const skillsDst = path.join(root, 'skills');
573
+ const pluginDst = path.join(root, 'plugins');
556
574
 
557
575
  if (opts.dryRun) {
558
576
  note(' would write chisle ruleset to ' + target);
@@ -567,7 +585,7 @@ function installOpencode(ctx) {
567
585
  const st = writeFencedRuleset(target, opts, note);
568
586
  const n = copySkills(skillsDst, opts);
569
587
  process.stdout.write(' installed: ' + n + ' skill file(s) to ' + skillsDst + '\n');
570
- const p = copyOpencodePlugin();
588
+ const p = copyOpencodePlugin(root);
571
589
  process.stdout.write(' installed: ' + p + ' plugin file(s) to ' + pluginDst + '\n');
572
590
  if (st === 'installed') results.installed.push('opencode');
573
591
  else results.skipped.push(['opencode', 'ruleset already present; skills + plugin refreshed']);
@@ -575,6 +593,65 @@ function installOpencode(ctx) {
575
593
  process.stdout.write('\n');
576
594
  }
577
595
 
596
+ // ── Copilot CLI ─────────────────────────────────────────────────────────────
597
+ // Rules: fenced block in ~/.copilot/copilot-instructions.md, Copilot CLI's
598
+ // user-level instructions file. Input axis: a user-level postToolUse hook
599
+ // (~/.copilot/hooks/chisle.json, no workspace admission needed) running the
600
+ // shared compressor, which already speaks Copilot's camelCase payload and
601
+ // answers with modifiedResult. Verified live on Copilot CLI 1.0.95: a
602
+ // 13.9k-char shell result reached the model at ~100 lines with the mid-log
603
+ // error line and the `<shellId: 0 completed with exit code 0>` tail intact.
604
+ function copilotHookConfig(hookPath) {
605
+ return JSON.stringify({
606
+ version: 1,
607
+ hooks: {
608
+ postToolUse: [{
609
+ type: 'command',
610
+ bash: 'node "' + hookPath + '"',
611
+ powershell: 'node "' + hookPath + '"',
612
+ timeoutSec: 10,
613
+ }],
614
+ },
615
+ }, null, 2) + '\n';
616
+ }
617
+
618
+ function installCopilotCli(ctx) {
619
+ const { say, note, opts, results } = ctx;
620
+ results.detected++;
621
+ say('\u2192 Copilot CLI detected');
622
+ const root = copilotDir();
623
+ const rules = path.join(root, 'copilot-instructions.md');
624
+ const hooksDir = path.join(root, 'chisle-hooks');
625
+ const hookFile = path.join(root, 'hooks', 'chisle.json');
626
+
627
+ if (opts.dryRun) {
628
+ note(' would write chisle ruleset to ' + rules);
629
+ note(' would copy compressor to ' + hooksDir);
630
+ note(' would write postToolUse hook ' + hookFile);
631
+ results.installed.push('copilot-cli');
632
+ process.stdout.write('\n');
633
+ return;
634
+ }
635
+
636
+ try {
637
+ const st = writeFencedRuleset(rules, opts, note);
638
+ fs.mkdirSync(hooksDir, { recursive: true });
639
+ for (const f of ['chisle-compress-output.js', 'chisle-config.js']) {
640
+ fs.copyFileSync(path.join(REPO_ROOT, 'hooks', f), path.join(hooksDir, f));
641
+ }
642
+ fs.writeFileSync(path.join(hooksDir, 'package.json'), '{ "type": "commonjs" }\n');
643
+ const hookExisted = fs.existsSync(hookFile);
644
+ if (!hookExisted || opts.force) {
645
+ fs.mkdirSync(path.dirname(hookFile), { recursive: true });
646
+ fs.writeFileSync(hookFile, copilotHookConfig(path.join(hooksDir, 'chisle-compress-output.js')), { mode: 0o644 });
647
+ process.stdout.write(' installed: ' + hookFile + '\n');
648
+ }
649
+ if (st === 'installed' || !hookExisted) results.installed.push('copilot-cli');
650
+ else results.skipped.push(['copilot-cli', 'already present; compressor refreshed']);
651
+ } catch (e) { results.failed.push(['copilot-cli', (e && e.message) || 'write failed']); }
652
+ process.stdout.write('\n');
653
+ }
654
+
578
655
  // ── Hermes ──────────────────────────────────────────────────────────
579
656
  // Agent-Skills host: bundled skills land verbatim in ~/.hermes/skills, where
580
657
  // Hermes discovers them as /chisle slash commands. No ruleset is injected:
@@ -690,7 +767,9 @@ function uninstall(ctx) {
690
767
  }
691
768
 
692
769
  if (wants('opencode')) {
693
- const ocMd = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
770
+ const ocRoot = opencodeDir();
771
+ const ocState = opencodeStateDir();
772
+ const ocMd = path.join(ocRoot, 'AGENTS.md');
694
773
  if (fs.existsSync(ocMd)) {
695
774
  const txt = fs.readFileSync(ocMd, 'utf8');
696
775
  const b = txt.indexOf(FENCE_BEGIN);
@@ -704,17 +783,17 @@ function uninstall(ctx) {
704
783
  note(' removed chisle block from ' + ocMd); touched++;
705
784
  }
706
785
  }
707
- const ocSkills = path.join(homeDir(), '.config', 'opencode', 'skills');
786
+ const ocSkills = path.join(ocRoot, 'skills');
708
787
  const ocGone = opts.dryRun ? ownedSkillNames().filter(function (nm) { return fs.existsSync(path.join(ocSkills, nm)); }).length : pruneSkills(ocSkills);
709
788
  if (ocGone > 0) { note(' removed ' + ocGone + ' chisle skill dir(s) from ' + ocSkills); touched++; }
710
789
 
711
- const ocPlugins = path.join(homeDir(), '.config', 'opencode', 'plugins');
790
+ const ocPlugins = path.join(ocRoot, 'plugins');
712
791
  for (const rel of ['chisle.js', 'chisle-hooks']) {
713
792
  const dst = path.join(ocPlugins, rel);
714
793
  if (fs.existsSync(dst)) { if (!opts.dryRun) fs.rmSync(dst, { recursive: true, force: true }); note(' removed ' + dst); touched++; }
715
794
  }
716
795
  for (const f of ['chisle-spill', 'chisle-dedup', '.chisle-compress-stats.json', '.chisle-compress-last.json']) {
717
- const p = path.join(homeDir(), '.config', 'opencode', f);
796
+ const p = path.join(ocState, f);
718
797
  if (fs.existsSync(p)) { if (!opts.dryRun) fs.rmSync(p, { recursive: true, force: true }); note(' removed ' + p); touched++; }
719
798
  }
720
799
  }
@@ -725,6 +804,36 @@ function uninstall(ctx) {
725
804
  if (hGone > 0) { note(' removed ' + hGone + ' chisle skill dir(s) from ' + hSkills); touched++; }
726
805
  }
727
806
 
807
+ if (wants('copilot-cli')) {
808
+ const cpRoot = copilotDir();
809
+ const cpMd = path.join(cpRoot, 'copilot-instructions.md');
810
+ if (fs.existsSync(cpMd)) {
811
+ const txt = fs.readFileSync(cpMd, 'utf8');
812
+ const b = txt.indexOf(FENCE_BEGIN);
813
+ const e = b === -1 ? -1 : txt.indexOf(FENCE_END, b);
814
+ if (b !== -1 && e !== -1) {
815
+ const tail = txt.slice(e + FENCE_END.length).replace(/^\n/, '');
816
+ const stripped = (txt.slice(0, b) + tail).replace(/\n{3,}/g, '\n\n').replace(/\n{2,}$/, '\n');
817
+ if (!opts.dryRun) fs.writeFileSync(cpMd, stripped, { mode: 0o644 });
818
+ note(' removed chisle block from ' + cpMd); touched++;
819
+ }
820
+ }
821
+ for (const rel of [path.join('hooks', 'chisle.json'), 'chisle-hooks', 'chisle-spill', 'chisle-dedup',
822
+ '.chisle-compress-stats.json']) {
823
+ const p = path.join(cpRoot, rel);
824
+ if (fs.existsSync(p)) { if (!opts.dryRun) fs.rmSync(p, { recursive: true, force: true }); note(' removed ' + p); touched++; }
825
+ }
826
+ }
827
+
828
+ // MCP servers routed through the proxy by `npx chisle mcp wrap`: restore
829
+ // their original commands before anything else could remove the proxy.
830
+ if (!opts.only.length) {
831
+ const r = MCP.run(['unwrap'].concat(opts.dryRun ? ['--dry-run'] : []), () => {});
832
+ if (r.changed) { note(' restored ' + r.changed + ' MCP server command(s) wrapped by `chisle mcp wrap`'); touched++; }
833
+ const pd = MCP.proxyDir();
834
+ if (fs.existsSync(pd)) { if (!opts.dryRun) fs.rmSync(pd, { recursive: true, force: true }); note(' removed ' + pd); touched++; }
835
+ }
836
+
728
837
  if (!opts.only.length || opts.only.some(id => PROVIDERS.find(p => p.id === id).scope === 'project')) {
729
838
  note('');
730
839
  note('Project-scoped rule files (.cursor/, .windsurf/, .clinerules/, .kiro/, .agents/rules/, .github/copilot-instructions.md)');
@@ -747,18 +856,26 @@ Flags:
747
856
  --only <id> Limit install/uninstall to one agent (repeatable)
748
857
  --dry-run Print actions, change nothing
749
858
  --force Reinstall / overwrite even if already present
750
- --uninstall, -u Remove what chisle installed
859
+ --uninstall, -u Remove what chisle installed (also restores wrapped MCP servers)
751
860
  --config-dir <p> Override Claude config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)
752
861
  --no-color Disable ANSI color
753
862
  --help, -h This help
754
863
 
755
864
  Agents: ${PROVIDERS.map(p => p.id).join(', ')}
756
865
 
866
+ MCP output compression (opt-in, any agent that launches stdio MCP servers):
867
+ npx chisle mcp wrap [--only <id>] [--file <mcp.json>] [--dry-run]
868
+ npx chisle mcp unwrap [--only <id>] [--file <mcp.json>] [--dry-run]
869
+ npx chisle mcp status
870
+ Targets: ${[...new Set(MCP.targets().map(t => t.id))].join(', ')}. Never Claude Code
871
+ (its own hook already compresses MCP output).
872
+
757
873
  Examples:
758
874
  npx chisle # auto-detect + install
759
875
  npx chisle --only claude # just Claude Code
760
876
  npx chisle --dry-run # preview
761
877
  npx chisle --stats # what the compressor has saved
878
+ npx chisle mcp wrap # compress MCP tool output in Cursor, Gemini, ...
762
879
  npx chisle@latest --update # upgrade what is already installed
763
880
  `);
764
881
  }
@@ -770,7 +887,10 @@ Examples:
770
887
  // without the ruleset, which is what the benchmark arms are for. A counter that
771
888
  // blended the two would be inventing the interesting half.
772
889
  function printStats(c, opts) {
773
- const dir = claudeDir(opts);
890
+ const only = opts.only.length === 1 ? opts.only[0] : null;
891
+ const dir = only === 'opencode' ? opencodeStateDir()
892
+ : only === 'copilot-cli' ? copilotDir()
893
+ : claudeDir(opts);
774
894
  const p = path.join(dir, '.chisle-compress-stats.json');
775
895
  process.stdout.write(c.orange('chisle') + ': tool-output savings\n\n');
776
896
 
@@ -805,7 +925,7 @@ function printStats(c, opts) {
805
925
  function printList(c) {
806
926
  process.stdout.write(c.orange('chisle') + ': detected agents:\n\n');
807
927
  for (const p of PROVIDERS) {
808
- const found = detectMatch(p.detect);
928
+ const found = providerDetected(p);
809
929
  const mark = found ? c.green('✓') : c.dim('·');
810
930
  const scope = p.scope === 'project' ? c.dim(' (project-scoped)') : '';
811
931
  process.stdout.write(` ${mark} ${p.label}${scope}\n`);
@@ -815,6 +935,7 @@ function printList(c) {
815
935
 
816
936
  // ── main ────────────────────────────────────────────────────────────────────
817
937
  function main() {
938
+ if (process.argv[2] === 'mcp') return MCP.main(process.argv.slice(3));
818
939
  const opts = parseArgs(process.argv.slice(2));
819
940
  const c = makeChalk(opts.noColor);
820
941
 
@@ -834,7 +955,7 @@ function main() {
834
955
  say(c.dim(opts.dryRun ? ' (dry run, nothing will change)' : ' maximum signal, minimum noise'));
835
956
  say('');
836
957
 
837
- let targets = PROVIDERS.filter(p => opts.only.length ? opts.only.includes(p.id) : detectMatch(p.detect));
958
+ let targets = PROVIDERS.filter(p => opts.only.length ? opts.only.includes(p.id) : providerDetected(p));
838
959
  if (opts.update) targets = targets.filter(p => installedFor(p.id, opts));
839
960
  if (targets.length === 0) {
840
961
  if (opts.update) {
@@ -856,6 +977,7 @@ function main() {
856
977
  else if (p.id === 'codex') installCodex(ctx);
857
978
  else if (p.id === 'opencode') installOpencode(ctx);
858
979
  else if (p.id === 'hermes') installHermes(ctx);
980
+ else if (p.id === 'copilot-cli') installCopilotCli(ctx);
859
981
  else installProjectRule(ctx, p);
860
982
  }
861
983
 
package/bin/lib/mcp.js ADDED
@@ -0,0 +1,179 @@
1
+ // chisle — `npx chisle mcp <wrap|unwrap|status>`: route stdio MCP servers
2
+ // through hooks/chisle-mcp-proxy.js so their oversized tool results get the
3
+ // same compression Claude Code and Pi get from PostToolUse.
4
+ //
5
+ // Opt-in, because it rewrites other tools' config files:
6
+ // - strict JSON only: a file with comments is reported and left alone,
7
+ // since rewriting it would drop them
8
+ // - first write of each file keeps a `<file>.chisle-bak` copy
9
+ // - only stdio entries (`command`) are wrapped; url/http servers untouched
10
+ // - wrapped form is reversible from the args alone, no extra keys a host
11
+ // schema might reject: { command: "node", args: [proxy, "--", cmd, ...args] }
12
+ //
13
+ // Claude Code is never a target: its PostToolUse hook already compresses
14
+ // mcp__* output, and wrapping would only add a process per server.
15
+ // Codex (TOML config) is not wrapped automatically.
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { getConfigDir } = require('../../hooks/chisle-config');
23
+
24
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
25
+ const PROXY_FILE = 'chisle-mcp-proxy.js';
26
+ const PROXY_SOURCES = ['chisle-mcp-proxy.js', 'chisle-compress-output.js', 'chisle-config.js'];
27
+
28
+ function home() { return process.env.CHISLE_HOME || os.homedir(); }
29
+ function proxyDir() { return path.join(getConfigDir(), 'mcp-proxy'); }
30
+
31
+ // VS Code-family user dirs, for Cline's global MCP settings.
32
+ function vscodeUserDirs() {
33
+ const base = process.platform === 'darwin' ? path.join(home(), 'Library', 'Application Support')
34
+ : process.platform === 'win32' ? (process.env.APPDATA || path.join(home(), 'AppData', 'Roaming'))
35
+ : (process.env.XDG_CONFIG_HOME || path.join(home(), '.config'));
36
+ return ['Code', 'Code - Insiders', 'VSCodium', 'Cursor', 'Windsurf'].map(n => path.join(base, n, 'User'));
37
+ }
38
+
39
+ // Global MCP config files, all `{ "mcpServers": { name: { command, args } } }`.
40
+ function targets() {
41
+ const h = home();
42
+ const copilot = process.env.COPILOT_HOME || path.join(h, '.copilot');
43
+ const list = [
44
+ ['cursor', 'Cursor', path.join(h, '.cursor', 'mcp.json')],
45
+ ['windsurf', 'Windsurf', path.join(h, '.codeium', 'windsurf', 'mcp_config.json')],
46
+ ['gemini', 'Gemini CLI', path.join(h, '.gemini', 'settings.json')],
47
+ ['qwen', 'Qwen Code', path.join(h, '.qwen', 'settings.json')],
48
+ ['kiro', 'Kiro', path.join(h, '.kiro', 'settings', 'mcp.json')],
49
+ ['copilot-cli', 'Copilot CLI', path.join(copilot, 'mcp-config.json')],
50
+ ];
51
+ for (const dir of vscodeUserDirs()) {
52
+ list.push(['cline', 'Cline', path.join(dir, 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json')]);
53
+ }
54
+ return list.map(([id, label, file]) => ({ id, label, file }));
55
+ }
56
+
57
+ function isWrapped(entry) {
58
+ return !!(entry && Array.isArray(entry.args) && typeof entry.args[0] === 'string' &&
59
+ path.basename(entry.args[0]) === PROXY_FILE && entry.args[1] === '--');
60
+ }
61
+
62
+ function isStdio(entry) {
63
+ return !!(entry && typeof entry.command === 'string' && entry.command &&
64
+ !entry.url && !entry.httpUrl && !entry.serverUrl &&
65
+ (entry.type == null || entry.type === 'stdio' || entry.type === 'local'));
66
+ }
67
+
68
+ function wrapEntry(entry, proxy) {
69
+ const args = Array.isArray(entry.args) ? entry.args : [];
70
+ entry.args = [proxy, '--', entry.command, ...args];
71
+ entry.command = 'node';
72
+ }
73
+
74
+ function unwrapEntry(entry) {
75
+ const rest = entry.args.slice(2);
76
+ entry.command = rest[0];
77
+ entry.args = rest.slice(1);
78
+ if (!entry.args.length) delete entry.args; // wrap added it; most configs omit an empty one
79
+ }
80
+
81
+ function readConfig(file) {
82
+ let raw;
83
+ try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { return { missing: true }; }
84
+ try {
85
+ const data = JSON.parse(raw.replace(/^/, ''));
86
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { error: 'not a JSON object' };
87
+ return { data, raw };
88
+ } catch (e) { return { error: 'not strict JSON (comments?); edit by hand' }; }
89
+ }
90
+
91
+ function writeConfig(file, raw, data) {
92
+ const bak = file + '.chisle-bak';
93
+ if (!fs.existsSync(bak)) fs.writeFileSync(bak, raw, { mode: 0o600 });
94
+ let mode = 0o600;
95
+ try { mode = fs.statSync(file).mode & 0o777; } catch (e) {}
96
+ const tmp = file + '.chisle-tmp-' + process.pid;
97
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode });
98
+ fs.renameSync(tmp, file);
99
+ }
100
+
101
+ function installProxy() {
102
+ const dir = proxyDir();
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ for (const f of PROXY_SOURCES) fs.copyFileSync(path.join(REPO_ROOT, 'hooks', f), path.join(dir, f));
105
+ fs.writeFileSync(path.join(dir, 'package.json'), '{ "type": "commonjs" }\n');
106
+ return path.join(dir, PROXY_FILE);
107
+ }
108
+
109
+ function parse(argv) {
110
+ const o = { action: argv[0], dryRun: false, only: [], files: [] };
111
+ for (let i = 1; i < argv.length; i++) {
112
+ const a = argv[i];
113
+ if (a === '--dry-run') o.dryRun = true;
114
+ else if (a === '--only' && argv[i + 1]) o.only.push(argv[++i]);
115
+ else if (a === '--file' && argv[i + 1]) o.files.push(path.resolve(argv[++i]));
116
+ else if (a === '--no-color' || a === '--') continue;
117
+ else throw new Error('unknown option: ' + a);
118
+ }
119
+ if (!['wrap', 'unwrap', 'status'].includes(o.action)) throw new Error('usage: npx chisle mcp <wrap|unwrap|status> [--only <agent>] [--file <mcp.json>] [--dry-run]');
120
+ return o;
121
+ }
122
+
123
+ function selected(o) {
124
+ if (o.files.length) return o.files.map(file => ({ id: 'file', label: 'custom', file }));
125
+ return targets().filter(t => !o.only.length || o.only.includes(t.id));
126
+ }
127
+
128
+ // Returns { changed, files } so `--uninstall` can reuse it quietly.
129
+ function run(argv, out) {
130
+ out = out || (s => process.stdout.write(s + '\n'));
131
+ const o = parse(argv);
132
+ const proxy = o.action === 'wrap' && !o.dryRun ? installProxy() : path.join(proxyDir(), PROXY_FILE);
133
+ let changed = 0, found = 0;
134
+
135
+ for (const t of selected(o)) {
136
+ const cfg = readConfig(t.file);
137
+ if (cfg.missing) { if (o.files.length) out(' ' + t.file + ': not found'); continue; }
138
+ found++;
139
+ if (cfg.error) { out(' ' + t.label + ': ' + t.file + ': skipped, ' + cfg.error); continue; }
140
+ const servers = cfg.data.mcpServers;
141
+ if (!servers || typeof servers !== 'object') { out(' ' + t.label + ': no mcpServers in ' + t.file); continue; }
142
+
143
+ const names = Object.keys(servers);
144
+ const wrapped = names.filter(n => isWrapped(servers[n]));
145
+ if (o.action === 'status') {
146
+ out(' ' + t.label + ': ' + wrapped.length + '/' + names.filter(n => isStdio(servers[n])).length +
147
+ ' stdio server(s) wrapped ' + t.file);
148
+ continue;
149
+ }
150
+
151
+ const todo = o.action === 'wrap'
152
+ ? names.filter(n => isStdio(servers[n]) && !isWrapped(servers[n]))
153
+ : wrapped;
154
+ if (!todo.length) { out(' ' + t.label + ': nothing to ' + o.action + ' ' + t.file); continue; }
155
+ for (const n of todo) (o.action === 'wrap' ? wrapEntry(servers[n], proxy) : unwrapEntry(servers[n]));
156
+ if (!o.dryRun) writeConfig(t.file, cfg.raw, cfg.data);
157
+ changed += todo.length;
158
+ out(' ' + t.label + ': ' + (o.dryRun ? 'would ' + o.action : o.action + 'ped') + ' ' + todo.join(', ') + ' ' + t.file);
159
+ }
160
+
161
+ if (!found) out(' no MCP config files found' + (o.only.length ? ' for ' + o.only.join(', ') : ''));
162
+ if (o.action === 'status') {
163
+ const dir = process.env.CHISLE_STATE_DIR || getConfigDir();
164
+ let stats = null;
165
+ try { stats = JSON.parse(fs.readFileSync(path.join(dir, '.chisle-compress-stats.json'), 'utf8')); } catch (e) {}
166
+ out('\n saved so far: ' + (stats && Number.isFinite(stats.savedChars)
167
+ ? stats.savedChars.toLocaleString('en-US') + ' chars over ' + stats.events + ' tool result(s)'
168
+ : 'nothing yet') + ' (' + dir + ')');
169
+ }
170
+ if (o.action === 'wrap' && changed && !o.dryRun) out('\n Restart the agent so it relaunches its MCP servers.');
171
+ return { changed, found };
172
+ }
173
+
174
+ function main(argv) {
175
+ try { run(argv); }
176
+ catch (e) { process.stderr.write('error: ' + e.message + '\n'); process.exit(2); }
177
+ }
178
+
179
+ module.exports = { main, run, targets, isWrapped, isStdio, proxyDir };