chisle 3.6.1 → 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,9 +7,10 @@
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
- // - Cursor/Windsurf/Cline/Kiro/Copilot → project rule file dropped into CWD
13
+ // - Cursor/Windsurf/Cline/Kiro/Copilot/Antigravity → project rule file dropped into CWD
13
14
  //
14
15
  // Usage:
15
16
  // npx chisle auto-detect + install
@@ -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' },
@@ -57,6 +62,10 @@ const PROVIDERS = [
57
62
  rule: '.kiro/steering/chisle.md' },
58
63
  { id: 'copilot', label: 'GitHub Copilot',scope: 'project', detect: 'vscode-ext:github.copilot||vscode-ext:github.copilot-chat',
59
64
  rule: '.github/copilot-instructions.md' },
65
+ // Antigravity IDE (`antigravity`) and its CLI (`agy`, antigravity-cli) both
66
+ // load workspace rules from .agents/rules/*.md.
67
+ { id: 'antigravity', label: 'Antigravity', scope: 'project', detect: 'cmd:antigravity||cmd:agy||dir:~/.antigravity',
68
+ rule: '.agents/rules/chisle.md' },
60
69
  ];
61
70
 
62
71
  // ── argv ────────────────────────────────────────────────────────────────────
@@ -146,6 +155,11 @@ function detectMatch(spec) {
146
155
  return false;
147
156
  }
148
157
 
158
+ function providerDetected(provider) {
159
+ return detectMatch(provider.detect) ||
160
+ (provider.id === 'opencode' && safeStat(opencodeDir(), 'isDirectory'));
161
+ }
162
+
149
163
  // ── spawn helpers ───────────────────────────────────────────────────────────
150
164
  function quoteWin(a) {
151
165
  if (!IS_WIN) return a;
@@ -184,6 +198,11 @@ function claudeDir(opts) {
184
198
  // (tests, containers); mirrors the CLAUDE_CONFIG_DIR precedent for Claude.
185
199
  function homeDir() { return process.env.CHISLE_HOME || os.homedir(); }
186
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
+
187
206
  // ── what is already installed ───────────────────────────────────────────────
188
207
  // Every install path skips when Chisle is already present, which is right for
189
208
  // `npx chisle` and useless for an upgrade: the run reports success and changes
@@ -221,13 +240,15 @@ function installedFor(id, opts) {
221
240
  }
222
241
  case 'opencode': {
223
242
  try {
224
- const md = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
243
+ const md = path.join(opencodeDir(), 'AGENTS.md');
225
244
  if (fs.existsSync(md) && fs.readFileSync(md, 'utf8').includes(FENCE_BEGIN)) return true;
226
245
  } catch (_) {}
227
- return fs.existsSync(path.join(homeDir(), '.config', 'opencode', 'skills', 'chisle', 'SKILL.md'));
246
+ return fs.existsSync(path.join(opencodeDir(), 'skills', 'chisle', 'SKILL.md'));
228
247
  }
229
248
  case 'hermes':
230
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'));
231
252
  default: {
232
253
  const prov = PROVIDERS.find(x => x.id === id);
233
254
  return !!(prov && prov.rule && fs.existsSync(path.join(process.cwd(), prov.rule)));
@@ -506,13 +527,13 @@ function installCodex(ctx) {
506
527
  }
507
528
 
508
529
  // ── OpenCode ─────────────────────────────────────────────────────────────
509
- // Global ruleset (~/.config/opencode/AGENTS.md) carries the always-on output
510
- // axis next to existing user instructions; the bundled skills copy into
511
- // ~/.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
512
533
  // (the global skills dir is scanned by default, so skills.paths needs no
513
534
  // edit and no second system message is added).
514
535
  // Copies the OpenCode compression plugin and the shared compressor core it
515
- // requires into ~/.config/opencode/plugins/. OpenCode auto-discovers any *.js
536
+ // requires into the configured OpenCode plugins directory. It auto-discovers *.js
516
537
  // or *.ts file in that dir at startup (not *.mjs), and its config dir is
517
538
  // type:module, so the ESM source ships as chisle.js. Returns the number of
518
539
  // files written. Never called on a dry run (installOpencode returns early),
@@ -523,8 +544,8 @@ function installCodex(ctx) {
523
544
  // dies on its first `require` with a ReferenceError that takes the whole
524
545
  // plugin with it (loadFrom only swallows MODULE_NOT_FOUND). Bun is lenient
525
546
  // enough not to care; Node is not, so pin it rather than rely on the runtime.
526
- function copyOpencodePlugin() {
527
- const pluginDir = path.join(homeDir(), '.config', 'opencode', 'plugins');
547
+ function copyOpencodePlugin(root) {
548
+ const pluginDir = path.join(root, 'plugins');
528
549
  const hooksDir = path.join(pluginDir, 'chisle-hooks');
529
550
  const files = [
530
551
  ['.opencode/plugins/chisle.mjs', path.join(pluginDir, 'chisle.js')],
@@ -546,9 +567,10 @@ function installOpencode(ctx) {
546
567
  const { say, note, opts, results } = ctx;
547
568
  results.detected++;
548
569
  say('\u2192 OpenCode detected');
549
- const target = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
550
- const skillsDst = path.join(homeDir(), '.config', 'opencode', 'skills');
551
- 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');
552
574
 
553
575
  if (opts.dryRun) {
554
576
  note(' would write chisle ruleset to ' + target);
@@ -563,7 +585,7 @@ function installOpencode(ctx) {
563
585
  const st = writeFencedRuleset(target, opts, note);
564
586
  const n = copySkills(skillsDst, opts);
565
587
  process.stdout.write(' installed: ' + n + ' skill file(s) to ' + skillsDst + '\n');
566
- const p = copyOpencodePlugin();
588
+ const p = copyOpencodePlugin(root);
567
589
  process.stdout.write(' installed: ' + p + ' plugin file(s) to ' + pluginDst + '\n');
568
590
  if (st === 'installed') results.installed.push('opencode');
569
591
  else results.skipped.push(['opencode', 'ruleset already present; skills + plugin refreshed']);
@@ -571,6 +593,65 @@ function installOpencode(ctx) {
571
593
  process.stdout.write('\n');
572
594
  }
573
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
+
574
655
  // ── Hermes ──────────────────────────────────────────────────────────
575
656
  // Agent-Skills host: bundled skills land verbatim in ~/.hermes/skills, where
576
657
  // Hermes discovers them as /chisle slash commands. No ruleset is injected:
@@ -597,7 +678,7 @@ function installHermes(ctx) {
597
678
  process.stdout.write('\n');
598
679
  }
599
680
 
600
- // ── Project-scoped rule agents (Cursor/Windsurf/Cline/Kiro/Copilot) ─────────
681
+ // ── Project-scoped rule agents (Cursor/Windsurf/Cline/Kiro/Copilot/Antigravity)
601
682
  function installProjectRule(ctx, prov) {
602
683
  const { say, note, warn, opts, results } = ctx;
603
684
  results.detected++;
@@ -686,7 +767,9 @@ function uninstall(ctx) {
686
767
  }
687
768
 
688
769
  if (wants('opencode')) {
689
- 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');
690
773
  if (fs.existsSync(ocMd)) {
691
774
  const txt = fs.readFileSync(ocMd, 'utf8');
692
775
  const b = txt.indexOf(FENCE_BEGIN);
@@ -700,17 +783,17 @@ function uninstall(ctx) {
700
783
  note(' removed chisle block from ' + ocMd); touched++;
701
784
  }
702
785
  }
703
- const ocSkills = path.join(homeDir(), '.config', 'opencode', 'skills');
786
+ const ocSkills = path.join(ocRoot, 'skills');
704
787
  const ocGone = opts.dryRun ? ownedSkillNames().filter(function (nm) { return fs.existsSync(path.join(ocSkills, nm)); }).length : pruneSkills(ocSkills);
705
788
  if (ocGone > 0) { note(' removed ' + ocGone + ' chisle skill dir(s) from ' + ocSkills); touched++; }
706
789
 
707
- const ocPlugins = path.join(homeDir(), '.config', 'opencode', 'plugins');
790
+ const ocPlugins = path.join(ocRoot, 'plugins');
708
791
  for (const rel of ['chisle.js', 'chisle-hooks']) {
709
792
  const dst = path.join(ocPlugins, rel);
710
793
  if (fs.existsSync(dst)) { if (!opts.dryRun) fs.rmSync(dst, { recursive: true, force: true }); note(' removed ' + dst); touched++; }
711
794
  }
712
795
  for (const f of ['chisle-spill', 'chisle-dedup', '.chisle-compress-stats.json', '.chisle-compress-last.json']) {
713
- const p = path.join(homeDir(), '.config', 'opencode', f);
796
+ const p = path.join(ocState, f);
714
797
  if (fs.existsSync(p)) { if (!opts.dryRun) fs.rmSync(p, { recursive: true, force: true }); note(' removed ' + p); touched++; }
715
798
  }
716
799
  }
@@ -721,9 +804,39 @@ function uninstall(ctx) {
721
804
  if (hGone > 0) { note(' removed ' + hGone + ' chisle skill dir(s) from ' + hSkills); touched++; }
722
805
  }
723
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
+
724
837
  if (!opts.only.length || opts.only.some(id => PROVIDERS.find(p => p.id === id).scope === 'project')) {
725
838
  note('');
726
- note('Project-scoped rule files (.cursor/, .windsurf/, .clinerules/, .kiro/, .github/copilot-instructions.md)');
839
+ note('Project-scoped rule files (.cursor/, .windsurf/, .clinerules/, .kiro/, .agents/rules/, .github/copilot-instructions.md)');
727
840
  note('live in your project repos, so remove them per-project with git if you added them there.');
728
841
  }
729
842
  say(touched ? c.green(`\nUninstalled. ${touched} item(s) cleaned.`) : c.yellow('\nNothing to uninstall.'));
@@ -743,18 +856,26 @@ Flags:
743
856
  --only <id> Limit install/uninstall to one agent (repeatable)
744
857
  --dry-run Print actions, change nothing
745
858
  --force Reinstall / overwrite even if already present
746
- --uninstall, -u Remove what chisle installed
859
+ --uninstall, -u Remove what chisle installed (also restores wrapped MCP servers)
747
860
  --config-dir <p> Override Claude config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)
748
861
  --no-color Disable ANSI color
749
862
  --help, -h This help
750
863
 
751
864
  Agents: ${PROVIDERS.map(p => p.id).join(', ')}
752
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
+
753
873
  Examples:
754
874
  npx chisle # auto-detect + install
755
875
  npx chisle --only claude # just Claude Code
756
876
  npx chisle --dry-run # preview
757
877
  npx chisle --stats # what the compressor has saved
878
+ npx chisle mcp wrap # compress MCP tool output in Cursor, Gemini, ...
758
879
  npx chisle@latest --update # upgrade what is already installed
759
880
  `);
760
881
  }
@@ -766,7 +887,10 @@ Examples:
766
887
  // without the ruleset, which is what the benchmark arms are for. A counter that
767
888
  // blended the two would be inventing the interesting half.
768
889
  function printStats(c, opts) {
769
- 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);
770
894
  const p = path.join(dir, '.chisle-compress-stats.json');
771
895
  process.stdout.write(c.orange('chisle') + ': tool-output savings\n\n');
772
896
 
@@ -801,7 +925,7 @@ function printStats(c, opts) {
801
925
  function printList(c) {
802
926
  process.stdout.write(c.orange('chisle') + ': detected agents:\n\n');
803
927
  for (const p of PROVIDERS) {
804
- const found = detectMatch(p.detect);
928
+ const found = providerDetected(p);
805
929
  const mark = found ? c.green('✓') : c.dim('·');
806
930
  const scope = p.scope === 'project' ? c.dim(' (project-scoped)') : '';
807
931
  process.stdout.write(` ${mark} ${p.label}${scope}\n`);
@@ -811,6 +935,7 @@ function printList(c) {
811
935
 
812
936
  // ── main ────────────────────────────────────────────────────────────────────
813
937
  function main() {
938
+ if (process.argv[2] === 'mcp') return MCP.main(process.argv.slice(3));
814
939
  const opts = parseArgs(process.argv.slice(2));
815
940
  const c = makeChalk(opts.noColor);
816
941
 
@@ -830,13 +955,13 @@ function main() {
830
955
  say(c.dim(opts.dryRun ? ' (dry run, nothing will change)' : ' maximum signal, minimum noise'));
831
956
  say('');
832
957
 
833
- 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));
834
959
  if (opts.update) targets = targets.filter(p => installedFor(p.id, opts));
835
960
  if (targets.length === 0) {
836
961
  if (opts.update) {
837
962
  warn('Nothing to update: Chisle is not installed for any detected agent.');
838
963
  note('Run `npx chisle` to install it, or `npx chisle --list` to see what we look for.');
839
- note('Project-scoped agents (Cursor, Windsurf, Cline, Kiro, Copilot) are per-repo:');
964
+ note('Project-scoped agents (Cursor, Windsurf, Cline, Kiro, Copilot, Antigravity) are per-repo:');
840
965
  note('run this from the project that has the rule file.');
841
966
  return;
842
967
  }
@@ -852,6 +977,7 @@ function main() {
852
977
  else if (p.id === 'codex') installCodex(ctx);
853
978
  else if (p.id === 'opencode') installOpencode(ctx);
854
979
  else if (p.id === 'hermes') installHermes(ctx);
980
+ else if (p.id === 'copilot-cli') installCopilotCli(ctx);
855
981
  else installProjectRule(ctx, p);
856
982
  }
857
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 };