token-goat 2.9.29 → 2.9.31
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/README.md +53 -42
- package/SECURITY.md +37 -11
- package/THIRD_PARTY_NOTICES.md +41 -4
- package/dist/native/linux-arm64/tg-hook +0 -0
- package/dist/native/linux-x64/tg-hook +0 -0
- package/dist/native/win32-arm64/tg-hook.exe +0 -0
- package/dist/native/win32-x64/tg-hook.exe +0 -0
- package/dist/ort-wasm-simd-threaded.mjs +59 -0
- package/dist/{token-goat-chunk-SE6E4BKJ.mjs → token-goat-chunk-25TRAWET.mjs} +23 -6
- package/dist/token-goat-chunk-2KXO6V2B.mjs +203 -0
- package/dist/{token-goat-chunk-UXSXQQIY.mjs → token-goat-chunk-2M7E4PF7.mjs} +43 -14
- package/dist/token-goat-chunk-2UPZGKKY.mjs +188 -0
- package/dist/token-goat-chunk-2WLPWKHV.mjs +135 -0
- package/dist/token-goat-chunk-2XBMMDBD.mjs +126 -0
- package/dist/token-goat-chunk-32ZOY4N5.mjs +23 -0
- package/dist/token-goat-chunk-3EZRNKTK.mjs +188 -0
- package/dist/token-goat-chunk-4ADZHI7J.mjs +63 -0
- package/dist/token-goat-chunk-4GBVPH3D.mjs +242 -0
- package/dist/{token-goat-chunk-2WOUYWLM.mjs → token-goat-chunk-4GILLLWY.mjs} +34 -7
- package/dist/token-goat-chunk-4NAPX7BA.mjs +90 -0
- package/dist/{token-goat-chunk-NMBWLRHW.mjs → token-goat-chunk-4SC7UJYV.mjs} +9981 -11563
- package/dist/token-goat-chunk-4SMRO4TO.mjs +366 -0
- package/dist/{token-goat-chunk-O6I2XU4P.mjs → token-goat-chunk-4ZBFWLXF.mjs} +310 -441
- package/dist/token-goat-chunk-57BIAXJL.mjs +286 -0
- package/dist/token-goat-chunk-5AC66HNP.mjs +115 -0
- package/dist/token-goat-chunk-5IMLKSOP.mjs +145 -0
- package/dist/token-goat-chunk-5RG5SBL6.mjs +221 -0
- package/dist/{token-goat-chunk-RRNZMM3A.mjs → token-goat-chunk-5Y6ZYL6U.mjs} +21 -2
- package/dist/token-goat-chunk-646E6U44.mjs +398 -0
- package/dist/token-goat-chunk-65N4RHP4.mjs +33 -0
- package/dist/{token-goat-chunk-6AK46ZT7.mjs → token-goat-chunk-6DG6HWP3.mjs} +13 -3
- package/dist/token-goat-chunk-6ENQSNRZ.mjs +573 -0
- package/dist/token-goat-chunk-6ZY4GNF2.mjs +91 -0
- package/dist/{token-goat-chunk-E5TK5DNO.mjs → token-goat-chunk-76FGDLRO.mjs} +574 -340
- package/dist/{token-goat-chunk-TGOXNAHW.mjs → token-goat-chunk-7EZTZOMC.mjs} +780 -767
- package/dist/token-goat-chunk-7MAOA6X4.mjs +3647 -0
- package/dist/token-goat-chunk-7R2ZVVAX.mjs +218 -0
- package/dist/{token-goat-chunk-TCQANXYC.mjs → token-goat-chunk-AM5V6GSX.mjs} +18 -2
- package/dist/token-goat-chunk-AUNGYYQX.mjs +184 -0
- package/dist/token-goat-chunk-AUQYRMMH.mjs +2985 -0
- package/dist/token-goat-chunk-B3PEKBYG.mjs +128 -0
- package/dist/{token-goat-chunk-KOPIR3KZ.mjs → token-goat-chunk-BA4HI6BW.mjs} +1910 -165
- package/dist/{token-goat-chunk-52NSDWYY.mjs → token-goat-chunk-BFCWY73P.mjs} +58 -20
- package/dist/token-goat-chunk-BPGCUMR7.mjs +30 -0
- package/dist/token-goat-chunk-BWGFUZZE.mjs +25 -0
- package/dist/token-goat-chunk-C75NA7PL.mjs +322 -0
- package/dist/token-goat-chunk-CBIQ7WTD.mjs +87 -0
- package/dist/token-goat-chunk-CETM74KC.mjs +307 -0
- package/dist/token-goat-chunk-CIMQ2BB3.mjs +233 -0
- package/dist/{token-goat-chunk-DNSPXQXQ.mjs → token-goat-chunk-CJLS3CDL.mjs} +70 -18
- package/dist/{token-goat-chunk-MELOKFDJ.mjs → token-goat-chunk-CO3UYX6G.mjs} +7 -1
- package/dist/token-goat-chunk-COPNXRJM.mjs +154 -0
- package/dist/token-goat-chunk-CSJ5L3NL.mjs +479 -0
- package/dist/token-goat-chunk-CVCEVUGK.mjs +315 -0
- package/dist/token-goat-chunk-CXHOFN7R.mjs +371 -0
- package/dist/token-goat-chunk-D735C4QM.mjs +603 -0
- package/dist/{token-goat-chunk-E4ZOJIZK.mjs → token-goat-chunk-DGS2LVNA.mjs} +70 -160
- package/dist/{token-goat-chunk-L7H3U27L.mjs → token-goat-chunk-DOWRSMWO.mjs} +1 -1
- package/dist/token-goat-chunk-E45INMY3.mjs +71 -0
- package/dist/token-goat-chunk-EC5F37F4.mjs +244 -0
- package/dist/{token-goat-chunk-XTQAOTSO.mjs → token-goat-chunk-EDBDRVH5.mjs} +4 -1
- package/dist/token-goat-chunk-EELTBKDM.mjs +613 -0
- package/dist/token-goat-chunk-EVMZJ27Z.mjs +147 -0
- package/dist/token-goat-chunk-EXKGNHEG.mjs +2096 -0
- package/dist/{token-goat-chunk-YOOVFPTP.mjs → token-goat-chunk-F4HYL64R.mjs} +12 -2
- package/dist/token-goat-chunk-F6PAUIEK.mjs +624 -0
- package/dist/token-goat-chunk-FDSREWLX.mjs +692 -0
- package/dist/{token-goat-chunk-NMTKNYGF.mjs → token-goat-chunk-FNFBKLRD.mjs} +74 -9
- package/dist/token-goat-chunk-G5PJ7AGM.mjs +36 -0
- package/dist/token-goat-chunk-GGGG7RWB.mjs +236 -0
- package/dist/{token-goat-chunk-TDUT2CL6.mjs → token-goat-chunk-GL3ETKSC.mjs} +276 -19
- package/dist/{token-goat-chunk-7CPMDFGB.mjs → token-goat-chunk-GVB2MUST.mjs} +265 -1567
- package/dist/token-goat-chunk-GWFLAUMQ.mjs +307 -0
- package/dist/token-goat-chunk-HAVAOB3S.mjs +87 -0
- package/dist/token-goat-chunk-HLPW63A7.mjs +48 -0
- package/dist/token-goat-chunk-HU4TORC5.mjs +1377 -0
- package/dist/token-goat-chunk-I7VKZZAW.mjs +35 -0
- package/dist/token-goat-chunk-IA6XSPE3.mjs +218 -0
- package/dist/token-goat-chunk-IAGXMHIK.mjs +863 -0
- package/dist/{token-goat-chunk-435UQTKD.mjs → token-goat-chunk-IK2DRMFX.mjs} +129 -74
- package/dist/{token-goat-chunk-ZER6EAXZ.mjs → token-goat-chunk-INNPTRUX.mjs} +2954 -2783
- package/dist/token-goat-chunk-JJ5BX4F4.mjs +129 -0
- package/dist/token-goat-chunk-JVFSHVFJ.mjs +129 -0
- package/dist/token-goat-chunk-KGEFFSHD.mjs +50 -0
- package/dist/token-goat-chunk-KKUFXAPD.mjs +25 -0
- package/dist/token-goat-chunk-KQBLRPBN.mjs +433 -0
- package/dist/token-goat-chunk-LCNQ2CNN.mjs +497 -0
- package/dist/token-goat-chunk-M3LFJDPU.mjs +58 -0
- package/dist/token-goat-chunk-M52KPT3C.mjs +87 -0
- package/dist/token-goat-chunk-MBEG42H7.mjs +150 -0
- package/dist/{token-goat-chunk-EVC4TOLE.mjs → token-goat-chunk-METECDOH.mjs} +3 -12
- package/dist/{token-goat-chunk-2AWQ2R5X.mjs → token-goat-chunk-MHX7XCC3.mjs} +3 -2
- package/dist/token-goat-chunk-MNWKPRAL.mjs +13 -0
- package/dist/token-goat-chunk-N5LSOVSU.mjs +137 -0
- package/dist/{token-goat-chunk-DPNLUJWB.mjs → token-goat-chunk-N5MMR2IW.mjs} +30 -8
- package/dist/token-goat-chunk-N7B3JQ2C.mjs +160 -0
- package/dist/{token-goat-chunk-VMFWNHGR.mjs → token-goat-chunk-NISNMD7T.mjs} +2545 -2953
- package/dist/token-goat-chunk-NMD42SFQ.mjs +434 -0
- package/dist/token-goat-chunk-NOSS5VWY.mjs +174 -0
- package/dist/token-goat-chunk-NSNSPPBG.mjs +69 -0
- package/dist/token-goat-chunk-OBOFIHKN.mjs +38 -0
- package/dist/token-goat-chunk-OBRFJ5O5.mjs +76 -0
- package/dist/token-goat-chunk-ODBT2USM.mjs +800 -0
- package/dist/token-goat-chunk-OS5EM5PP.mjs +107 -0
- package/dist/token-goat-chunk-OUKDTVNU.mjs +1178 -0
- package/dist/token-goat-chunk-OXFMP75N.mjs +566 -0
- package/dist/{token-goat-chunk-ASLWOQ3I.mjs → token-goat-chunk-OYIYKDDT.mjs} +2 -1
- package/dist/{token-goat-chunk-67BNIKKO.mjs → token-goat-chunk-P6HQSWQ3.mjs} +55 -47
- package/dist/token-goat-chunk-P6VGNTQT.mjs +71 -0
- package/dist/token-goat-chunk-PFWIMH2E.mjs +261 -0
- package/dist/token-goat-chunk-PJZXIS3G.mjs +39 -0
- package/dist/token-goat-chunk-PUO5INXP.mjs +606 -0
- package/dist/token-goat-chunk-PX5PQWTY.mjs +462 -0
- package/dist/token-goat-chunk-Q5QDBC6O.mjs +111 -0
- package/dist/token-goat-chunk-QFNQXSDT.mjs +40 -0
- package/dist/token-goat-chunk-QH7YVYOY.mjs +3957 -0
- package/dist/token-goat-chunk-QLMUHEPB.mjs +43 -0
- package/dist/{token-goat-chunk-PYSUADX4.mjs → token-goat-chunk-QQOJIHL3.mjs} +50 -14
- package/dist/token-goat-chunk-QQTWJY2O.mjs +85 -0
- package/dist/token-goat-chunk-QYHO5CPH.mjs +93 -0
- package/dist/token-goat-chunk-RELAVWAD.mjs +27 -0
- package/dist/token-goat-chunk-RSLPOBKQ.mjs +339 -0
- package/dist/token-goat-chunk-SAMFDIS2.mjs +712 -0
- package/dist/token-goat-chunk-SDELXLDM.mjs +48 -0
- package/dist/{token-goat-chunk-PGTH2U4C.mjs → token-goat-chunk-SOAFZNNZ.mjs} +164 -40
- package/dist/{token-goat-chunk-G23DWHV4.mjs → token-goat-chunk-SRTHT7KV.mjs} +33 -15
- package/dist/token-goat-chunk-T5P5ACNQ.mjs +212 -0
- package/dist/token-goat-chunk-T5YGG4V3.mjs +45 -0
- package/dist/token-goat-chunk-TAUZ4OFS.mjs +110 -0
- package/dist/token-goat-chunk-TM6MC754.mjs +40 -0
- package/dist/token-goat-chunk-TWUKBOMP.mjs +72 -0
- package/dist/token-goat-chunk-U2KSB4SG.mjs +56 -0
- package/dist/token-goat-chunk-U4WHMG6Y.mjs +898 -0
- package/dist/token-goat-chunk-UA5V6P3P.mjs +122 -0
- package/dist/token-goat-chunk-UGJHGR5A.mjs +52 -0
- package/dist/{token-goat-chunk-RQGGMPCR.mjs → token-goat-chunk-UOUDVA5U.mjs} +76 -16
- package/dist/{token-goat-chunk-HNFZZHFW.mjs → token-goat-chunk-URRJ33GR.mjs} +22 -4
- package/dist/token-goat-chunk-UZOROKIS.mjs +165 -0
- package/dist/token-goat-chunk-VIHGGGEI.mjs +4234 -0
- package/dist/token-goat-chunk-VTJLAA7P.mjs +21 -0
- package/dist/token-goat-chunk-VWDXLT4F.mjs +678 -0
- package/dist/token-goat-chunk-VWZA2H4P.mjs +368 -0
- package/dist/{token-goat-chunk-QO3LXVVR.mjs → token-goat-chunk-VXEXMJDL.mjs} +2964 -3153
- package/dist/token-goat-chunk-WBA5SKT4.mjs +547 -0
- package/dist/{token-goat-chunk-ONXH6NZ5.mjs → token-goat-chunk-WDH44VAU.mjs} +19 -22
- package/dist/token-goat-chunk-X2SUP3EI.mjs +107 -0
- package/dist/token-goat-chunk-X3BELZSJ.mjs +125 -0
- package/dist/{token-goat-chunk-RLP6J7WR.mjs → token-goat-chunk-XLRBNZCC.mjs} +40 -21
- package/dist/{token-goat-chunk-S4VSXURL.mjs → token-goat-chunk-XRKUX6CM.mjs} +4 -0
- package/dist/token-goat-chunk-XX6O25RN.mjs +82 -0
- package/dist/token-goat-chunk-XZSUHBVS.mjs +65 -0
- package/dist/token-goat-chunk-Y2ZTQOCL.mjs +35 -0
- package/dist/{token-goat-chunk-UA5BKWWQ.mjs → token-goat-chunk-YB4VMGK2.mjs} +15 -4
- package/dist/token-goat-chunk-YBIIY3HR.mjs +51 -0
- package/dist/{token-goat-chunk-PKRWMHVP.mjs → token-goat-chunk-YHVDKQBR.mjs} +389 -221
- package/dist/token-goat-chunk-YJB5XLWI.mjs +26 -0
- package/dist/{token-goat-chunk-PQ53BSCF.mjs → token-goat-chunk-YKB6AQG3.mjs} +53 -31
- package/dist/token-goat-chunk-YMTG2YA5.mjs +35 -0
- package/dist/token-goat-chunk-YPBZYQRV.mjs +40 -0
- package/dist/{token-goat-chunk-HUYUHODZ.mjs → token-goat-chunk-YY6AVUFL.mjs} +13246 -12247
- package/dist/token-goat-chunk-Z7N5OMMP.mjs +98 -0
- package/dist/{token-goat-chunk-BGHSSJUA.mjs → token-goat-chunk-ZFOBKUWH.mjs} +1 -1
- package/dist/token-goat-chunk-ZMBL6UVV.mjs +110 -0
- package/dist/token-goat-chunk-ZNMNUPAZ.mjs +938 -0
- package/dist/token-goat-hook-client.cjs +87 -23
- package/dist/token-goat-hook-client.mjs +3 -3
- package/dist/token-goat-hook.mjs +76 -31
- package/dist/token-goat.core.mjs +6 -6
- package/docs/C4_RUNTIME_ARCHITECTURE.md +2 -2
- package/docs/cli.md +113 -60
- package/docs/install.md +63 -20
- package/docs/security.md +10 -8
- package/package.json +18 -7
- package/scripts/verify-native-dist.mjs +386 -0
- package/dist/token-goat-chunk-2NNUA2LR.mjs +0 -143
- package/dist/token-goat-chunk-44BM7DDQ.mjs +0 -294
- package/dist/token-goat-chunk-BGYOZ6DO.mjs +0 -24
- package/dist/token-goat-chunk-C5YPNZKQ.mjs +0 -318
- package/dist/token-goat-chunk-CIQ2FTY3.mjs +0 -1990
- package/dist/token-goat-chunk-D4ETHJD6.mjs +0 -246
- package/dist/token-goat-chunk-DNRPXHYH.mjs +0 -1062
- package/dist/token-goat-chunk-EUSDXRXU.mjs +0 -1429
- package/dist/token-goat-chunk-FTP4U4QA.mjs +0 -64
- package/dist/token-goat-chunk-G7IWXCFF.mjs +0 -456
- package/dist/token-goat-chunk-GLXRTTLE.mjs +0 -36
- package/dist/token-goat-chunk-H5J3C56Q.mjs +0 -1511
- package/dist/token-goat-chunk-IVWI2OCK.mjs +0 -368
- package/dist/token-goat-chunk-JTKCQMQA.mjs +0 -418
- package/dist/token-goat-chunk-LZNBQTDK.mjs +0 -26
- package/dist/token-goat-chunk-ORNFMIER.mjs +0 -295
- package/dist/token-goat-chunk-RTMVLHWX.mjs +0 -7113
- package/dist/token-goat-chunk-VQ6F6T7X.mjs +0 -36
- package/dist/token-goat-chunk-VZQEWRVH.mjs +0 -312
- package/dist/token-goat-chunk-XYWPOSCN.mjs +0 -807
- package/dist/token-goat-chunk-YAGLESS3.mjs +0 -23
- package/dist/token-goat-chunk-YKSRAWUU.mjs +0 -33
- package/dist/token-goat-chunk-Z6KBMOBI.mjs +0 -53
|
@@ -0,0 +1,2985 @@
|
|
|
1
|
+
import { createRequire as __cjsRequire } from 'node:module';
|
|
2
|
+
const require = __cjsRequire(import.meta.url);
|
|
3
|
+
import {
|
|
4
|
+
HookCommandRewriteError,
|
|
5
|
+
LEGACY_SHIM_FILE,
|
|
6
|
+
MATERIALIZE_SHRUNK_IMAGE_JS,
|
|
7
|
+
SHIM_FILE,
|
|
8
|
+
SHIM_MAX_BUFFER_CONST,
|
|
9
|
+
SHIM_REQUIRES,
|
|
10
|
+
SHIM_SPAWN_LADDER,
|
|
11
|
+
SHIM_TRY_IN_PROCESS,
|
|
12
|
+
SHIM_TRY_SERVER,
|
|
13
|
+
SHIM_VALID_HOOK_EVENTS,
|
|
14
|
+
anchoredMarkerPattern,
|
|
15
|
+
buildGuidanceBlock,
|
|
16
|
+
buildGuidanceBody,
|
|
17
|
+
bundledCliPath,
|
|
18
|
+
canonicalProjectRoot,
|
|
19
|
+
claudeHooksInstalledAnyScope,
|
|
20
|
+
copilotHooksFilePaths,
|
|
21
|
+
copilotMcpConfigPath,
|
|
22
|
+
dropEmptyServers,
|
|
23
|
+
editAt,
|
|
24
|
+
findStrayClaudeMdBlocks,
|
|
25
|
+
finishHookUninstall,
|
|
26
|
+
groupHasTokenGoat,
|
|
27
|
+
hasManagedServer,
|
|
28
|
+
hookCommandRewrittenSpan,
|
|
29
|
+
installCopilotHooksFile,
|
|
30
|
+
isManagedServer,
|
|
31
|
+
isResidueServersJson,
|
|
32
|
+
legacyShimForwarder,
|
|
33
|
+
managedServer,
|
|
34
|
+
managedServerEntry,
|
|
35
|
+
nativeHookBinary,
|
|
36
|
+
nativeHookCommandLine,
|
|
37
|
+
noteRootKeyCreation,
|
|
38
|
+
readServersJson,
|
|
39
|
+
releaseCopilotHooksFile,
|
|
40
|
+
serversOf,
|
|
41
|
+
setTokenGoatServer,
|
|
42
|
+
shimFallbackTimeoutConst,
|
|
43
|
+
skillDescriptionLine,
|
|
44
|
+
splitHookCommand,
|
|
45
|
+
syncVisualStudioProjectGuidance,
|
|
46
|
+
visualStudioManagedEntry
|
|
47
|
+
} from "./token-goat-chunk-7MAOA6X4.mjs";
|
|
48
|
+
import {
|
|
49
|
+
parseJsonOrJsonc,
|
|
50
|
+
stripBom
|
|
51
|
+
} from "./token-goat-chunk-QQTWJY2O.mjs";
|
|
52
|
+
import {
|
|
53
|
+
readCopilotHooksOwners
|
|
54
|
+
} from "./token-goat-chunk-G5PJ7AGM.mjs";
|
|
55
|
+
import {
|
|
56
|
+
toolMatcherFor
|
|
57
|
+
} from "./token-goat-chunk-C75NA7PL.mjs";
|
|
58
|
+
import {
|
|
59
|
+
GEMINI_TOOL_NAME_MAP
|
|
60
|
+
} from "./token-goat-chunk-76FGDLRO.mjs";
|
|
61
|
+
import {
|
|
62
|
+
loadConfig,
|
|
63
|
+
parse,
|
|
64
|
+
stringify
|
|
65
|
+
} from "./token-goat-chunk-7EZTZOMC.mjs";
|
|
66
|
+
import {
|
|
67
|
+
assertProjectScopeTarget,
|
|
68
|
+
atomicWriteBytes,
|
|
69
|
+
atomicWriteText,
|
|
70
|
+
backupFile,
|
|
71
|
+
countNoun,
|
|
72
|
+
ensureDirRecordingCreation,
|
|
73
|
+
ensureDirSync,
|
|
74
|
+
extractErrorMessage,
|
|
75
|
+
hasCreatedConfig,
|
|
76
|
+
hookCommandFor,
|
|
77
|
+
hookPowershellCommand,
|
|
78
|
+
installSingleFilePlugin,
|
|
79
|
+
projectPathIsConsultable,
|
|
80
|
+
projectScopeRoot,
|
|
81
|
+
recordCreatedBy,
|
|
82
|
+
recordCreatedConfig,
|
|
83
|
+
recordTurnedOffSetting,
|
|
84
|
+
removeCreatedBackups,
|
|
85
|
+
removeCreatedIfEmpty,
|
|
86
|
+
removeCreatedTree,
|
|
87
|
+
removeEmptyDirInScope,
|
|
88
|
+
removeFileInScope,
|
|
89
|
+
stripDelimitedBlock,
|
|
90
|
+
stripOwnHooksFromMap,
|
|
91
|
+
stripStaleGroupHooks,
|
|
92
|
+
takeCreatedConfig,
|
|
93
|
+
takeTurnedOffSetting,
|
|
94
|
+
uninstallSingleFilePlugin,
|
|
95
|
+
upsertDelimitedBlock,
|
|
96
|
+
withInstallScope,
|
|
97
|
+
writeConfigText,
|
|
98
|
+
writeIfDifferent,
|
|
99
|
+
writeJsonSettings
|
|
100
|
+
} from "./token-goat-chunk-HU4TORC5.mjs";
|
|
101
|
+
import {
|
|
102
|
+
echoedValue,
|
|
103
|
+
fencedCommand
|
|
104
|
+
} from "./token-goat-chunk-GGGG7RWB.mjs";
|
|
105
|
+
import {
|
|
106
|
+
foldPath,
|
|
107
|
+
powershellHookLine,
|
|
108
|
+
quoteShellPath
|
|
109
|
+
} from "./token-goat-chunk-AM5V6GSX.mjs";
|
|
110
|
+
import {
|
|
111
|
+
displaySafeText,
|
|
112
|
+
normalizePath
|
|
113
|
+
} from "./token-goat-chunk-FNFBKLRD.mjs";
|
|
114
|
+
import {
|
|
115
|
+
init_define_import_meta_env
|
|
116
|
+
} from "./token-goat-chunk-A37V4PBF.mjs";
|
|
117
|
+
|
|
118
|
+
// src/bridges/gemini_install.ts
|
|
119
|
+
init_define_import_meta_env();
|
|
120
|
+
import * as fs2 from "node:fs";
|
|
121
|
+
import * as os from "node:os";
|
|
122
|
+
import * as path2 from "node:path";
|
|
123
|
+
|
|
124
|
+
// src/bridges/commented_settings.ts
|
|
125
|
+
init_define_import_meta_env();
|
|
126
|
+
import * as fs from "node:fs";
|
|
127
|
+
import * as path from "node:path";
|
|
128
|
+
function isPlainObject(value) {
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
|
+
}
|
|
131
|
+
function applyDiff(text, prev, next, at) {
|
|
132
|
+
let out = text;
|
|
133
|
+
for (const key of Object.keys(prev)) {
|
|
134
|
+
if (!(key in next)) out = editAt(out, [...at, key], void 0);
|
|
135
|
+
}
|
|
136
|
+
for (const key of Object.keys(next)) {
|
|
137
|
+
const before = prev[key];
|
|
138
|
+
const after = next[key];
|
|
139
|
+
if (after === void 0) {
|
|
140
|
+
if (key in prev) out = editAt(out, [...at, key], void 0);
|
|
141
|
+
} else if (!(key in prev)) out = editAt(out, [...at, key], after);
|
|
142
|
+
else if (isPlainObject(before) && isPlainObject(after)) out = applyDiff(out, before, after, [...at, key]);
|
|
143
|
+
else if (JSON.stringify(before) !== JSON.stringify(after)) out = editAt(out, [...at, key], after);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
function writeSettingsKeepingComments(p, next, opts = {}) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = fs.readFileSync(p, "utf8");
|
|
151
|
+
} catch {
|
|
152
|
+
raw = void 0;
|
|
153
|
+
}
|
|
154
|
+
if (raw === void 0 || !isPlainObject(next)) return writeJsonSettings(p, next);
|
|
155
|
+
try {
|
|
156
|
+
JSON.parse(raw);
|
|
157
|
+
return writeJsonSettings(p, next);
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
const body = stripBom(raw);
|
|
161
|
+
let prev;
|
|
162
|
+
try {
|
|
163
|
+
prev = parseJsonOrJsonc(body, opts);
|
|
164
|
+
} catch {
|
|
165
|
+
return writeJsonSettings(p, next);
|
|
166
|
+
}
|
|
167
|
+
if (!isPlainObject(prev)) return writeJsonSettings(p, next);
|
|
168
|
+
const edited = applyDiff(body, prev, next, []);
|
|
169
|
+
ensureDirSync(path.dirname(p));
|
|
170
|
+
backupFile(p);
|
|
171
|
+
writeConfigText(p, edited.endsWith("\n") ? edited : `${edited}
|
|
172
|
+
`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/bridges/gemini_install.ts
|
|
176
|
+
var GEMINI_LEGACY_COMMAND_MARKER = "token-goat hook";
|
|
177
|
+
var GEMINI_HOOK_EVENTS = ["BeforeTool", "AfterTool", "PreCompress"];
|
|
178
|
+
var GEMINI_EVENT_ARG = {
|
|
179
|
+
BeforeTool: "pre_tool_use",
|
|
180
|
+
AfterTool: "post_tool_use",
|
|
181
|
+
PreCompress: "pre_compact"
|
|
182
|
+
};
|
|
183
|
+
function registeredInternalTools(eventName) {
|
|
184
|
+
const matcher = toolMatcherFor(eventName);
|
|
185
|
+
const names = /* @__PURE__ */ new Set();
|
|
186
|
+
if (matcher === null) return names;
|
|
187
|
+
for (const part of matcher.split("|")) {
|
|
188
|
+
const match = /^\^([A-Za-z]+)\$$/.exec(part);
|
|
189
|
+
if (match?.[1] !== void 0) names.add(match[1]);
|
|
190
|
+
}
|
|
191
|
+
return names;
|
|
192
|
+
}
|
|
193
|
+
var GeminiSettingsParseError = class extends Error {
|
|
194
|
+
};
|
|
195
|
+
function geminiSettingsPath() {
|
|
196
|
+
return path2.join(os.homedir(), ".gemini", "settings.json");
|
|
197
|
+
}
|
|
198
|
+
function readGeminiSettings(p, opts = {}) {
|
|
199
|
+
const refuse = (problem, detail) => new GeminiSettingsParseError(
|
|
200
|
+
(opts.command === "uninstall" ? `Gemini settings file ${echoedValue(p)} is unreadable: it ${problem}. Uninstall left it and its backups untouched; fix the file and run uninstall again.` : `Gemini settings file ${echoedValue(p)} ${problem}. Fix or back up the file before running install.`) + (detail === void 0 ? "" : ` (${detail})`)
|
|
201
|
+
);
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = fs2.readFileSync(p, "utf8");
|
|
205
|
+
} catch (e) {
|
|
206
|
+
const code = e.code;
|
|
207
|
+
if (opts.strict === true && code !== "ENOENT" && code !== "ENOTDIR") throw refuse("exists but cannot be read", code ?? extractErrorMessage(e));
|
|
208
|
+
return {};
|
|
209
|
+
}
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = parseJsonOrJsonc(raw, { allowTrailingComma: false });
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (opts.strict === true) throw refuse("exists but contains invalid JSON", extractErrorMessage(e));
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
218
|
+
return parsed;
|
|
219
|
+
}
|
|
220
|
+
if (opts.strict === true) throw refuse("does not contain a JSON object at the top level");
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
var GEMINI_LEGACY_MARKER_PATTERN = anchoredMarkerPattern(GEMINI_LEGACY_COMMAND_MARKER);
|
|
224
|
+
var GEMINI_COMMAND_PATTERN = /^"[^"]+"\s+"([^"]+)"\s+hook\s+(?:pre_tool_use|post_tool_use|pre_compact)$/;
|
|
225
|
+
var GEMINI_ENTRY_PATH_MARKER_PATTERN = anchoredMarkerPattern("token-goat");
|
|
226
|
+
function isGeminiTokenGoatCommand(command) {
|
|
227
|
+
if (typeof command !== "string") return false;
|
|
228
|
+
const match = GEMINI_COMMAND_PATTERN.exec(command);
|
|
229
|
+
if (match) {
|
|
230
|
+
const entryPath = match[1] ?? "";
|
|
231
|
+
return GEMINI_ENTRY_PATH_MARKER_PATTERN.test(entryPath);
|
|
232
|
+
}
|
|
233
|
+
return GEMINI_LEGACY_MARKER_PATTERN.test(command);
|
|
234
|
+
}
|
|
235
|
+
function isCurrentGeminiTokenGoatCommand(command, desiredCommand) {
|
|
236
|
+
if (typeof command !== "string") return false;
|
|
237
|
+
return command === desiredCommand;
|
|
238
|
+
}
|
|
239
|
+
function geminiHookCommand(eventArg) {
|
|
240
|
+
const entryPath = process.argv[1];
|
|
241
|
+
if (!entryPath) return `token-goat hook ${eventArg}`;
|
|
242
|
+
return `${quoteShellPath(process.execPath)} ${quoteShellPath(entryPath)} hook ${eventArg}`;
|
|
243
|
+
}
|
|
244
|
+
function geminiNamesByInternalTool() {
|
|
245
|
+
const out = /* @__PURE__ */ new Map();
|
|
246
|
+
for (const [geminiName, internalTool] of Object.entries(GEMINI_TOOL_NAME_MAP)) {
|
|
247
|
+
const list = out.get(internalTool);
|
|
248
|
+
if (list) {
|
|
249
|
+
list.push(geminiName);
|
|
250
|
+
} else {
|
|
251
|
+
out.set(internalTool, [geminiName]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
function desiredMatchersFor(event) {
|
|
257
|
+
if (event === "PreCompress") return [void 0];
|
|
258
|
+
const toolSet = registeredInternalTools(GEMINI_EVENT_ARG[event]);
|
|
259
|
+
const matchers = [];
|
|
260
|
+
for (const [internalTool, names] of geminiNamesByInternalTool()) {
|
|
261
|
+
if (!toolSet.has(internalTool)) continue;
|
|
262
|
+
matchers.push(`^(${names.join("|")})$`);
|
|
263
|
+
}
|
|
264
|
+
return matchers;
|
|
265
|
+
}
|
|
266
|
+
function installGemini() {
|
|
267
|
+
const p = geminiSettingsPath();
|
|
268
|
+
const settings = readGeminiSettings(p, { strict: true });
|
|
269
|
+
const hooks = settings.hooks ?? {};
|
|
270
|
+
let changed = false;
|
|
271
|
+
for (const event of GEMINI_HOOK_EVENTS) {
|
|
272
|
+
const command = geminiHookCommand(GEMINI_EVENT_ARG[event]);
|
|
273
|
+
const groups = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
|
|
274
|
+
for (const matcher of desiredMatchersFor(event)) {
|
|
275
|
+
if (groupHasTokenGoat(groups, matcher, (c) => isCurrentGeminiTokenGoatCommand(c, command))) continue;
|
|
276
|
+
const nextGroups = stripStaleGroupHooks(groups, isGeminiTokenGoatCommand, { matcher });
|
|
277
|
+
groups.length = 0;
|
|
278
|
+
groups.push(...nextGroups);
|
|
279
|
+
const group = matcher === void 0 ? { hooks: [{ type: "command", command }] } : { matcher, hooks: [{ type: "command", command }] };
|
|
280
|
+
groups.push(group);
|
|
281
|
+
changed = true;
|
|
282
|
+
}
|
|
283
|
+
hooks[event] = groups;
|
|
284
|
+
}
|
|
285
|
+
if (!changed) {
|
|
286
|
+
return { settingsPath: p, alreadyInstalled: true };
|
|
287
|
+
}
|
|
288
|
+
settings.hooks = hooks;
|
|
289
|
+
recordCreatedBy([p], () => writeSettingsKeepingComments(p, settings, { allowTrailingComma: false }));
|
|
290
|
+
return { settingsPath: p, alreadyInstalled: false };
|
|
291
|
+
}
|
|
292
|
+
function uninstallGemini() {
|
|
293
|
+
const p = geminiSettingsPath();
|
|
294
|
+
const settings = readGeminiSettings(p, { strict: true, command: "uninstall" });
|
|
295
|
+
const removed = settings.hooks !== void 0 && stripOwnHooksFromMap(settings.hooks, isGeminiTokenGoatCommand);
|
|
296
|
+
const result = finishHookUninstall(p, settings, removed, (file, next) => writeSettingsKeepingComments(file, next, { allowTrailingComma: false }));
|
|
297
|
+
removeCreatedTree([p]);
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
function isGeminiInstalled() {
|
|
301
|
+
const settings = readGeminiSettings(geminiSettingsPath());
|
|
302
|
+
const hooks = settings.hooks;
|
|
303
|
+
if (hooks === void 0) return false;
|
|
304
|
+
for (const event of GEMINI_HOOK_EVENTS) {
|
|
305
|
+
const command = geminiHookCommand(GEMINI_EVENT_ARG[event]);
|
|
306
|
+
for (const matcher of desiredMatchersFor(event)) {
|
|
307
|
+
if (!groupHasTokenGoat(hooks[event], matcher, (c) => isCurrentGeminiTokenGoatCommand(c, command))) return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/bridges/qwen_install.ts
|
|
314
|
+
init_define_import_meta_env();
|
|
315
|
+
import * as fs3 from "node:fs";
|
|
316
|
+
import * as os2 from "node:os";
|
|
317
|
+
import * as path3 from "node:path";
|
|
318
|
+
var QWEN_HOOK_EVENTS = ["PreToolUse", "PostToolUse", "PreCompact", "UserPromptSubmit", "SubagentStop"];
|
|
319
|
+
var QWEN_EVENT_ARG = {
|
|
320
|
+
PreToolUse: "pre_tool_use",
|
|
321
|
+
PostToolUse: "post_tool_use",
|
|
322
|
+
PreCompact: "pre_compact",
|
|
323
|
+
UserPromptSubmit: "user_prompt_submit",
|
|
324
|
+
SubagentStop: "subagent_stop"
|
|
325
|
+
};
|
|
326
|
+
var QwenSettingsParseError = class extends Error {
|
|
327
|
+
};
|
|
328
|
+
function qwenSettingsPath() {
|
|
329
|
+
return path3.join(os2.homedir(), ".qwen", "settings.json");
|
|
330
|
+
}
|
|
331
|
+
function readQwenSettings(p, opts = {}) {
|
|
332
|
+
const refuse = (problem, detail) => new QwenSettingsParseError(
|
|
333
|
+
(opts.command === "uninstall" ? `Qwen Code settings file ${echoedValue(p)} is unreadable: it ${problem}. Uninstall left it and its backups untouched; fix the file and run uninstall again.` : `Qwen Code settings file ${echoedValue(p)} ${problem}. Fix or back up the file before running install.`) + (detail === void 0 ? "" : ` (${detail})`)
|
|
334
|
+
);
|
|
335
|
+
let raw;
|
|
336
|
+
try {
|
|
337
|
+
raw = fs3.readFileSync(p, "utf8");
|
|
338
|
+
} catch (e) {
|
|
339
|
+
const code = e.code;
|
|
340
|
+
if (opts.strict === true && code !== "ENOENT" && code !== "ENOTDIR") throw refuse("exists but cannot be read", code ?? extractErrorMessage(e));
|
|
341
|
+
return {};
|
|
342
|
+
}
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = parseJsonOrJsonc(raw, { allowTrailingComma: false });
|
|
346
|
+
} catch (e) {
|
|
347
|
+
if (opts.strict === true) throw refuse("exists but contains invalid JSON", extractErrorMessage(e));
|
|
348
|
+
return {};
|
|
349
|
+
}
|
|
350
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
351
|
+
return parsed;
|
|
352
|
+
}
|
|
353
|
+
if (opts.strict === true) throw refuse("does not contain a JSON object at the top level");
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
var QWEN_LEGACY_COMMAND_MARKER = "token-goat hook";
|
|
357
|
+
var QWEN_LEGACY_MARKER_PATTERN = anchoredMarkerPattern(QWEN_LEGACY_COMMAND_MARKER);
|
|
358
|
+
var QWEN_COMMAND_PATTERN = /^"[^"]+"\s+"([^"]+)"\s+hook\s+(?:pre_tool_use|post_tool_use|pre_compact|user_prompt_submit|subagent_stop)(?:\s+--harness\s+qwen)?$/;
|
|
359
|
+
var QWEN_ENTRY_PATH_MARKER_PATTERN = anchoredMarkerPattern("token-goat");
|
|
360
|
+
function isQwenTokenGoatCommand(command) {
|
|
361
|
+
if (typeof command !== "string") return false;
|
|
362
|
+
const match = QWEN_COMMAND_PATTERN.exec(command);
|
|
363
|
+
if (match) {
|
|
364
|
+
const entryPath = match[1] ?? "";
|
|
365
|
+
return QWEN_ENTRY_PATH_MARKER_PATTERN.test(entryPath);
|
|
366
|
+
}
|
|
367
|
+
return QWEN_LEGACY_MARKER_PATTERN.test(command);
|
|
368
|
+
}
|
|
369
|
+
function isCurrentQwenTokenGoatCommand(command, desiredCommand) {
|
|
370
|
+
if (typeof command !== "string") return false;
|
|
371
|
+
return command === desiredCommand;
|
|
372
|
+
}
|
|
373
|
+
function qwenHookCommand(eventArg) {
|
|
374
|
+
const entryPath = process.argv[1];
|
|
375
|
+
if (!entryPath) return `token-goat hook ${eventArg} --harness qwen`;
|
|
376
|
+
return `${quoteShellPath(process.execPath)} ${quoteShellPath(entryPath)} hook ${eventArg} --harness qwen`;
|
|
377
|
+
}
|
|
378
|
+
function installQwen() {
|
|
379
|
+
const p = qwenSettingsPath();
|
|
380
|
+
const settings = readQwenSettings(p, { strict: true });
|
|
381
|
+
const hooks = settings.hooks ?? {};
|
|
382
|
+
let changed = false;
|
|
383
|
+
for (const event of QWEN_HOOK_EVENTS) {
|
|
384
|
+
const command = qwenHookCommand(QWEN_EVENT_ARG[event]);
|
|
385
|
+
const existingGroups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
386
|
+
if (groupHasTokenGoat(existingGroups, "", (c) => isCurrentQwenTokenGoatCommand(c, command))) {
|
|
387
|
+
hooks[event] = existingGroups;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const groups = stripStaleGroupHooks(existingGroups, isQwenTokenGoatCommand);
|
|
391
|
+
groups.push({ matcher: "", hooks: [{ type: "command", command }] });
|
|
392
|
+
hooks[event] = groups;
|
|
393
|
+
changed = true;
|
|
394
|
+
}
|
|
395
|
+
if (!changed) {
|
|
396
|
+
return { settingsPath: p, alreadyInstalled: true };
|
|
397
|
+
}
|
|
398
|
+
settings.hooks = hooks;
|
|
399
|
+
recordCreatedBy([p], () => writeSettingsKeepingComments(p, settings, { allowTrailingComma: false }));
|
|
400
|
+
return { settingsPath: p, alreadyInstalled: false };
|
|
401
|
+
}
|
|
402
|
+
function uninstallQwen() {
|
|
403
|
+
const p = qwenSettingsPath();
|
|
404
|
+
const settings = readQwenSettings(p, { strict: true, command: "uninstall" });
|
|
405
|
+
const removed = settings.hooks !== void 0 && stripOwnHooksFromMap(settings.hooks, isQwenTokenGoatCommand);
|
|
406
|
+
const result = finishHookUninstall(p, settings, removed, (file, next) => writeSettingsKeepingComments(file, next, { allowTrailingComma: false }));
|
|
407
|
+
removeCreatedTree([p]);
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
function isQwenInstalled() {
|
|
411
|
+
const settings = readQwenSettings(qwenSettingsPath());
|
|
412
|
+
const hooks = settings.hooks;
|
|
413
|
+
if (hooks === void 0) return false;
|
|
414
|
+
for (const event of QWEN_HOOK_EVENTS) {
|
|
415
|
+
const command = qwenHookCommand(QWEN_EVENT_ARG[event]);
|
|
416
|
+
if (!groupHasTokenGoat(hooks[event], "", (c) => isCurrentQwenTokenGoatCommand(c, command))) return false;
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/bridges/kimi_install.ts
|
|
422
|
+
init_define_import_meta_env();
|
|
423
|
+
import * as fs4 from "node:fs";
|
|
424
|
+
import * as os3 from "node:os";
|
|
425
|
+
import * as path4 from "node:path";
|
|
426
|
+
|
|
427
|
+
// src/bridges/kimi.ts
|
|
428
|
+
init_define_import_meta_env();
|
|
429
|
+
var KIMI_HOOK_SCRIPT = `#!/usr/bin/env node
|
|
430
|
+
// token-goat Kimi Code hook shim. Forwards the hook payload to \`token-goat hook <event>\`, then rewrites the response into Kimi's own contract: permissionDecision "deny" for a block, top-level "message" for a hint, empty stdout for a no-op.
|
|
431
|
+
'use strict'
|
|
432
|
+
${SHIM_REQUIRES}
|
|
433
|
+
|
|
434
|
+
${SHIM_VALID_HOOK_EVENTS}
|
|
435
|
+
|
|
436
|
+
// Kimi Code publishes no ambient per-session env var identifying its own hook
|
|
437
|
+
// subprocesses, so the harness identity has to be injected here for
|
|
438
|
+
// detectHarness() (src/bridges/registry.ts) to resolve 'kimi'.
|
|
439
|
+
process.env.TOKEN_GOAT_HARNESS_OVERRIDE = 'kimi'
|
|
440
|
+
|
|
441
|
+
// Pull the hint text out of a token-goat response: hookSpecificOutput.additionalContext
|
|
442
|
+
// (most events) or the top-level systemMessage (notification/pre_compact, which Claude
|
|
443
|
+
// Code rejects additionalContext on). Kimi reads neither, so both fold into its 'message'.
|
|
444
|
+
function hintText(parsed) {
|
|
445
|
+
if (!parsed || typeof parsed !== 'object') return ''
|
|
446
|
+
const hso = parsed.hookSpecificOutput
|
|
447
|
+
if (hso && typeof hso === 'object' && typeof hso.additionalContext === 'string') return hso.additionalContext
|
|
448
|
+
if (typeof parsed.systemMessage === 'string') return parsed.systemMessage
|
|
449
|
+
return ''
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Translate a token-goat (Claude Code shaped) response into Kimi's contract.
|
|
453
|
+
// Returns '' for anything Kimi cannot act on, which the caller writes as empty
|
|
454
|
+
// stdout rather than '{}' -- see this module's docstring for why that matters
|
|
455
|
+
// on UserPromptSubmit.
|
|
456
|
+
function toKimi(parsed) {
|
|
457
|
+
if (!parsed || typeof parsed !== 'object') return ''
|
|
458
|
+
if (parsed.decision === 'block' && typeof parsed.reason === 'string' && parsed.reason) {
|
|
459
|
+
return JSON.stringify({
|
|
460
|
+
message: parsed.reason,
|
|
461
|
+
hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: parsed.reason },
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
const hint = hintText(parsed)
|
|
465
|
+
if (hint) return JSON.stringify({ message: hint })
|
|
466
|
+
return ''
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
${SHIM_TRY_SERVER}
|
|
470
|
+
|
|
471
|
+
${SHIM_TRY_IN_PROCESS}
|
|
472
|
+
|
|
473
|
+
${SHIM_MAX_BUFFER_CONST}
|
|
474
|
+
${shimFallbackTimeoutConst("kimi")}
|
|
475
|
+
|
|
476
|
+
async function main() {
|
|
477
|
+
const eventName = process.argv[2] || ''
|
|
478
|
+
if (!VALID_HOOK_EVENTS.has(eventName)) return
|
|
479
|
+
let input = ''
|
|
480
|
+
try {
|
|
481
|
+
input = require('node:fs').readFileSync(0, 'utf8')
|
|
482
|
+
} catch {
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
// process.argv[3], when present, is the absolute path to the token-goat CLI entry that ran
|
|
486
|
+
// 'token-goat install --kimi' (baked in by hookCommandFor in kimi_install.ts). Same three-step
|
|
487
|
+
// ladder as the Codex shim: in-process hook lib, then the baked entry via process.execPath,
|
|
488
|
+
// then a PATH-based shell:true invocation for an older cached hook config. The SHIM_FALLBACK_TIMEOUT_MS
|
|
489
|
+
// timeout keeps both spawn fallbacks well inside Kimi's own 30s default hook timeout
|
|
490
|
+
// (docs/en/customization/hooks.md), so token-goat degrades to its own fail-open no-op.
|
|
491
|
+
const entryPath = process.argv[3]
|
|
492
|
+
let stdout = await tryInProcess(entryPath, eventName, input)
|
|
493
|
+
if (stdout === undefined) {
|
|
494
|
+
${SHIM_SPAWN_LADDER}
|
|
495
|
+
: spawnSync('token-goat hook ' + eventName, {
|
|
496
|
+
input,
|
|
497
|
+
encoding: 'utf8',
|
|
498
|
+
shell: true,
|
|
499
|
+
timeout: SHIM_FALLBACK_TIMEOUT_MS,
|
|
500
|
+
killSignal: 'SIGKILL',
|
|
501
|
+
maxBuffer: SHIM_MAX_BUFFER_BYTES,
|
|
502
|
+
})
|
|
503
|
+
if (res.status !== 0 || !res.stdout) return
|
|
504
|
+
stdout = res.stdout
|
|
505
|
+
}
|
|
506
|
+
let parsed
|
|
507
|
+
try {
|
|
508
|
+
parsed = JSON.parse(stdout)
|
|
509
|
+
} catch {
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
const out = toKimi(parsed)
|
|
513
|
+
if (out) process.stdout.write(out)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
main().catch(() => {
|
|
517
|
+
// Fail open: write nothing, exit 0. Kimi treats a silent hook as "allow".
|
|
518
|
+
})
|
|
519
|
+
`;
|
|
520
|
+
|
|
521
|
+
// src/bridges/kimi_install.ts
|
|
522
|
+
var KIMI_EVENT_ARG = {
|
|
523
|
+
PreToolUse: "pre_tool_use",
|
|
524
|
+
PostToolUse: "post_tool_use",
|
|
525
|
+
PreCompact: "pre_compact",
|
|
526
|
+
UserPromptSubmit: "user_prompt_submit",
|
|
527
|
+
SubagentStop: "subagent_stop",
|
|
528
|
+
SessionStart: "session_start"
|
|
529
|
+
};
|
|
530
|
+
var KIMI_HOOK_EVENTS = Object.keys(KIMI_EVENT_ARG);
|
|
531
|
+
var KimiConfigParseError = class extends Error {
|
|
532
|
+
};
|
|
533
|
+
function kimiHome() {
|
|
534
|
+
const override = process.env["KIMI_CODE_HOME"];
|
|
535
|
+
if (override !== void 0 && override.trim() !== "") return path4.resolve(override);
|
|
536
|
+
return path4.join(os3.homedir(), ".kimi-code");
|
|
537
|
+
}
|
|
538
|
+
function kimiConfigPath() {
|
|
539
|
+
return path4.join(kimiHome(), "config.toml");
|
|
540
|
+
}
|
|
541
|
+
function kimiAgentsPath() {
|
|
542
|
+
return path4.join(kimiHome(), "AGENTS.md");
|
|
543
|
+
}
|
|
544
|
+
function kimiHookScriptPath() {
|
|
545
|
+
return path4.join(kimiHome(), "hooks", SHIM_FILE);
|
|
546
|
+
}
|
|
547
|
+
function kimiLegacyHookScriptPath() {
|
|
548
|
+
return path4.join(kimiHome(), "hooks", LEGACY_SHIM_FILE);
|
|
549
|
+
}
|
|
550
|
+
function kimiSkillDir() {
|
|
551
|
+
return path4.join(kimiHome(), "skills", "token-goat");
|
|
552
|
+
}
|
|
553
|
+
function kimiSkillPath() {
|
|
554
|
+
return path4.join(kimiSkillDir(), "SKILL.md");
|
|
555
|
+
}
|
|
556
|
+
function readKimiConfig(p, opts = {}) {
|
|
557
|
+
const refuse = (problem, detail) => new KimiConfigParseError(
|
|
558
|
+
(opts.command === "uninstall" ? `Kimi Code config file ${echoedValue(p)} is unreadable: it ${problem}. Uninstall left it untouched, along with the hook shim it may still name, the AGENTS.md block, the skill and the file's backups; fix the file and run uninstall again.` : `Kimi Code config file ${echoedValue(p)} ${problem}. Fix or back up the file before running install.`) + (detail === void 0 ? "" : ` (${detail})`)
|
|
559
|
+
);
|
|
560
|
+
let raw;
|
|
561
|
+
try {
|
|
562
|
+
raw = fs4.readFileSync(p, "utf8");
|
|
563
|
+
} catch (e) {
|
|
564
|
+
const code = e.code;
|
|
565
|
+
if (opts.strict === true && code !== "ENOENT" && code !== "ENOTDIR") throw refuse("exists but cannot be read", code ?? extractErrorMessage(e));
|
|
566
|
+
return {};
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
return parse(raw);
|
|
570
|
+
} catch (e) {
|
|
571
|
+
if (opts.strict === true) throw refuse("exists but contains invalid TOML", extractErrorMessage(e));
|
|
572
|
+
return {};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
var KIMI_COMMAND_MARKER = "token-goat";
|
|
576
|
+
var KIMI_MARKER_PATTERN = anchoredMarkerPattern(KIMI_COMMAND_MARKER);
|
|
577
|
+
function isKimiTokenGoatCommand(command) {
|
|
578
|
+
return typeof command === "string" && KIMI_MARKER_PATTERN.test(command);
|
|
579
|
+
}
|
|
580
|
+
var AGENTS_BEGIN = "<!-- token-goat-kimi-begin -->";
|
|
581
|
+
var AGENTS_END = "<!-- token-goat-kimi-end -->";
|
|
582
|
+
function buildKimiAgentsBlock() {
|
|
583
|
+
return buildGuidanceBlock({
|
|
584
|
+
beginMarker: AGENTS_BEGIN,
|
|
585
|
+
endMarker: AGENTS_END,
|
|
586
|
+
fallbackToolClause: "Kimi Code's native `Read`, `Grep`, `Glob`, and `ReadMediaFile` tools (shell commands like `cat`/`type` run inside `Bash`)",
|
|
587
|
+
gdrive: loadConfig().gdrive.enabled
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
function writeKimiAgentsBlock(p) {
|
|
591
|
+
return upsertDelimitedBlock(p, AGENTS_BEGIN, AGENTS_END, buildKimiAgentsBlock());
|
|
592
|
+
}
|
|
593
|
+
function stripKimiAgentsBlock(p) {
|
|
594
|
+
return stripDelimitedBlock(p, AGENTS_BEGIN, AGENTS_END);
|
|
595
|
+
}
|
|
596
|
+
function kimiSkillContent() {
|
|
597
|
+
const gdrive = loadConfig().gdrive.enabled;
|
|
598
|
+
const frontmatter = [
|
|
599
|
+
"---",
|
|
600
|
+
"name: token-goat",
|
|
601
|
+
skillDescriptionLine(gdrive),
|
|
602
|
+
"---"
|
|
603
|
+
].join("\n");
|
|
604
|
+
const body = buildGuidanceBody("Kimi Code's own Read, Grep, and Glob preference rules", { gdrive });
|
|
605
|
+
return `${frontmatter}
|
|
606
|
+
|
|
607
|
+
${body}
|
|
608
|
+
`;
|
|
609
|
+
}
|
|
610
|
+
function writeKimiSkill() {
|
|
611
|
+
return writeIfDifferent(kimiSkillPath(), kimiSkillContent(), true);
|
|
612
|
+
}
|
|
613
|
+
function kimiArtifactPaths() {
|
|
614
|
+
return [kimiConfigPath(), kimiAgentsPath(), kimiSkillPath(), kimiHookScriptPath(), kimiLegacyHookScriptPath()];
|
|
615
|
+
}
|
|
616
|
+
function installKimi() {
|
|
617
|
+
return recordCreatedBy(kimiArtifactPaths(), installKimiFiles);
|
|
618
|
+
}
|
|
619
|
+
function installKimiFiles() {
|
|
620
|
+
const configPath = kimiConfigPath();
|
|
621
|
+
const agentsPath = kimiAgentsPath();
|
|
622
|
+
const scriptPath = kimiHookScriptPath();
|
|
623
|
+
const desired = KIMI_HOOK_EVENTS.map((event) => ({
|
|
624
|
+
event,
|
|
625
|
+
command: kimiHookCommandFor(scriptPath, KIMI_EVENT_ARG[event] ?? "")
|
|
626
|
+
}));
|
|
627
|
+
for (const { command } of process.platform === "win32" ? desired : []) {
|
|
628
|
+
const span = hookCommandRewrittenSpan(command, "cmd");
|
|
629
|
+
if (span !== void 0) {
|
|
630
|
+
throw new HookCommandRewriteError(`Kimi Code runs hook commands through cmd.exe, which replaces ${span} with the value of the environment variable of that name whenever one is set, before it reads any quotes, so the hook command token-goat would write (${command}) would not run token-goat. Nothing was written. Move the path that contains ${echoedValue(span)} (the Kimi Code home, token-goat's install directory, or Node's) to one without a %...% pair, then run \`token-goat install --kimi\` again.`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
ensureDirSync(path4.dirname(scriptPath));
|
|
634
|
+
atomicWriteText(scriptPath, KIMI_HOOK_SCRIPT);
|
|
635
|
+
atomicWriteText(kimiLegacyHookScriptPath(), legacyShimForwarder(""));
|
|
636
|
+
const config = readKimiConfig(configPath, { strict: true });
|
|
637
|
+
const existing = Array.isArray(config.hooks) ? config.hooks : [];
|
|
638
|
+
const foreign = existing.filter((h) => !isKimiTokenGoatCommand(h?.command));
|
|
639
|
+
const ours = existing.filter((h) => isKimiTokenGoatCommand(h?.command));
|
|
640
|
+
const hooksChanged = JSON.stringify(ours) !== JSON.stringify(desired);
|
|
641
|
+
const agentsChanged = writeKimiAgentsBlock(agentsPath);
|
|
642
|
+
const skillChanged = writeKimiSkill();
|
|
643
|
+
if (hooksChanged) {
|
|
644
|
+
config.hooks = [...foreign, ...desired];
|
|
645
|
+
ensureDirSync(path4.dirname(configPath));
|
|
646
|
+
backupFile(configPath);
|
|
647
|
+
writeConfigText(configPath, stringify(config));
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
configPath,
|
|
651
|
+
agentsPath,
|
|
652
|
+
hookScriptPath: scriptPath,
|
|
653
|
+
skillPath: kimiSkillPath(),
|
|
654
|
+
alreadyInstalled: !hooksChanged && !agentsChanged && !skillChanged
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function uninstallKimi() {
|
|
658
|
+
const configPath = kimiConfigPath();
|
|
659
|
+
const config = readKimiConfig(configPath, { strict: true, command: "uninstall" });
|
|
660
|
+
let removed = false;
|
|
661
|
+
if (Array.isArray(config.hooks)) {
|
|
662
|
+
const kept = config.hooks.filter((h) => !isKimiTokenGoatCommand(h?.command));
|
|
663
|
+
if (kept.length !== config.hooks.length) {
|
|
664
|
+
if (kept.length === 0) {
|
|
665
|
+
delete config.hooks;
|
|
666
|
+
} else {
|
|
667
|
+
config.hooks = kept;
|
|
668
|
+
}
|
|
669
|
+
backupFile(configPath);
|
|
670
|
+
writeConfigText(configPath, stringify(config));
|
|
671
|
+
removed = true;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (stripKimiAgentsBlock(kimiAgentsPath())) removed = true;
|
|
675
|
+
try {
|
|
676
|
+
if (fs4.existsSync(kimiSkillDir())) {
|
|
677
|
+
removeCreatedBackups(kimiSkillPath());
|
|
678
|
+
fs4.rmSync(kimiSkillDir(), { recursive: true, force: true });
|
|
679
|
+
removed = true;
|
|
680
|
+
}
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
for (const p of [kimiHookScriptPath(), kimiLegacyHookScriptPath()]) {
|
|
684
|
+
try {
|
|
685
|
+
if (fs4.existsSync(p)) {
|
|
686
|
+
fs4.rmSync(p, { force: true });
|
|
687
|
+
removed = true;
|
|
688
|
+
}
|
|
689
|
+
} catch {
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
removeCreatedBackups(configPath);
|
|
693
|
+
removeCreatedTree(kimiArtifactPaths());
|
|
694
|
+
return removed;
|
|
695
|
+
}
|
|
696
|
+
function kimiHookCommandFor(scriptPath, eventArg, opts = {}) {
|
|
697
|
+
const bin = nativeHookBinary(process.argv[1], opts);
|
|
698
|
+
const native = bin === void 0 ? void 0 : nativeHookCommandLine(process.platform === "win32" ? "cmd" : "sh", bin, "kimi", scriptPath, eventArg);
|
|
699
|
+
return native ?? hookCommandFor(scriptPath, eventArg);
|
|
700
|
+
}
|
|
701
|
+
function wiredKimiHookWords() {
|
|
702
|
+
const hooks = readKimiConfig(kimiConfigPath()).hooks;
|
|
703
|
+
if (!Array.isArray(hooks)) return [];
|
|
704
|
+
const scriptPath = kimiHookScriptPath();
|
|
705
|
+
const expected = new Set(KIMI_HOOK_EVENTS.map((e) => kimiHookCommandFor(scriptPath, KIMI_EVENT_ARG[e] ?? "", { sync: false })));
|
|
706
|
+
return hooks.filter((h) => isKimiTokenGoatCommand(h?.command)).map((h) => ({ words: splitHookCommand(h.command, process.platform === "win32" ? "cmd" : "sh"), current: expected.has(h.command) }));
|
|
707
|
+
}
|
|
708
|
+
function isKimiInstalled() {
|
|
709
|
+
const config = readKimiConfig(kimiConfigPath());
|
|
710
|
+
if (!Array.isArray(config.hooks)) return false;
|
|
711
|
+
const scriptPath = kimiHookScriptPath();
|
|
712
|
+
for (const event of KIMI_HOOK_EVENTS) {
|
|
713
|
+
const expected = kimiHookCommandFor(scriptPath, KIMI_EVENT_ARG[event] ?? "", { sync: false });
|
|
714
|
+
if (!config.hooks.some((h) => h?.event === event && h?.command === expected)) return false;
|
|
715
|
+
}
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/bridges/pi_install.ts
|
|
720
|
+
init_define_import_meta_env();
|
|
721
|
+
import * as fs5 from "node:fs";
|
|
722
|
+
import * as os4 from "node:os";
|
|
723
|
+
import * as path5 from "node:path";
|
|
724
|
+
|
|
725
|
+
// src/bridges/pi.ts
|
|
726
|
+
init_define_import_meta_env();
|
|
727
|
+
var PI_EXTENSION_SCRIPT = `// token-goat bridge extension for pi (pi-coding-agent)
|
|
728
|
+
// Bridges pi's extension events to token-goat's subprocess hook protocol.
|
|
729
|
+
// https://github.com/DFKHelper/token-goat
|
|
730
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
731
|
+
import { spawnSync } from "node:child_process"
|
|
732
|
+
import crypto from "node:crypto";
|
|
733
|
+
import * as fs from "node:fs";
|
|
734
|
+
import * as os from "node:os";
|
|
735
|
+
import * as path from "node:path";
|
|
736
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
737
|
+
|
|
738
|
+
// pi built-in tool names -> token-goat internal tool names. powershell is pi's Windows shell twin of bash -- same input schema (PowerShellToolInput = BashToolInput, i.e. command/timeout) and registered under its own name (badlogic/pi-mono packages/coding-agent/src/core/tools/powershell.ts + tools/index.ts's ToolName union). Unmapped, every shell command in a powershell-tool pi session bypassed the Bash hooks entirely, the same shape as the Copilot shim's bash/powershell pairing.
|
|
739
|
+
const TOOL_TO_TG: Record<string, string> = {
|
|
740
|
+
read: "Read",
|
|
741
|
+
bash: "Bash",
|
|
742
|
+
powershell: "Bash",
|
|
743
|
+
edit: "Edit",
|
|
744
|
+
write: "Write",
|
|
745
|
+
grep: "Grep",
|
|
746
|
+
find: "Glob",
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// pi tool args (camelCase/short) -> token-goat snake_case tool_input keys
|
|
750
|
+
const ARGS_TO_TG: Record<string, Record<string, string>> = {
|
|
751
|
+
read: { path: "file_path", offset: "offset", limit: "limit" },
|
|
752
|
+
bash: { command: "command", timeout: "timeout" },
|
|
753
|
+
powershell: { command: "command", timeout: "timeout" },
|
|
754
|
+
edit: { path: "file_path" },
|
|
755
|
+
write: { path: "file_path" },
|
|
756
|
+
grep: { pattern: "pattern", path: "path" },
|
|
757
|
+
find: { pattern: "pattern", path: "path" },
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// Tools with a pre-hook whose output shape this extension can act on: deny ({block}), updatedInput (in-place arg rewrite), or the Read image-shrink materialization. Glob's pre handler (preGlobDedupHandler in hooks_glob.ts -- the old claim here that Glob has no pre handler was stale) emits only an advisory contextOutput hint, which pi's tool_call contract has no channel for (see the module docblock), so calling it would cost a hook call per find only to drop the answer; it stays excluded for that reason.
|
|
761
|
+
const PRE_HOOK_TOOLS = new Set(["Read", "Grep", "Bash", "WebFetch"]);
|
|
762
|
+
|
|
763
|
+
// resolveEntryPath reads a sidecar JSON file (token-goat-entry.json, written by
|
|
764
|
+
// installPi next to this extension file) containing the absolute path to the
|
|
765
|
+
// token-goat CLI entry that was running at install time. Unlike Codex/Copilot,
|
|
766
|
+
// this extension has no per-invocation command line to bake a path into (pi
|
|
767
|
+
// loads it once as a module, then calls into it via pi.on(...) handlers), so
|
|
768
|
+
// the sidecar file is the install-time channel for this value instead. Returns
|
|
769
|
+
// undefined (triggering the PATH-based fallback below) if the sidecar is
|
|
770
|
+
// missing, unreadable, or points at a path that no longer exists -- e.g. an
|
|
771
|
+
// older install predating this file, or a moved/removed token-goat install.
|
|
772
|
+
function resolveEntryPath(): string | undefined {
|
|
773
|
+
try {
|
|
774
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
775
|
+
const sidecarPath = path.join(here, "token-goat-entry.json");
|
|
776
|
+
const parsed = JSON.parse(fs.readFileSync(sidecarPath, "utf8")) as { entryPath?: unknown };
|
|
777
|
+
if (typeof parsed.entryPath === "string" && parsed.entryPath && fs.existsSync(parsed.entryPath)) {
|
|
778
|
+
return parsed.entryPath;
|
|
779
|
+
}
|
|
780
|
+
} catch {
|
|
781
|
+
// fall through to undefined
|
|
782
|
+
}
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// import()s dist/token-goat-hook.mjs (a sibling of the baked token-goat entry path,
|
|
787
|
+
// built with zero load-time side effects -- unlike the CLI entry, which runs the full
|
|
788
|
+
// argv-parsing CLI as a side effect of being loaded) and returns its exported
|
|
789
|
+
// relayInProcess() function, so callHook can call straight into the hook registry
|
|
790
|
+
// instead of spawnSync-ing a whole second node process for every single hook call in
|
|
791
|
+
// this long-lived agent process. Cached after the first successful resolution (the
|
|
792
|
+
// module itself is cached by Node's ESM loader on repeat import() calls of the same
|
|
793
|
+
// URL, so this mainly avoids repeat fs.existsSync/sidecar-read overhead). Returns
|
|
794
|
+
// undefined (triggering the spawnSync fallback below) when entryPath is absent, the
|
|
795
|
+
// sibling file doesn't exist (an install predating this file), or anything else goes
|
|
796
|
+
// wrong -- this must never throw.
|
|
797
|
+
type RelayInProcessFn = (event: string, payload: unknown, harnessWaitMs?: number, opts?: { elapsedMs?: () => number }) => Promise<string>;
|
|
798
|
+
let cachedRelayInProcess: RelayInProcessFn | undefined;
|
|
799
|
+
async function resolveRelayInProcess(): Promise<RelayInProcessFn | undefined> {
|
|
800
|
+
if (cachedRelayInProcess) return cachedRelayInProcess;
|
|
801
|
+
const entryPath = resolveEntryPath();
|
|
802
|
+
if (!entryPath) return undefined;
|
|
803
|
+
try {
|
|
804
|
+
const hookLibPath = path.join(path.dirname(entryPath), "token-goat-hook.mjs");
|
|
805
|
+
if (!fs.existsSync(hookLibPath)) return undefined;
|
|
806
|
+
const mod = (await import(pathToFileURL(hookLibPath).href)) as { relayInProcess?: unknown };
|
|
807
|
+
if (typeof mod.relayInProcess !== "function") return undefined;
|
|
808
|
+
cachedRelayInProcess = mod.relayInProcess as RelayInProcessFn;
|
|
809
|
+
return cachedRelayInProcess;
|
|
810
|
+
} catch {
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
${shimFallbackTimeoutConst("pi")}
|
|
816
|
+
|
|
817
|
+
function callHookViaSpawn(event: string, payload: Record<string, unknown>): Record<string, unknown> | null {
|
|
818
|
+
try {
|
|
819
|
+
// TOKEN_GOAT_HARNESS_OVERRIDE=pi guarantees detectHarness() resolves to
|
|
820
|
+
// 'pi' for every call this bridge makes, instead of relying on a guessed
|
|
821
|
+
// ambient env var pi-coding-agent may or may not set.
|
|
822
|
+
//
|
|
823
|
+
// Invoking "token-goat" as a bare command here depends on PATH resolution
|
|
824
|
+
// (the npm global bin being on whatever PATH pi-coding-agent's own process
|
|
825
|
+
// inherits) -- the same class of single-point-of-failure fixed for the
|
|
826
|
+
// Codex/Copilot CLI bridges' hook commands. When resolveEntryPath() finds a
|
|
827
|
+
// baked install-time path, invoke it directly via process.execPath instead,
|
|
828
|
+
// sidestepping PATH entirely; otherwise fall back to the old PATH-based
|
|
829
|
+
// lookup (e.g. an extension installed before this fix).
|
|
830
|
+
const entryPath = resolveEntryPath();
|
|
831
|
+
const r = entryPath
|
|
832
|
+
? spawnSync(process.execPath, [entryPath, "hook", event], {
|
|
833
|
+
input: JSON.stringify(payload),
|
|
834
|
+
encoding: "utf8",
|
|
835
|
+
timeout: SHIM_FALLBACK_TIMEOUT_MS,
|
|
836
|
+
killSignal: "SIGKILL",
|
|
837
|
+
windowsHide: true,
|
|
838
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
839
|
+
env: { ...process.env, TOKEN_GOAT_HARNESS_OVERRIDE: "pi" },
|
|
840
|
+
})
|
|
841
|
+
: spawnSync('token-goat hook ' + event, {
|
|
842
|
+
input: JSON.stringify(payload),
|
|
843
|
+
encoding: "utf8",
|
|
844
|
+
timeout: SHIM_FALLBACK_TIMEOUT_MS,
|
|
845
|
+
killSignal: "SIGKILL",
|
|
846
|
+
shell: true,
|
|
847
|
+
windowsHide: true,
|
|
848
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
849
|
+
env: { ...process.env, TOKEN_GOAT_HARNESS_OVERRIDE: "pi" },
|
|
850
|
+
});
|
|
851
|
+
if (r.error) return null;
|
|
852
|
+
const out = r.stdout?.trim();
|
|
853
|
+
if (!out) return null;
|
|
854
|
+
return JSON.parse(out) as Record<string, unknown>;
|
|
855
|
+
} catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Tries the in-process hook call first (resolveRelayInProcess above), which avoids
|
|
861
|
+
// spawning a second node process altogether for every single tool call in this
|
|
862
|
+
// long-lived agent process. Falls back to callHookViaSpawn (the original
|
|
863
|
+
// spawnSync-based path, now bounded by SHIM_FALLBACK_TIMEOUT_MS and a killSignal so token-goat degrades to
|
|
864
|
+
// its own fail-open null rather than hanging pi, which applies no hook timeout of its own) when the in-process path is unavailable or throws.
|
|
865
|
+
async function callHook(event: string, payload: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
|
866
|
+
// Timed from here, not from process start: this host lives far longer than one call, so the hook library's default clock (process age) would record the host's uptime as this call's duration.
|
|
867
|
+
const start = performance.now();
|
|
868
|
+
const relay = await resolveRelayInProcess();
|
|
869
|
+
if (relay) {
|
|
870
|
+
try {
|
|
871
|
+
process.env.TOKEN_GOAT_HARNESS_OVERRIDE = "pi";
|
|
872
|
+
const out = await relay(event, payload, undefined, { elapsedMs: () => performance.now() - start });
|
|
873
|
+
const trimmed = out?.trim();
|
|
874
|
+
if (!trimmed) return null;
|
|
875
|
+
return JSON.parse(trimmed) as Record<string, unknown>;
|
|
876
|
+
} catch {
|
|
877
|
+
// fall through to the spawnSync fallback below
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return callHookViaSpawn(event, payload);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function reverseArgMap(tool: string): Record<string, string> {
|
|
884
|
+
const fwd = ARGS_TO_TG[tool] ?? {};
|
|
885
|
+
return Object.fromEntries(Object.entries(fwd).map(([piKey, tgKey]) => [tgKey, piKey]));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const UNICODE_SPACES = new RegExp("[" + [0xa0, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x202f, 0x205f, 0x3000].map((c) => String.fromCharCode(c)).join("") + "]", "g");
|
|
889
|
+
|
|
890
|
+
function fileExists(p: string): boolean {
|
|
891
|
+
try {
|
|
892
|
+
fs.accessSync(p);
|
|
893
|
+
return true;
|
|
894
|
+
} catch {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// The absolute path pi will open for a read/edit/write "path" argument. pi rewrites the argument before resolving it (pi-coding-agent 0.99.1, core/tools/path-utils.js resolveToCwd over utils/paths.js normalizePath), in this order: trim, unicode spaces to plain spaces, drop one leading "@", on Windows turn a Git Bash, Cygwin or WSL drive path (/c/x, /cygdrive/c/x, /mnt/c/x) into C:/x, expand "~", convert a file:// URL, then resolve against the session cwd. A read of a missing file also tries the macOS screenshot spellings (resolveReadPath). token-goat was sent the argument as written, so "@src/a.ts" or "~/notes.md" named a file that does not exist: a re-read went unrecognised and an edit never reached the index.
|
|
900
|
+
function resolvePiPath(raw: string, base: string, isRead: boolean): string {
|
|
901
|
+
let p = raw.trim().replace(UNICODE_SPACES, " ");
|
|
902
|
+
if (p.startsWith("@")) p = p.slice(1);
|
|
903
|
+
if (process.platform === "win32" && p.startsWith("/") && !p.startsWith("//") && !p.includes(String.fromCharCode(92))) {
|
|
904
|
+
const m = p.match(/^[/](?:mnt[/]|cygdrive[/])?([a-z])(?:[/](.*))?$/i);
|
|
905
|
+
if (m) p = m[1].toUpperCase() + ":/" + (m[2] ?? "");
|
|
906
|
+
}
|
|
907
|
+
if (p === "~") p = os.homedir();
|
|
908
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~" + String.fromCharCode(92)))) p = path.join(os.homedir(), p.slice(2));
|
|
909
|
+
else if (/^file:[/][/]/.test(p)) {
|
|
910
|
+
try { p = fileURLToPath(p); } catch {}
|
|
911
|
+
}
|
|
912
|
+
const resolved = path.resolve(base, p);
|
|
913
|
+
if (!isRead || fileExists(resolved)) return resolved;
|
|
914
|
+
const amPm = resolved.replace(/ (AM|PM)[.]/gi, String.fromCharCode(0x202f) + "$1.");
|
|
915
|
+
const nfd = resolved.normalize("NFD");
|
|
916
|
+
const curly = (s: string) => s.replace(/'/g, String.fromCharCode(0x2019));
|
|
917
|
+
for (const variant of [amPm, nfd, curly(resolved), curly(nfd)]) {
|
|
918
|
+
if (variant !== resolved && fileExists(variant)) return variant;
|
|
919
|
+
}
|
|
920
|
+
return resolved;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function toToolInput(tool: string, input: Record<string, unknown>, base: string): Record<string, unknown> {
|
|
924
|
+
const argMap = ARGS_TO_TG[tool] ?? {};
|
|
925
|
+
const out: Record<string, unknown> = {};
|
|
926
|
+
for (const [piKey, tgKey] of Object.entries(argMap)) {
|
|
927
|
+
if (input[piKey] !== undefined) out[tgKey] = input[piKey];
|
|
928
|
+
}
|
|
929
|
+
if (typeof out["file_path"] === "string") out["file_path"] = resolvePiPath(out["file_path"] as string, base, tool === "read");
|
|
930
|
+
return out;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Pull the additionalContext / systemMessage string out of a hook response,
|
|
934
|
+
// whichever shape it came back as (see the response-contract note in this
|
|
935
|
+
// module's header comment).
|
|
936
|
+
function extractContext(resp: Record<string, unknown>): string | undefined {
|
|
937
|
+
const hso = resp["hookSpecificOutput"] as Record<string, unknown> | undefined;
|
|
938
|
+
if (hso && typeof hso["additionalContext"] === "string") return hso["additionalContext"] as string;
|
|
939
|
+
if (typeof resp["systemMessage"] === "string") return resp["systemMessage"] as string;
|
|
940
|
+
return undefined;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function extractUpdatedInput(resp: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
944
|
+
const hso = resp["hookSpecificOutput"] as Record<string, unknown> | undefined;
|
|
945
|
+
return hso ? (hso["updatedInput"] as Record<string, unknown> | undefined) : undefined;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Decode a token-goat image-shrink additionalContext payload
|
|
949
|
+
// ("<summary>\\ndata:image/<fmt>;base64,<data>") into a real file on disk,
|
|
950
|
+
// since pi's tool_call handler has no context-injection channel -- only
|
|
951
|
+
// in-place arg mutation. Returns undefined (leaving the read path untouched)
|
|
952
|
+
// if the context isn't a shrink payload or anything goes wrong writing it.
|
|
953
|
+
// Best-effort sweep of previously materialized shrunk copies in the OS temp dir: typed twin of pruneMaterializedShrinks in shrink_block.ts (MATERIALIZE_SHRUNK_IMAGE_JS), which documents why pi keeps its own copy. The temp file only needs to outlive the single tool call whose path was rewritten to it, so anything older than an hour is finished with; the "token-goat-shrink-" prefix check confines the sweep to this mechanism's own files.
|
|
954
|
+
const MATERIALIZED_SHRINK_MAX_AGE_MS = 60 * 60 * 1000;
|
|
955
|
+
let lastMaterializedShrinkSweepAtMs = 0;
|
|
956
|
+
function pruneMaterializedShrinks(): void {
|
|
957
|
+
const now = Date.now();
|
|
958
|
+
if (now - lastMaterializedShrinkSweepAtMs < MATERIALIZED_SHRINK_MAX_AGE_MS) return;
|
|
959
|
+
lastMaterializedShrinkSweepAtMs = now;
|
|
960
|
+
try {
|
|
961
|
+
const dir = os.tmpdir();
|
|
962
|
+
for (const file of fs.readdirSync(dir)) {
|
|
963
|
+
if (!file.startsWith("token-goat-shrink-")) continue;
|
|
964
|
+
const full = path.join(dir, file);
|
|
965
|
+
try {
|
|
966
|
+
const st = fs.statSync(full);
|
|
967
|
+
if (st.isFile() && now - st.mtimeMs > MATERIALIZED_SHRINK_MAX_AGE_MS) fs.unlinkSync(full);
|
|
968
|
+
} catch {
|
|
969
|
+
// Best-effort per-file cleanup; one bad stat/unlink must not abort the sweep.
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
} catch {
|
|
973
|
+
// Best-effort; a readdir failure must never break the materialization below.
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function materializeShrunkImage(context: string | undefined): string | undefined {
|
|
978
|
+
if (typeof context !== "string") return undefined;
|
|
979
|
+
const idx = context.indexOf("data:image/");
|
|
980
|
+
if (idx === -1) return undefined;
|
|
981
|
+
const match = /^data:image\\/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(context.slice(idx).trim());
|
|
982
|
+
if (!match) return undefined;
|
|
983
|
+
try {
|
|
984
|
+
pruneMaterializedShrinks();
|
|
985
|
+
const buf = Buffer.from(match[2], "base64");
|
|
986
|
+
const name = \`token-goat-shrink-\${process.pid}-\${Date.now()}-\${globalThis.crypto.randomUUID()}.\${match[1]}\`;
|
|
987
|
+
const file = path.join(os.tmpdir(), name);
|
|
988
|
+
// Owner-only: the copy is of a workspace image and sits in the shared temp dir.
|
|
989
|
+
fs.writeFileSync(file, buf, { mode: 0o600 });
|
|
990
|
+
return file;
|
|
991
|
+
} catch {
|
|
992
|
+
return undefined;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export default function (pi: ExtensionAPI) {
|
|
997
|
+
// Stable per-session id derived from pi's session file (filesystem-safe), or
|
|
998
|
+
// a process-scoped fallback for ephemeral sessions. Recomputed on every
|
|
999
|
+
// session_start (new / resume / fork all re-fire it).
|
|
1000
|
+
let sessionId = \`pi-\${process.pid}\`;
|
|
1001
|
+
let cwd = process.cwd();
|
|
1002
|
+
// Manifest captured at session_before_compact, injected after compaction.
|
|
1003
|
+
let pendingManifest: string | undefined;
|
|
1004
|
+
|
|
1005
|
+
// No forwarding callHook for session_start here (there used to be one):
|
|
1006
|
+
// token-goat retired the session_start hook -- it only ever reached a
|
|
1007
|
+
// permanent no-op handler (see the removal in src/hooks_session.ts and
|
|
1008
|
+
// src/types.ts). This subscription is kept regardless, because it's pi's
|
|
1009
|
+
// own lifecycle event and is still needed to refresh sessionId/cwd for
|
|
1010
|
+
// every other bridged call below.
|
|
1011
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1012
|
+
cwd = ctx.cwd ?? process.cwd();
|
|
1013
|
+
const file = ctx.sessionManager?.getSessionFile?.();
|
|
1014
|
+
sessionId = file ? \`pi-\${file.replace(/[^A-Za-z0-9._-]/g, "_")}\` : \`pi-\${process.pid}\`;
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
1018
|
+
const tg = TOOL_TO_TG[event.toolName];
|
|
1019
|
+
if (!tg || !PRE_HOOK_TOOLS.has(tg)) return;
|
|
1020
|
+
|
|
1021
|
+
const input = event.input as Record<string, unknown>;
|
|
1022
|
+
const resp = await callHook("pre_tool_use", {
|
|
1023
|
+
session_id: sessionId,
|
|
1024
|
+
tool_name: tg,
|
|
1025
|
+
tool_input: toToolInput(event.toolName, input, ctx?.cwd ?? cwd),
|
|
1026
|
+
cwd: ctx?.cwd ?? cwd,
|
|
1027
|
+
});
|
|
1028
|
+
if (!resp) return;
|
|
1029
|
+
|
|
1030
|
+
// Deny: block the tool call with a reason (e.g. confirmed re-read).
|
|
1031
|
+
if (resp["decision"] === "block") {
|
|
1032
|
+
return {
|
|
1033
|
+
block: true,
|
|
1034
|
+
reason: (resp["reason"] as string) ?? "blocked by token-goat",
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Rewrite: update tool args in place (e.g. compressed bash command).
|
|
1039
|
+
// token-goat returns its own snake_case keys; map them back.
|
|
1040
|
+
const updated = extractUpdatedInput(resp);
|
|
1041
|
+
if (updated) {
|
|
1042
|
+
const rev = reverseArgMap(event.toolName);
|
|
1043
|
+
for (const [tgKey, val] of Object.entries(updated)) {
|
|
1044
|
+
input[rev[tgKey] ?? tgKey] = val;
|
|
1045
|
+
}
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Image shrink has no context channel here -- translate it into a
|
|
1050
|
+
// rewritten path pointing at a materialized shrunk copy instead.
|
|
1051
|
+
if (tg === "Read") {
|
|
1052
|
+
const shrunkPath = materializeShrunkImage(extractContext(resp));
|
|
1053
|
+
if (shrunkPath) input["path"] = shrunkPath;
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
1058
|
+
const tg = TOOL_TO_TG[event.toolName];
|
|
1059
|
+
if (!tg) return;
|
|
1060
|
+
// event.content is tool_result's real output field (ToolResultEventBase.content:
|
|
1061
|
+
// (TextContent | ImageContent)[] -- verified against pi's own
|
|
1062
|
+
// core/extensions/types.ts). Join the text blocks into a single string so
|
|
1063
|
+
// extractReadOutput's truncation-marker detection (which only recognizes a
|
|
1064
|
+
// string tool_response.output) can see it, mirroring opencode.ts's
|
|
1065
|
+
// tool_response: { output } shape.
|
|
1066
|
+
const contentBlocks = Array.isArray(event.content) ? event.content : [];
|
|
1067
|
+
const output = contentBlocks
|
|
1068
|
+
.filter((c: { type?: string }) => c && c.type === "text")
|
|
1069
|
+
.map((c: { text?: string }) => c.text ?? "")
|
|
1070
|
+
.join("\\n");
|
|
1071
|
+
const resp = await callHook("post_tool_use", {
|
|
1072
|
+
session_id: sessionId,
|
|
1073
|
+
tool_name: tg,
|
|
1074
|
+
tool_input: toToolInput(event.toolName, (event.input ?? {}) as Record<string, unknown>, ctx?.cwd ?? cwd),
|
|
1075
|
+
cwd: ctx?.cwd ?? cwd,
|
|
1076
|
+
tool_response: { output },
|
|
1077
|
+
});
|
|
1078
|
+
if (!resp) return;
|
|
1079
|
+
|
|
1080
|
+
// rewriteOutput: replace the tool result the model sees. pi's tool_result handler CAN modify the result: its own types declare "Fired after a tool executes. Can modify result." over ToolResultEvent and a ToolResultEventResult of { content, details, isError, usage }, and the shipped runtime applies it (dist/core/extensions/runner.js emitToolResult assigns handlerResult.content onto the event, and dist/core/agent-session.js afterToolCall returns hookResult.content as the tool result content). Without this, injection fencing, secret redaction and output compression were computed and thrown away on every pi session, the same whitelist-shaped drop fixed for opencode.
|
|
1081
|
+
const hso = resp["hookSpecificOutput"] as Record<string, unknown> | undefined;
|
|
1082
|
+
const updatedToolOutput = hso && typeof hso["updatedToolOutput"] === "string" ? (hso["updatedToolOutput"] as string) : undefined;
|
|
1083
|
+
if (updatedToolOutput === undefined) return;
|
|
1084
|
+
|
|
1085
|
+
// Replace only the text blocks, in place, and keep every non-text block (images) untouched: emitToolResult overwrites content wholesale, so anything not carried here is dropped from what the model sees. details/isError/usage are deliberately not returned, since runner.js only overwrites the fields a handler actually provides.
|
|
1086
|
+
const rewritten: typeof contentBlocks = [];
|
|
1087
|
+
let replaced = false;
|
|
1088
|
+
for (const block of contentBlocks) {
|
|
1089
|
+
const isText = Boolean(block) && (block as { type?: string }).type === "text";
|
|
1090
|
+
if (!isText) {
|
|
1091
|
+
rewritten.push(block);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (replaced) continue;
|
|
1095
|
+
replaced = true;
|
|
1096
|
+
rewritten.push({ type: "text", text: updatedToolOutput });
|
|
1097
|
+
}
|
|
1098
|
+
if (!replaced) rewritten.push({ type: "text", text: updatedToolOutput });
|
|
1099
|
+
return { content: rewritten };
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// Compaction: pi's session_before_compact REPLACES the summary rather than
|
|
1103
|
+
// appending to it (unlike opencode's additive output.context). So capture the
|
|
1104
|
+
// token-goat manifest here, let pi build its own summary, then inject the
|
|
1105
|
+
// manifest as a post-compaction message that survives into the new context
|
|
1106
|
+
// window. This preserves the edited-file / symbol manifest within pi's
|
|
1107
|
+
// replace-only compaction model.
|
|
1108
|
+
pi.on("session_before_compact", async (_event, _ctx) => {
|
|
1109
|
+
const resp = await callHook("pre_compact", { session_id: sessionId, trigger: "auto" });
|
|
1110
|
+
pendingManifest = resp?.["systemMessage"] as string | undefined;
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
pi.on("session_compact", async (_event, _ctx) => {
|
|
1114
|
+
if (pendingManifest) {
|
|
1115
|
+
pi.sendMessage(
|
|
1116
|
+
{ customType: "token-goat-manifest", content: pendingManifest, display: false },
|
|
1117
|
+
{ deliverAs: "nextTurn" },
|
|
1118
|
+
);
|
|
1119
|
+
pendingManifest = undefined;
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
`;
|
|
1124
|
+
|
|
1125
|
+
// src/bridges/pi_install.ts
|
|
1126
|
+
function piGlobalExtensionPath() {
|
|
1127
|
+
return path5.join(os4.homedir(), ".pi", "agent", "extensions", "token-goat.ts");
|
|
1128
|
+
}
|
|
1129
|
+
function piLocalExtensionPath() {
|
|
1130
|
+
return path5.join(process.cwd(), ".pi", "extensions", "token-goat.ts");
|
|
1131
|
+
}
|
|
1132
|
+
function piExtensionPath(opts = {}) {
|
|
1133
|
+
return opts.local === true ? piLocalExtensionPath() : piGlobalExtensionPath();
|
|
1134
|
+
}
|
|
1135
|
+
function piEntrySidecarPath(opts = {}) {
|
|
1136
|
+
return path5.join(path5.dirname(piExtensionPath(opts)), "token-goat-entry.json");
|
|
1137
|
+
}
|
|
1138
|
+
function installPi(opts = {}) {
|
|
1139
|
+
return withInstallScope(projectScopeRoot(opts), () => {
|
|
1140
|
+
const extensionPath = piExtensionPath(opts);
|
|
1141
|
+
const { alreadyInstalled } = recordCreatedBy([extensionPath, piEntrySidecarPath(opts)], () => installSingleFilePlugin(extensionPath, piEntrySidecarPath(opts), PI_EXTENSION_SCRIPT));
|
|
1142
|
+
return { extensionPath, alreadyInstalled };
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
function uninstallPiScope(opts) {
|
|
1146
|
+
return withInstallScope(projectScopeRoot(opts), () => {
|
|
1147
|
+
const removed = uninstallSingleFilePlugin(piExtensionPath(opts), piEntrySidecarPath(opts));
|
|
1148
|
+
removeCreatedTree([piExtensionPath(opts), piEntrySidecarPath(opts)]);
|
|
1149
|
+
return removed;
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
function uninstallPi(opts = {}) {
|
|
1153
|
+
if (opts.local === true) {
|
|
1154
|
+
return uninstallPiScope({ local: true });
|
|
1155
|
+
}
|
|
1156
|
+
const globalRemoved = uninstallPiScope({ local: false });
|
|
1157
|
+
const localRemoved = uninstallPiScope({ local: true });
|
|
1158
|
+
return globalRemoved || localRemoved;
|
|
1159
|
+
}
|
|
1160
|
+
function isPiInstalled(opts = {}) {
|
|
1161
|
+
return fs5.existsSync(piExtensionPath(opts));
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// src/bridges/opencode_install.ts
|
|
1165
|
+
init_define_import_meta_env();
|
|
1166
|
+
import * as fs6 from "node:fs";
|
|
1167
|
+
import * as os5 from "node:os";
|
|
1168
|
+
import * as path6 from "node:path";
|
|
1169
|
+
|
|
1170
|
+
// src/bridges/opencode.ts
|
|
1171
|
+
init_define_import_meta_env();
|
|
1172
|
+
|
|
1173
|
+
// src/bridges/relay_block.ts
|
|
1174
|
+
init_define_import_meta_env();
|
|
1175
|
+
var BRIDGE_RELAY_JS = `// resolveEntryPath reads a sidecar JSON file (token-goat-entry.json, written by
|
|
1176
|
+
// the install step next to this plugin file) containing the absolute path to
|
|
1177
|
+
// the token-goat CLI entry that was running at install time. Unlike
|
|
1178
|
+
// Codex/Copilot's generated hook commands, this plugin has no per-invocation
|
|
1179
|
+
// command line to bake a path into (the host loads it once as a module), so
|
|
1180
|
+
// callHook reads this sidecar at runtime instead, to invoke that entry
|
|
1181
|
+
// directly via process.execPath rather than depending on PATH resolution for
|
|
1182
|
+
// a bare "token-goat" lookup -- the same single-point-of-failure class fixed
|
|
1183
|
+
// for the Codex/Copilot CLI bridges' hook commands and pi's extension
|
|
1184
|
+
// (resolveEntryPath). Returns undefined (triggering the PATH-based
|
|
1185
|
+
// shell:true fallback below) if the sidecar is missing, unreadable, or
|
|
1186
|
+
// points at a path that no longer exists.
|
|
1187
|
+
function resolveEntryPath() {
|
|
1188
|
+
try {
|
|
1189
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
1190
|
+
const sidecarPath = path.join(here, "token-goat-entry.json");
|
|
1191
|
+
const parsed = JSON.parse(fs.readFileSync(sidecarPath, "utf8"));
|
|
1192
|
+
if (typeof parsed.entryPath === "string" && parsed.entryPath && fs.existsSync(parsed.entryPath)) {
|
|
1193
|
+
return parsed.entryPath;
|
|
1194
|
+
}
|
|
1195
|
+
} catch {
|
|
1196
|
+
// fall through to undefined
|
|
1197
|
+
}
|
|
1198
|
+
return undefined;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// import()s dist/token-goat-hook.mjs (a sibling of the baked token-goat entry path,
|
|
1202
|
+
// built with zero load-time side effects -- unlike the CLI entry, which runs the full
|
|
1203
|
+
// argv-parsing CLI as a side effect of being loaded) and returns its exported
|
|
1204
|
+
// relayInProcess() function, so callHook can call straight into the hook registry
|
|
1205
|
+
// instead of spawnSync-ing a whole second node process for every single hook call in
|
|
1206
|
+
// this long-lived host process. Cached after the first successful resolution (the
|
|
1207
|
+
// module itself is cached by Node's ESM loader on repeat import() calls of the same
|
|
1208
|
+
// URL, so this mainly avoids repeat fs.existsSync/sidecar-read overhead). Returns
|
|
1209
|
+
// undefined (triggering the spawnSync fallback below) when entryPath is absent, the
|
|
1210
|
+
// sibling file doesn't exist (an install predating this file), or anything else goes
|
|
1211
|
+
// wrong -- this must never throw.
|
|
1212
|
+
let cachedRelayInProcess;
|
|
1213
|
+
async function resolveRelayInProcess() {
|
|
1214
|
+
if (cachedRelayInProcess) return cachedRelayInProcess;
|
|
1215
|
+
const entryPath = resolveEntryPath();
|
|
1216
|
+
if (!entryPath) return undefined;
|
|
1217
|
+
try {
|
|
1218
|
+
const hookLibPath = path.join(path.dirname(entryPath), "token-goat-hook.mjs");
|
|
1219
|
+
if (!fs.existsSync(hookLibPath)) return undefined;
|
|
1220
|
+
const mod = await import(pathToFileURL(hookLibPath).href);
|
|
1221
|
+
if (typeof mod.relayInProcess !== "function") return undefined;
|
|
1222
|
+
cachedRelayInProcess = mod.relayInProcess;
|
|
1223
|
+
return cachedRelayInProcess;
|
|
1224
|
+
} catch {
|
|
1225
|
+
return undefined;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
${shimFallbackTimeoutConst("relay")}
|
|
1230
|
+
|
|
1231
|
+
function callHookViaSpawn(event, payload) {
|
|
1232
|
+
try {
|
|
1233
|
+
// Invoking "token-goat" as a bare command here depends on PATH resolution
|
|
1234
|
+
// (the npm global bin being on whatever PATH the host process inherits) --
|
|
1235
|
+
// on Windows a global npm install resolves "token-goat" to a .cmd/.ps1
|
|
1236
|
+
// shim, which spawnSync cannot exec without shell: true. When
|
|
1237
|
+
// resolveEntryPath() finds a baked install-time path, invoke it directly
|
|
1238
|
+
// via process.execPath instead, sidestepping PATH entirely; otherwise
|
|
1239
|
+
// fall back to the old PATH-based lookup with shell: true so a .cmd/.ps1
|
|
1240
|
+
// shim still resolves (e.g. an install predating this fix).
|
|
1241
|
+
const entryPath = resolveEntryPath();
|
|
1242
|
+
const r = entryPath
|
|
1243
|
+
? spawnSync(process.execPath, [entryPath, "hook", event], {
|
|
1244
|
+
input: JSON.stringify(payload),
|
|
1245
|
+
encoding: "utf8",
|
|
1246
|
+
timeout: SHIM_FALLBACK_TIMEOUT_MS,
|
|
1247
|
+
killSignal: "SIGKILL",
|
|
1248
|
+
windowsHide: true,
|
|
1249
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
1250
|
+
})
|
|
1251
|
+
: spawnSync("token-goat hook " + event, {
|
|
1252
|
+
input: JSON.stringify(payload),
|
|
1253
|
+
encoding: "utf8",
|
|
1254
|
+
timeout: SHIM_FALLBACK_TIMEOUT_MS,
|
|
1255
|
+
killSignal: "SIGKILL",
|
|
1256
|
+
shell: true,
|
|
1257
|
+
windowsHide: true,
|
|
1258
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
1259
|
+
});
|
|
1260
|
+
if (r.error) return null;
|
|
1261
|
+
const out = r.stdout ? r.stdout.trim() : "";
|
|
1262
|
+
if (!out) return null;
|
|
1263
|
+
return JSON.parse(out);
|
|
1264
|
+
} catch {
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Tries the in-process hook call first (resolveRelayInProcess above), which avoids
|
|
1270
|
+
// spawning a second node process altogether for every single tool call in this
|
|
1271
|
+
// long-lived host process. Falls back to callHookViaSpawn (the original
|
|
1272
|
+
// spawnSync-based path, now bounded by SHIM_FALLBACK_TIMEOUT_MS and a killSignal so token-goat degrades to
|
|
1273
|
+
// its own fail-open null rather than being force-killed by the host's own hook timeout
|
|
1274
|
+
// budget, 15000ms for OpenClaw's before_tool_call) when the in-process path is unavailable or throws.
|
|
1275
|
+
async function callHook(event, payload) {
|
|
1276
|
+
// Timed from here, not from process start: this host lives far longer than one call, so the hook library's default clock (process age) would record the host's uptime as this call's duration.
|
|
1277
|
+
const start = performance.now();
|
|
1278
|
+
const relay = await resolveRelayInProcess();
|
|
1279
|
+
if (relay) {
|
|
1280
|
+
try {
|
|
1281
|
+
const out = await relay(event, payload, undefined, { elapsedMs: () => performance.now() - start });
|
|
1282
|
+
const trimmed = out ? out.trim() : "";
|
|
1283
|
+
if (!trimmed) return null;
|
|
1284
|
+
return JSON.parse(trimmed);
|
|
1285
|
+
} catch {
|
|
1286
|
+
// fall through to the spawnSync fallback below
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return callHookViaSpawn(event, payload);
|
|
1290
|
+
}`;
|
|
1291
|
+
|
|
1292
|
+
// src/bridges/opencode.ts
|
|
1293
|
+
var OPENCODE_PLUGIN_SCRIPT = `// token-goat bridge plugin for opencode
|
|
1294
|
+
// Bridges opencode's plugin hooks to token-goat's subprocess hook protocol.
|
|
1295
|
+
// https://github.com/DFKHelper/token-goat
|
|
1296
|
+
import { spawnSync } from "node:child_process"
|
|
1297
|
+
import crypto from "node:crypto"
|
|
1298
|
+
import fs from "node:fs"
|
|
1299
|
+
import os from "node:os"
|
|
1300
|
+
import path from "node:path"
|
|
1301
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
1302
|
+
|
|
1303
|
+
// opencode built-in tool id -> token-goat canonical tool name. websearch/skill/task were re-verified against opencode's own source at the tag matching the installed release (anomalyco/opencode v1.18.16 packages/opencode/src/tool/): WebSearchTool registers as "websearch" (websearch.ts), SkillTool as "skill" (skill.ts), TaskTool as "task" (task.ts, params prompt/subagent_type/description -- the exact keys token-goat's hooks_agent_spawn.ts reads, which registers a lowercase 'task' handler). Unmapped, all three were dead mechanisms here: no WebSearch repeat-search deny/compression, no repeat-skill-load deny, no agent-spawn briefing or report compaction. apply_patch is mapped to Edit (see patchTextPaths below). lsp/plan/question/todo have no token-goat equivalent and stay unmapped.
|
|
1304
|
+
const TOOL_TO_TG = {
|
|
1305
|
+
read: "Read",
|
|
1306
|
+
bash: "Bash",
|
|
1307
|
+
edit: "Edit",
|
|
1308
|
+
write: "Write",
|
|
1309
|
+
grep: "Grep",
|
|
1310
|
+
glob: "Glob",
|
|
1311
|
+
webfetch: "WebFetch",
|
|
1312
|
+
websearch: "WebSearch",
|
|
1313
|
+
skill: "Skill",
|
|
1314
|
+
task: "task",
|
|
1315
|
+
apply_patch: "Edit",
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Tools with a pre_tool_use handler server-side whose OUTPUT SHAPE this hook can act on: deny (throw), updatedInput (args rewrite), or the Read image-shrink materialization. websearch (repeat-search deny within the dedup TTL, hooks_websearch.ts), skill (repeat-load deny, hooks_skill.ts) and task (briefing updatedInput rewrite, hooks_agent_spawn.ts) all qualify. Edit/Write have no pre-hook at all. Glob DOES have one (preGlobDedupHandler, hooks_glob.ts) -- the old claim here that it has none was stale -- but its only output is an advisory contextOutput hint, and tool.execute.before has no context channel (see the module docblock), so calling it would spawn a subprocess per glob only to drop the answer; it stays excluded for that reason, not the old one.
|
|
1319
|
+
const PRE_HOOK_TOOLS = new Set(["read", "bash", "grep", "webfetch", "websearch", "skill", "task"])
|
|
1320
|
+
|
|
1321
|
+
// opencode tool args (camelCase) -> token-goat snake_case tool_input keys. websearch's query (websearch.ts Parameters), skill's name (skill.ts Parameters; hooks_skill.ts reads tool_input['skill']) and task's prompt/subagent_type/description (task.ts BaseParameterFields) are all from opencode's own schemas at v1.18.16.
|
|
1322
|
+
const ARGS_TO_TG = {
|
|
1323
|
+
read: { filePath: "file_path", offset: "offset", limit: "limit" },
|
|
1324
|
+
bash: { command: "command", timeout: "timeout" },
|
|
1325
|
+
edit: { filePath: "file_path" },
|
|
1326
|
+
write: { filePath: "file_path" },
|
|
1327
|
+
grep: { pattern: "pattern", path: "path" },
|
|
1328
|
+
glob: { pattern: "pattern", path: "path" },
|
|
1329
|
+
webfetch: { url: "url" },
|
|
1330
|
+
websearch: { query: "query" },
|
|
1331
|
+
skill: { name: "skill" },
|
|
1332
|
+
task: { prompt: "prompt", subagent_type: "subagent_type", description: "description" },
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function reverseArgMap(tool) {
|
|
1336
|
+
const fwd = ARGS_TO_TG[tool] ?? {}
|
|
1337
|
+
const rev = {}
|
|
1338
|
+
for (const [opencodeKey, tgKey] of Object.entries(fwd)) rev[tgKey] = opencodeKey
|
|
1339
|
+
return rev
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// opencode's apply_patch tool (tool/apply_patch.ts, v1.18.33) takes one patchText argument and no per-file path; it is the tool opencode offers GPT models in place of edit and write. The file paths are the header lines of the patch (patch/index.ts parsePatchHeader: "*** Add File: ", "*** Delete File: ", "*** Update File: ", plus "*** Move to: " for a rename), each trimmed and resolved against the instance directory, which is the directory this plugin sends as cwd. A trailing carriage return is dropped by the trim. Left unmapped, an edit a GPT model made was never queued for reindexing.
|
|
1343
|
+
const PATCH_PATH_HEADERS = ["*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "]
|
|
1344
|
+
|
|
1345
|
+
function patchTextPaths(patchText) {
|
|
1346
|
+
const paths = []
|
|
1347
|
+
if (typeof patchText !== "string") return paths
|
|
1348
|
+
for (const line of patchText.split(String.fromCharCode(10))) {
|
|
1349
|
+
for (const header of PATCH_PATH_HEADERS) {
|
|
1350
|
+
if (!line.startsWith(header)) continue
|
|
1351
|
+
const p = line.slice(header.length).trim()
|
|
1352
|
+
if (p && !paths.includes(p)) paths.push(p)
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return paths
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function toToolInput(tool, args) {
|
|
1359
|
+
if (tool === "apply_patch") {
|
|
1360
|
+
const paths = patchTextPaths(args && args.patchText)
|
|
1361
|
+
return paths.length === 1 ? { file_path: paths[0], file_paths: paths } : { file_paths: paths }
|
|
1362
|
+
}
|
|
1363
|
+
const map = ARGS_TO_TG[tool] ?? {}
|
|
1364
|
+
const out = {}
|
|
1365
|
+
for (const [opencodeKey, tgKey] of Object.entries(map)) {
|
|
1366
|
+
if (args && args[opencodeKey] !== undefined) out[tgKey] = args[opencodeKey]
|
|
1367
|
+
}
|
|
1368
|
+
return out
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
${BRIDGE_RELAY_JS}
|
|
1372
|
+
|
|
1373
|
+
// Pull the additionalContext / systemMessage string out of a hook response,
|
|
1374
|
+
// whichever shape it came back as (see the response-contract note above).
|
|
1375
|
+
function extractContext(resp) {
|
|
1376
|
+
if (!resp) return undefined
|
|
1377
|
+
const hso = resp.hookSpecificOutput
|
|
1378
|
+
if (hso && typeof hso.additionalContext === "string") return hso.additionalContext
|
|
1379
|
+
if (typeof resp.systemMessage === "string") return resp.systemMessage
|
|
1380
|
+
return undefined
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function extractUpdatedInput(resp) {
|
|
1384
|
+
const hso = resp && resp.hookSpecificOutput
|
|
1385
|
+
return hso && typeof hso === "object" ? hso.updatedInput : undefined
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
export const TokenGoatPlugin = async ({ directory }) => {
|
|
1389
|
+
return {
|
|
1390
|
+
"tool.execute.before": async (input, output) => {
|
|
1391
|
+
const tg = TOOL_TO_TG[input.tool]
|
|
1392
|
+
if (!tg || !PRE_HOOK_TOOLS.has(input.tool)) return
|
|
1393
|
+
|
|
1394
|
+
const resp = await callHook("pre_tool_use", {
|
|
1395
|
+
session_id: input.sessionID,
|
|
1396
|
+
tool_name: tg,
|
|
1397
|
+
tool_input: toToolInput(input.tool, output.args),
|
|
1398
|
+
cwd: directory,
|
|
1399
|
+
})
|
|
1400
|
+
if (!resp) return
|
|
1401
|
+
|
|
1402
|
+
if (resp.decision === "block") {
|
|
1403
|
+
throw new Error(resp.reason || "blocked by token-goat")
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const updated = extractUpdatedInput(resp)
|
|
1407
|
+
if (updated) {
|
|
1408
|
+
const rev = reverseArgMap(input.tool)
|
|
1409
|
+
for (const [tgKey, val] of Object.entries(updated)) {
|
|
1410
|
+
output.args[rev[tgKey] ?? tgKey] = val
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
|
|
1415
|
+
"tool.execute.after": async (input, output) => {
|
|
1416
|
+
const tg = TOOL_TO_TG[input.tool]
|
|
1417
|
+
if (!tg) return
|
|
1418
|
+
|
|
1419
|
+
const resp = await callHook("post_tool_use", {
|
|
1420
|
+
session_id: input.sessionID,
|
|
1421
|
+
tool_name: tg,
|
|
1422
|
+
tool_input: toToolInput(input.tool, input.args),
|
|
1423
|
+
tool_response: { output: output.output, exit_code: output.metadata ? output.metadata.exit : undefined },
|
|
1424
|
+
cwd: directory,
|
|
1425
|
+
})
|
|
1426
|
+
if (!resp) return
|
|
1427
|
+
|
|
1428
|
+
// rewriteOutput: replace the tool result wholesale. This is the shape every post_tool_use rewrite producer emits (WebFetch injection fencing and secret redaction, websearch redaction, bash/grep output compression, agent-report compaction), and it went unapplied here for the same whitelist reason the response-contract docblock above used to omit it -- the plugin only handled the shapes its author listed, so opencode sessions got the appended hint channel but never a fenced, redacted or compressed result. Mutating output.output is the exact mechanism the context append below already relies on, so this claims no capability the append did not.
|
|
1429
|
+
const hso = resp.hookSpecificOutput
|
|
1430
|
+
const updatedToolOutput = hso && typeof hso.updatedToolOutput === "string" ? hso.updatedToolOutput : undefined
|
|
1431
|
+
if (updatedToolOutput !== undefined) {
|
|
1432
|
+
output.output = updatedToolOutput
|
|
1433
|
+
return
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const context = extractContext(resp)
|
|
1437
|
+
if (context && typeof output.output === "string") {
|
|
1438
|
+
output.output += \`\\n\\n[token-goat] \${context}\`
|
|
1439
|
+
}
|
|
1440
|
+
},
|
|
1441
|
+
|
|
1442
|
+
"experimental.session.compacting": async (input, output) => {
|
|
1443
|
+
const resp = await callHook("pre_compact", { session_id: input.sessionID })
|
|
1444
|
+
const manifest = resp && typeof resp.systemMessage === "string" ? resp.systemMessage : undefined
|
|
1445
|
+
if (manifest) output.context.push(manifest)
|
|
1446
|
+
},
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
`;
|
|
1450
|
+
|
|
1451
|
+
// src/bridges/opencode_install.ts
|
|
1452
|
+
function opencodeGlobalConfigDir() {
|
|
1453
|
+
const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
|
|
1454
|
+
if (xdgConfigHome !== void 0 && xdgConfigHome.trim() !== "") return xdgConfigHome;
|
|
1455
|
+
return path6.join(os5.homedir(), ".config");
|
|
1456
|
+
}
|
|
1457
|
+
function opencodePluginPath() {
|
|
1458
|
+
return path6.join(opencodeGlobalConfigDir(), "opencode", "plugins", "token-goat.ts");
|
|
1459
|
+
}
|
|
1460
|
+
function opencodeEntrySidecarPath() {
|
|
1461
|
+
return path6.join(path6.dirname(opencodePluginPath()), "token-goat-entry.json");
|
|
1462
|
+
}
|
|
1463
|
+
function legacyWindowsPluginPath() {
|
|
1464
|
+
if (process.platform !== "win32") return null;
|
|
1465
|
+
const appData = process.env["APPDATA"];
|
|
1466
|
+
const root = appData !== void 0 && appData.trim() !== "" ? appData : path6.join(os5.homedir(), "AppData", "Roaming");
|
|
1467
|
+
return path6.join(root, "opencode", "plugins", "token-goat.ts");
|
|
1468
|
+
}
|
|
1469
|
+
function removeLegacyWindowsPlugin() {
|
|
1470
|
+
const legacy = legacyWindowsPluginPath();
|
|
1471
|
+
if (legacy === null) return false;
|
|
1472
|
+
return uninstallSingleFilePlugin(legacy, path6.join(path6.dirname(legacy), "token-goat-entry.json"));
|
|
1473
|
+
}
|
|
1474
|
+
function installOpencode() {
|
|
1475
|
+
const pluginPath = opencodePluginPath();
|
|
1476
|
+
const { alreadyInstalled } = recordCreatedBy([pluginPath, opencodeEntrySidecarPath()], () => installSingleFilePlugin(pluginPath, opencodeEntrySidecarPath(), OPENCODE_PLUGIN_SCRIPT));
|
|
1477
|
+
removeLegacyWindowsPlugin();
|
|
1478
|
+
return { pluginPath, alreadyInstalled };
|
|
1479
|
+
}
|
|
1480
|
+
function uninstallOpencode() {
|
|
1481
|
+
const removedLegacy = removeLegacyWindowsPlugin();
|
|
1482
|
+
const removed = uninstallSingleFilePlugin(opencodePluginPath(), opencodeEntrySidecarPath());
|
|
1483
|
+
removeCreatedTree([opencodePluginPath(), opencodeEntrySidecarPath()]);
|
|
1484
|
+
return removed || removedLegacy;
|
|
1485
|
+
}
|
|
1486
|
+
function isOpencodeInstalled() {
|
|
1487
|
+
return fs6.existsSync(opencodePluginPath());
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// src/bridges/openclaw_install.ts
|
|
1491
|
+
init_define_import_meta_env();
|
|
1492
|
+
import * as fs7 from "node:fs";
|
|
1493
|
+
import * as os6 from "node:os";
|
|
1494
|
+
import * as path7 from "node:path";
|
|
1495
|
+
|
|
1496
|
+
// src/bridges/openclaw.ts
|
|
1497
|
+
init_define_import_meta_env();
|
|
1498
|
+
var OPENCLAW_PLUGIN_SCRIPT = `// token-goat bridge plugin for OpenClaw
|
|
1499
|
+
// Bridges OpenClaw's tool-call and session hooks to token-goat's subprocess
|
|
1500
|
+
// hook protocol. https://github.com/DFKHelper/token-goat
|
|
1501
|
+
//
|
|
1502
|
+
// NOTE ON PARAMETER SHAPES: OpenClaw's bash tool really does use token-goat's own key names (command/timeout), but the path-carrying tools (read/edit/write) send the file path under "path", not "file_path" -- read out of OpenClaw's own tool input schemas (src/agents/sessions/tools/read-tool-contract.ts, edit.ts, bash.ts in openclaw/openclaw). toToolInput below ADDS the canonical file_path alongside the original path so token-goat's getFilePath()-based handlers can see it, without renaming anything. The built-in tool NAME list below is broader than any single source confirms, since an unmatched name is a harmless no-op here, not a wrong rewrite.
|
|
1503
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
1504
|
+
import { spawnSync } from "node:child_process";
|
|
1505
|
+
import * as fs from "node:fs";
|
|
1506
|
+
import * as os from "node:os";
|
|
1507
|
+
import * as path from "node:path";
|
|
1508
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
1509
|
+
|
|
1510
|
+
// OpenClaw built-in tool names -> token-goat internal tool names.
|
|
1511
|
+
const TOOL_TO_TG = {
|
|
1512
|
+
read: "Read",
|
|
1513
|
+
write: "Write",
|
|
1514
|
+
edit: "Edit",
|
|
1515
|
+
apply_patch: "Edit",
|
|
1516
|
+
exec: "Bash",
|
|
1517
|
+
bash: "Bash",
|
|
1518
|
+
grep: "Grep",
|
|
1519
|
+
glob: "Glob",
|
|
1520
|
+
find: "Glob",
|
|
1521
|
+
webfetch: "WebFetch",
|
|
1522
|
+
web_search: "WebFetch",
|
|
1523
|
+
web_fetch: "WebFetch",
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
// Tools with a pre_tool_use handler whose output shape this hook can act on: deny (block/blockReason), updatedInput (params rewrite), or the Read image-shrink materialization. Edit/Write have no pre handler at all. Glob (OpenClaw's find) DOES have one -- preGlobDedupHandler in hooks_glob.ts; the old claim here that it has none was stale -- but its only output is an advisory contextOutput hint and before_tool_call has no context channel, so calling it would spawn a hook subprocess per find only to drop the answer; it stays excluded for that reason (matching pi.ts/opencode.ts's corrected note).
|
|
1527
|
+
const PRE_HOOK_TOOLS = new Set(["Read", "Grep", "Bash", "WebFetch"]);
|
|
1528
|
+
|
|
1529
|
+
// OpenClaw tools whose file path travels under "path" (see the parameter-shapes note above). apply_patch is handled separately: its input is a patch document naming any number of files.
|
|
1530
|
+
const PATH_ARG_TOOLS = new Set(["read", "write", "edit"]);
|
|
1531
|
+
|
|
1532
|
+
// The directory OpenClaw resolves a relative tool path against. Checked against OpenClaw 2026.9.7: its coding tools resolve against the agent workspace (agents.list[].workspace, else agents.defaults.workspace, else the state dir's workspace folder), not the gateway process's own directory, and the tool-call hook context carries only agentId/sessionId/sessionKey/runId/toolCallId, no directory. So the workspace is looked up through the plugin runtime's own resolver, api.runtime.agent.resolveAgentWorkspaceDir(api.runtime.config.current(), agentId). The workspace differs from the resolve base only when a run asked for a separate cwd or runs sandboxed, and neither is visible to a plugin. process.cwd() is the last resort for a runtime without that resolver.
|
|
1533
|
+
function workspaceFor(api, ctx) {
|
|
1534
|
+
if (ctx && typeof ctx.workspaceDir === "string" && ctx.workspaceDir) return ctx.workspaceDir;
|
|
1535
|
+
try {
|
|
1536
|
+
const rt = api && api.runtime;
|
|
1537
|
+
const dir = rt.agent.resolveAgentWorkspaceDir(rt.config.current(), ctx && ctx.agentId);
|
|
1538
|
+
if (typeof dir === "string" && dir) return dir;
|
|
1539
|
+
} catch {}
|
|
1540
|
+
return process.cwd();
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Turn a tool's raw path argument into the absolute path OpenClaw will open, applying OpenClaw's own rewrites in its order: a leading "@" (a file reference) is dropped unless a file or folder literally named that way exists, a file:// URL becomes a path, and "~", "~/" and, on Windows, "~" plus a backslash expand to the home folder. Without this, a relative path was resolved against whatever directory token-goat happened to run in, so a re-read was never recognised and an edit was never queued for reindexing.
|
|
1544
|
+
function resolveToolPathArg(raw, base) {
|
|
1545
|
+
let p = raw.trim();
|
|
1546
|
+
if (p.startsWith("@") && p.length > 1) {
|
|
1547
|
+
const first = p.split("/")[0].split(path.sep)[0];
|
|
1548
|
+
if (!fs.existsSync(path.resolve(base, p)) && !fs.existsSync(path.resolve(base, first))) p = p.slice(1);
|
|
1549
|
+
}
|
|
1550
|
+
if (/^file:[/][/]/i.test(p)) {
|
|
1551
|
+
try { p = fileURLToPath(p); } catch {}
|
|
1552
|
+
}
|
|
1553
|
+
if (p === "~") p = os.homedir();
|
|
1554
|
+
else if (p.startsWith("~/") || p.startsWith("~" + path.sep)) p = path.join(os.homedir(), p.slice(2));
|
|
1555
|
+
return path.resolve(base, p);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// apply_patch (params.input, OpenClaw's apply-patch-paths extractApplyPatchTargets) names its files on header lines, leading whitespace allowed, with a rename's "*** Move to: " following its "*** Update File: ".
|
|
1559
|
+
const PATCH_PATH_HEADERS = ["*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "];
|
|
1560
|
+
|
|
1561
|
+
function patchTextPaths(patchText) {
|
|
1562
|
+
const paths = [];
|
|
1563
|
+
if (typeof patchText !== "string") return paths;
|
|
1564
|
+
for (const rawLine of patchText.split(String.fromCharCode(10))) {
|
|
1565
|
+
const line = rawLine.trimStart();
|
|
1566
|
+
for (const header of PATCH_PATH_HEADERS) {
|
|
1567
|
+
if (!line.startsWith(header)) continue;
|
|
1568
|
+
const p = line.slice(header.length).trim();
|
|
1569
|
+
if (p && !paths.includes(p)) paths.push(p);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return paths;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Build token-goat's tool_input from OpenClaw's params: same object, plus the canonical file_path (absolute, see resolveToolPathArg) added alongside the original path for the tools that carry one, or file_paths for apply_patch. derived is OpenClaw's own resolution of the patch's files (event.derivedPaths), preferred over re-parsing the patch. Never renames or drops a key.
|
|
1576
|
+
function toToolInput(toolName, params, base, derived) {
|
|
1577
|
+
const p = params || {};
|
|
1578
|
+
if (PATH_ARG_TOOLS.has(toolName) && typeof p.path === "string" && p.file_path === undefined) {
|
|
1579
|
+
return { ...p, file_path: resolveToolPathArg(p.path, base) };
|
|
1580
|
+
}
|
|
1581
|
+
if (toolName === "apply_patch" && p.file_path === undefined) {
|
|
1582
|
+
const files = derived && derived.length ? derived : patchTextPaths(p.input).map((f) => resolveToolPathArg(f, base));
|
|
1583
|
+
if (files.length === 1) return { ...p, file_path: files[0], file_paths: files };
|
|
1584
|
+
if (files.length > 1) return { ...p, file_paths: files };
|
|
1585
|
+
}
|
|
1586
|
+
return p;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Pull the additionalContext / systemMessage string out of a hook response, whichever shape it came back as (see the response-contract note in this module's header comment).
|
|
1590
|
+
function extractContext(resp) {
|
|
1591
|
+
if (!resp) return undefined;
|
|
1592
|
+
const hso = resp["hookSpecificOutput"];
|
|
1593
|
+
if (hso && typeof hso["additionalContext"] === "string") return hso["additionalContext"];
|
|
1594
|
+
if (typeof resp["systemMessage"] === "string") return resp["systemMessage"];
|
|
1595
|
+
return undefined;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
${BRIDGE_RELAY_JS}
|
|
1599
|
+
|
|
1600
|
+
${MATERIALIZE_SHRUNK_IMAGE_JS}
|
|
1601
|
+
|
|
1602
|
+
export default definePluginEntry({
|
|
1603
|
+
id: "token-goat",
|
|
1604
|
+
name: "token-goat",
|
|
1605
|
+
description:
|
|
1606
|
+
"Bridges OpenClaw's tool-call and session hooks to token-goat's read-hinting, bash compression, and index-refresh pipeline.",
|
|
1607
|
+
register(api) {
|
|
1608
|
+
// No confirmed stable per-session id on tool-call context (see module
|
|
1609
|
+
// header) -- fall back to a per-process pseudo-id if ctx carries none.
|
|
1610
|
+
let sessionId = \`openclaw-\${process.pid}-\${Date.now()}\`;
|
|
1611
|
+
|
|
1612
|
+
api.on("session_start", (event, ctx) => {
|
|
1613
|
+
sessionId = (ctx && (ctx.sessionId || ctx.sessionKey)) || sessionId;
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
// No forwarding callHook for session_start here (there used to be one):
|
|
1617
|
+
// token-goat retired the session_start hook -- it only ever reached a
|
|
1618
|
+
// permanent no-op handler (see the removal in src/hooks_session.ts and
|
|
1619
|
+
// src/types.ts). The subscription above is kept regardless, because it's
|
|
1620
|
+
// OpenClaw's own lifecycle event and is still needed to refresh
|
|
1621
|
+
// sessionId for every other bridged call below.
|
|
1622
|
+
|
|
1623
|
+
// No api.on("session_end", ...) here: token-goat's own HOOK_EVENTS
|
|
1624
|
+
// (src/types.ts) has no session_end member, so calling it would be the
|
|
1625
|
+
// same invented-event-name no-op already found and fixed once for pi's
|
|
1626
|
+
// bridge (commit 2f0a15e4). OpenClaw's session_end is a real lifecycle
|
|
1627
|
+
// event, but token-goat has no corresponding hook to bridge it to.
|
|
1628
|
+
|
|
1629
|
+
// apply_patch's files as OpenClaw itself resolved them (event.derivedPaths, absolute), keyed by toolCallId: only before_tool_call carries them, and after_tool_call is where the edit is recorded. Capped so a call that never completes cannot grow it without bound.
|
|
1630
|
+
const patchPaths = new Map();
|
|
1631
|
+
|
|
1632
|
+
api.on("before_tool_call", async (event, ctx) => {
|
|
1633
|
+
const callId = event.toolCallId || (ctx && ctx.toolCallId);
|
|
1634
|
+
if (event.toolName === "apply_patch" && callId && Array.isArray(event.derivedPaths)) {
|
|
1635
|
+
patchPaths.set(callId, [...event.derivedPaths]);
|
|
1636
|
+
if (patchPaths.size > 256) patchPaths.delete(patchPaths.keys().next().value);
|
|
1637
|
+
}
|
|
1638
|
+
const tg = TOOL_TO_TG[event.toolName];
|
|
1639
|
+
if (!tg || !PRE_HOOK_TOOLS.has(tg)) return {};
|
|
1640
|
+
|
|
1641
|
+
const sid = (ctx && (ctx.sessionId || ctx.sessionKey)) || sessionId;
|
|
1642
|
+
const base = workspaceFor(api, ctx);
|
|
1643
|
+
const resp = await callHook("pre_tool_use", {
|
|
1644
|
+
session_id: sid,
|
|
1645
|
+
tool_name: tg,
|
|
1646
|
+
tool_input: toToolInput(event.toolName, event.params, base),
|
|
1647
|
+
cwd: base,
|
|
1648
|
+
});
|
|
1649
|
+
if (!resp) return {};
|
|
1650
|
+
|
|
1651
|
+
// Deny: block the tool call with a reason (e.g. confirmed re-read).
|
|
1652
|
+
if (resp["decision"] === "block") {
|
|
1653
|
+
return { block: true, blockReason: resp["reason"] || "blocked by token-goat" };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Rewrite: token-goat returns its own snake_case tool_input keys, which
|
|
1657
|
+
// are already OpenClaw's native param shape (see module header) -- no
|
|
1658
|
+
// reverse-mapping needed, just merge over the original params.
|
|
1659
|
+
const hso = resp["hookSpecificOutput"];
|
|
1660
|
+
const updated = hso ? hso["updatedInput"] : undefined;
|
|
1661
|
+
if (updated) {
|
|
1662
|
+
return { params: { ...(event.params || {}), ...updated } };
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Image shrink has no context channel here -- translate it into a rewritten path pointing at a materialized shrunk copy instead, via the same params-rewrite channel the updatedInput merge above already relies on. OpenClaw's read tool takes the path under "path" (see the parameter-shapes note above), and the rest of the original params are preserved.
|
|
1666
|
+
if (tg === "Read") {
|
|
1667
|
+
const shrunkPath = materializeShrunkImage(extractContext(resp));
|
|
1668
|
+
if (shrunkPath) return { params: { ...(event.params || {}), path: shrunkPath } };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
return {};
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
1675
|
+
const tg = TOOL_TO_TG[event.toolName];
|
|
1676
|
+
if (!tg) return;
|
|
1677
|
+
const sid = (ctx && (ctx.sessionId || ctx.sessionKey)) || sessionId;
|
|
1678
|
+
// event.result's shape is unverified against a live OpenClaw instance
|
|
1679
|
+
// (see module header) -- token-goat's post_tool_use handlers already
|
|
1680
|
+
// accept tool_response as either a raw string or an object keyed by
|
|
1681
|
+
// output/content/text/body, so forward it as-is when it's already an
|
|
1682
|
+
// object, or wrap a bare string/other value as { output } so those
|
|
1683
|
+
// handlers can find it either way, mirroring opencode.ts's and
|
|
1684
|
+
// pi.ts's tool_response: { output } shape.
|
|
1685
|
+
const toolResponse =
|
|
1686
|
+
event.result && typeof event.result === "object" ? event.result : { output: event.result };
|
|
1687
|
+
const callId = event.toolCallId || (ctx && ctx.toolCallId);
|
|
1688
|
+
const derived = callId ? patchPaths.get(callId) : undefined;
|
|
1689
|
+
if (callId) patchPaths.delete(callId);
|
|
1690
|
+
const base = workspaceFor(api, ctx);
|
|
1691
|
+
await callHook("post_tool_use", {
|
|
1692
|
+
session_id: sid,
|
|
1693
|
+
tool_name: tg,
|
|
1694
|
+
tool_input: toToolInput(event.toolName, event.params, base, derived),
|
|
1695
|
+
cwd: base,
|
|
1696
|
+
tool_response: toolResponse,
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
// Observation-only: no manifest-reinjection channel exists here (see
|
|
1701
|
+
// module header). This keeps token-goat's own session bookkeeping in
|
|
1702
|
+
// sync with real compaction events.
|
|
1703
|
+
api.on("before_compaction", async (event, ctx) => {
|
|
1704
|
+
const sid = (ctx && (ctx.sessionId || ctx.sessionKey)) || sessionId;
|
|
1705
|
+
await callHook("pre_compact", { session_id: sid, trigger: "auto" });
|
|
1706
|
+
});
|
|
1707
|
+
},
|
|
1708
|
+
});
|
|
1709
|
+
`;
|
|
1710
|
+
|
|
1711
|
+
// src/bridges/openclaw_install.ts
|
|
1712
|
+
var OpenclawConfigParseError = class extends Error {
|
|
1713
|
+
};
|
|
1714
|
+
var OPENCLAW_PLUGIN_ID = "token-goat";
|
|
1715
|
+
function openclawHomeDir() {
|
|
1716
|
+
return path7.join(os6.homedir(), ".openclaw");
|
|
1717
|
+
}
|
|
1718
|
+
function openclawConfigPath() {
|
|
1719
|
+
return path7.join(openclawHomeDir(), "openclaw.json");
|
|
1720
|
+
}
|
|
1721
|
+
function openclawPluginPath() {
|
|
1722
|
+
return path7.join(openclawHomeDir(), "plugins", "token-goat.ts");
|
|
1723
|
+
}
|
|
1724
|
+
function openclawEntrySidecarPath() {
|
|
1725
|
+
return path7.join(path7.dirname(openclawPluginPath()), "token-goat-entry.json");
|
|
1726
|
+
}
|
|
1727
|
+
function readOpenclawConfig(p, opts = {}) {
|
|
1728
|
+
const refuse = (problem, detail) => new OpenclawConfigParseError(
|
|
1729
|
+
(opts.command === "uninstall" ? `OpenClaw config file ${echoedValue(p)} is unreadable: it ${problem}. Uninstall left it untouched, along with the plugin it may still load, the plugin's entry sidecar and the file's backups; fix the file and run uninstall again.` : `OpenClaw config file ${echoedValue(p)} ${problem}. Fix or back up the file before running install.`) + (detail === void 0 ? "" : ` (${detail})`)
|
|
1730
|
+
);
|
|
1731
|
+
let raw;
|
|
1732
|
+
try {
|
|
1733
|
+
raw = fs7.readFileSync(p, "utf8");
|
|
1734
|
+
} catch (e) {
|
|
1735
|
+
const code = e.code;
|
|
1736
|
+
if (opts.strict === true && code !== "ENOENT" && code !== "ENOTDIR") throw refuse("exists but cannot be read", code ?? extractErrorMessage(e));
|
|
1737
|
+
return {};
|
|
1738
|
+
}
|
|
1739
|
+
let parsed;
|
|
1740
|
+
try {
|
|
1741
|
+
parsed = parseJsonOrJsonc(raw);
|
|
1742
|
+
} catch (e) {
|
|
1743
|
+
if (opts.strict === true) throw refuse("exists but contains invalid JSON", extractErrorMessage(e));
|
|
1744
|
+
return {};
|
|
1745
|
+
}
|
|
1746
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1747
|
+
const settings = parsed;
|
|
1748
|
+
const paths = settings.plugins?.load?.paths;
|
|
1749
|
+
if (paths !== void 0 && !Array.isArray(paths)) {
|
|
1750
|
+
if (opts.strict === true) throw refuse("has a 'plugins.load.paths' field that isn't a JSON array");
|
|
1751
|
+
delete settings.plugins?.load?.paths;
|
|
1752
|
+
}
|
|
1753
|
+
return settings;
|
|
1754
|
+
}
|
|
1755
|
+
if (opts.strict === true) throw refuse("does not contain a JSON object at the top level");
|
|
1756
|
+
return {};
|
|
1757
|
+
}
|
|
1758
|
+
function openclawArtifactPaths() {
|
|
1759
|
+
return [openclawConfigPath(), openclawPluginPath(), openclawEntrySidecarPath()];
|
|
1760
|
+
}
|
|
1761
|
+
function installOpenclaw() {
|
|
1762
|
+
return recordCreatedBy(openclawArtifactPaths(), installOpenclawFiles);
|
|
1763
|
+
}
|
|
1764
|
+
function installOpenclawFiles() {
|
|
1765
|
+
const configPath = openclawConfigPath();
|
|
1766
|
+
const pluginPath = openclawPluginPath();
|
|
1767
|
+
let existingPlugin;
|
|
1768
|
+
try {
|
|
1769
|
+
existingPlugin = fs7.readFileSync(pluginPath, "utf8");
|
|
1770
|
+
} catch {
|
|
1771
|
+
existingPlugin = void 0;
|
|
1772
|
+
}
|
|
1773
|
+
const pluginChanged = existingPlugin !== OPENCLAW_PLUGIN_SCRIPT;
|
|
1774
|
+
const settings = readOpenclawConfig(configPath, { strict: true });
|
|
1775
|
+
const entryPath = process.argv[1];
|
|
1776
|
+
if (entryPath) {
|
|
1777
|
+
ensureDirSync(path7.dirname(pluginPath));
|
|
1778
|
+
atomicWriteText(openclawEntrySidecarPath(), JSON.stringify({ entryPath }));
|
|
1779
|
+
}
|
|
1780
|
+
const plugins = settings.plugins ?? {};
|
|
1781
|
+
const loadPaths = [...plugins.load?.paths ?? []];
|
|
1782
|
+
const entries = { ...plugins.entries ?? {} };
|
|
1783
|
+
let configChanged = false;
|
|
1784
|
+
if (!loadPaths.some((p) => foldPath(p) === foldPath(pluginPath))) {
|
|
1785
|
+
loadPaths.push(pluginPath);
|
|
1786
|
+
configChanged = true;
|
|
1787
|
+
}
|
|
1788
|
+
if (entries[OPENCLAW_PLUGIN_ID] === void 0) {
|
|
1789
|
+
entries[OPENCLAW_PLUGIN_ID] = { enabled: true };
|
|
1790
|
+
configChanged = true;
|
|
1791
|
+
}
|
|
1792
|
+
if (!pluginChanged && !configChanged) {
|
|
1793
|
+
return { configPath, pluginPath, alreadyInstalled: true };
|
|
1794
|
+
}
|
|
1795
|
+
if (pluginChanged) {
|
|
1796
|
+
ensureDirSync(path7.dirname(pluginPath));
|
|
1797
|
+
atomicWriteText(pluginPath, OPENCLAW_PLUGIN_SCRIPT);
|
|
1798
|
+
}
|
|
1799
|
+
if (configChanged) {
|
|
1800
|
+
settings.plugins = { ...plugins, load: { ...plugins.load, paths: loadPaths }, entries };
|
|
1801
|
+
writeSettingsKeepingComments(configPath, settings);
|
|
1802
|
+
}
|
|
1803
|
+
return { configPath, pluginPath, alreadyInstalled: false };
|
|
1804
|
+
}
|
|
1805
|
+
function uninstallOpenclaw() {
|
|
1806
|
+
const configPath = openclawConfigPath();
|
|
1807
|
+
const pluginPath = openclawPluginPath();
|
|
1808
|
+
const settings = readOpenclawConfig(configPath, { strict: true, command: "uninstall" });
|
|
1809
|
+
let removed = false;
|
|
1810
|
+
try {
|
|
1811
|
+
fs7.unlinkSync(pluginPath);
|
|
1812
|
+
removed = true;
|
|
1813
|
+
} catch {
|
|
1814
|
+
}
|
|
1815
|
+
try {
|
|
1816
|
+
fs7.unlinkSync(openclawEntrySidecarPath());
|
|
1817
|
+
} catch {
|
|
1818
|
+
}
|
|
1819
|
+
const plugins = settings.plugins;
|
|
1820
|
+
if (plugins !== void 0) {
|
|
1821
|
+
const loadPaths = plugins.load?.paths;
|
|
1822
|
+
const entries = plugins.entries;
|
|
1823
|
+
let configChanged = false;
|
|
1824
|
+
if (loadPaths !== void 0 && loadPaths.some((p) => foldPath(p) === foldPath(pluginPath))) {
|
|
1825
|
+
const kept = loadPaths.filter((p) => foldPath(p) !== foldPath(pluginPath));
|
|
1826
|
+
if (kept.length > 0) {
|
|
1827
|
+
plugins.load = { ...plugins.load, paths: kept };
|
|
1828
|
+
} else if (plugins.load !== void 0) {
|
|
1829
|
+
delete plugins.load.paths;
|
|
1830
|
+
if (Object.keys(plugins.load).length === 0) delete plugins.load;
|
|
1831
|
+
}
|
|
1832
|
+
configChanged = true;
|
|
1833
|
+
removed = true;
|
|
1834
|
+
}
|
|
1835
|
+
if (entries !== void 0 && entries[OPENCLAW_PLUGIN_ID] !== void 0) {
|
|
1836
|
+
delete entries[OPENCLAW_PLUGIN_ID];
|
|
1837
|
+
if (Object.keys(entries).length === 0) delete plugins.entries;
|
|
1838
|
+
configChanged = true;
|
|
1839
|
+
removed = true;
|
|
1840
|
+
}
|
|
1841
|
+
if (configChanged) {
|
|
1842
|
+
if (Object.keys(plugins).length === 0) {
|
|
1843
|
+
delete settings.plugins;
|
|
1844
|
+
} else {
|
|
1845
|
+
settings.plugins = plugins;
|
|
1846
|
+
}
|
|
1847
|
+
writeSettingsKeepingComments(configPath, settings);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
removeCreatedBackups(configPath);
|
|
1851
|
+
removeCreatedTree(openclawArtifactPaths());
|
|
1852
|
+
return removed;
|
|
1853
|
+
}
|
|
1854
|
+
function isOpenclawInstalled() {
|
|
1855
|
+
if (!fs7.existsSync(openclawPluginPath())) return false;
|
|
1856
|
+
const settings = readOpenclawConfig(openclawConfigPath());
|
|
1857
|
+
const plugins = settings.plugins;
|
|
1858
|
+
if (plugins === void 0) return false;
|
|
1859
|
+
const targetFolded = foldPath(openclawPluginPath());
|
|
1860
|
+
const hasPath = (plugins.load?.paths ?? []).some((p) => foldPath(p) === targetFolded);
|
|
1861
|
+
const hasEntry = plugins.entries?.[OPENCLAW_PLUGIN_ID] !== void 0;
|
|
1862
|
+
return hasPath && hasEntry;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/bridges/grok.ts
|
|
1866
|
+
init_define_import_meta_env();
|
|
1867
|
+
var GROK_HOOK_SCRIPT = `#!/usr/bin/env node
|
|
1868
|
+
// token-goat Grok CLI hook shim. Forwards the hook payload to \`token-goat hook <event>\`, then -- for pre_tool_use only -- translates token-goat's {"decision":"block",...} deny shape into Grok's documented {"decision":"deny",...} shape and sets exit code 2.
|
|
1869
|
+
'use strict'
|
|
1870
|
+
${SHIM_REQUIRES}
|
|
1871
|
+
|
|
1872
|
+
// Keep in sync with HOOK_EVENTS in src/types.ts.
|
|
1873
|
+
const VALID_HOOK_EVENTS = new Set([
|
|
1874
|
+
'pre_tool_use',
|
|
1875
|
+
'post_tool_use',
|
|
1876
|
+
'notification',
|
|
1877
|
+
'stop',
|
|
1878
|
+
'pre_compact',
|
|
1879
|
+
'user_prompt_submit',
|
|
1880
|
+
'subagent_stop',
|
|
1881
|
+
])
|
|
1882
|
+
|
|
1883
|
+
function allowResponse(eventName) {
|
|
1884
|
+
return eventName === 'pre_tool_use' ? '{"decision":"allow"}' : '{}'
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
${SHIM_TRY_SERVER}
|
|
1888
|
+
|
|
1889
|
+
${SHIM_TRY_IN_PROCESS}
|
|
1890
|
+
|
|
1891
|
+
${SHIM_MAX_BUFFER_CONST}
|
|
1892
|
+
${shimFallbackTimeoutConst("grok")}
|
|
1893
|
+
|
|
1894
|
+
async function main() {
|
|
1895
|
+
const eventName = process.argv[2] || ''
|
|
1896
|
+
if (!VALID_HOOK_EVENTS.has(eventName)) {
|
|
1897
|
+
process.stdout.write('{}')
|
|
1898
|
+
return
|
|
1899
|
+
}
|
|
1900
|
+
let input = ''
|
|
1901
|
+
try {
|
|
1902
|
+
input = require('node:fs').readFileSync(0, 'utf8')
|
|
1903
|
+
} catch {
|
|
1904
|
+
process.stdout.write(allowResponse(eventName))
|
|
1905
|
+
return
|
|
1906
|
+
}
|
|
1907
|
+
// process.argv[3], when present, is the absolute path to the token-goat CLI entry
|
|
1908
|
+
// that ran 'token-goat install --grok' (baked in by hookCommandFor in
|
|
1909
|
+
// grok_install.ts). Try the in-process hook lib first (tryInProcess above) -- this
|
|
1910
|
+
// avoids spawning a second node process altogether. If that's unavailable, invoking
|
|
1911
|
+
// the entry directly via process.execPath sidesteps PATH resolution for this inner
|
|
1912
|
+
// call, the same reasoning as every other bridge shim's inner call. Falls back
|
|
1913
|
+
// further to the old PATH-based shell:true invocation when argv[3] is absent. A
|
|
1914
|
+
// 3000ms timeout/killSignal on both spawnSync fallbacks bounds them well under
|
|
1915
|
+
// Grok's own hook timeout (default 5s, per the hooks doc), so token-goat degrades to
|
|
1916
|
+
// its own fail-open response rather than being force-killed by Grok first.
|
|
1917
|
+
const entryPath = process.argv[3]
|
|
1918
|
+
let stdout = await tryInProcess(entryPath, eventName, input)
|
|
1919
|
+
if (stdout === undefined) {
|
|
1920
|
+
${SHIM_SPAWN_LADDER}
|
|
1921
|
+
: spawnSync('token-goat hook ' + eventName, {
|
|
1922
|
+
input,
|
|
1923
|
+
encoding: 'utf8',
|
|
1924
|
+
shell: true,
|
|
1925
|
+
timeout: SHIM_FALLBACK_TIMEOUT_MS,
|
|
1926
|
+
killSignal: 'SIGKILL',
|
|
1927
|
+
maxBuffer: SHIM_MAX_BUFFER_BYTES,
|
|
1928
|
+
})
|
|
1929
|
+
if (res.status !== 0 || !res.stdout) {
|
|
1930
|
+
process.stdout.write(allowResponse(eventName))
|
|
1931
|
+
return
|
|
1932
|
+
}
|
|
1933
|
+
stdout = res.stdout
|
|
1934
|
+
}
|
|
1935
|
+
let parsed
|
|
1936
|
+
try {
|
|
1937
|
+
parsed = JSON.parse(stdout)
|
|
1938
|
+
} catch {
|
|
1939
|
+
process.stdout.write(allowResponse(eventName))
|
|
1940
|
+
return
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
if (eventName !== 'pre_tool_use') {
|
|
1944
|
+
// Passive events: Grok ignores stdout entirely (per the hooks doc), so the exact
|
|
1945
|
+
// shape doesn't matter -- forward token-goat's response verbatim.
|
|
1946
|
+
process.stdout.write(JSON.stringify(parsed))
|
|
1947
|
+
return
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (parsed && parsed.decision === 'block') {
|
|
1951
|
+
process.stdout.write(JSON.stringify({ decision: 'deny', reason: parsed.reason || 'blocked by token-goat' }))
|
|
1952
|
+
process.exitCode = 2
|
|
1953
|
+
return
|
|
1954
|
+
}
|
|
1955
|
+
process.stdout.write('{"decision":"allow"}')
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
main().catch((err) => {
|
|
1959
|
+
try {
|
|
1960
|
+
process.stdout.write(allowResponse(process.argv[2] || ''))
|
|
1961
|
+
} catch {
|
|
1962
|
+
// stdout itself is broken; nothing more can be done here.
|
|
1963
|
+
}
|
|
1964
|
+
})
|
|
1965
|
+
`;
|
|
1966
|
+
|
|
1967
|
+
// src/bridges/grok_install.ts
|
|
1968
|
+
init_define_import_meta_env();
|
|
1969
|
+
import * as fs8 from "node:fs";
|
|
1970
|
+
import * as os7 from "node:os";
|
|
1971
|
+
import * as path8 from "node:path";
|
|
1972
|
+
var GROK_HOOK_EVENTS = ["PreToolUse", "PostToolUse", "PreCompact", "UserPromptSubmit", "SubagentStop"];
|
|
1973
|
+
var GROK_EVENT_ARG = {
|
|
1974
|
+
PreToolUse: "pre_tool_use",
|
|
1975
|
+
PostToolUse: "post_tool_use",
|
|
1976
|
+
PreCompact: "pre_compact",
|
|
1977
|
+
UserPromptSubmit: "user_prompt_submit",
|
|
1978
|
+
SubagentStop: "subagent_stop"
|
|
1979
|
+
};
|
|
1980
|
+
function grokHooksDir() {
|
|
1981
|
+
return path8.join(os7.homedir(), ".grok", "hooks");
|
|
1982
|
+
}
|
|
1983
|
+
function grokConfigPath() {
|
|
1984
|
+
return path8.join(grokHooksDir(), "token-goat.json");
|
|
1985
|
+
}
|
|
1986
|
+
function grokHookScriptPath() {
|
|
1987
|
+
return path8.join(grokHooksDir(), SHIM_FILE);
|
|
1988
|
+
}
|
|
1989
|
+
function grokLegacyHookScriptPath() {
|
|
1990
|
+
return path8.join(grokHooksDir(), LEGACY_SHIM_FILE);
|
|
1991
|
+
}
|
|
1992
|
+
function grokHookCommandFor(scriptPath, eventArg, opts = {}) {
|
|
1993
|
+
const bin = nativeHookBinary(process.argv[1], opts);
|
|
1994
|
+
const native = bin === void 0 ? void 0 : nativeHookCommandLine(process.platform === "win32" ? "powershell" : "sh", bin, "grok", scriptPath, eventArg);
|
|
1995
|
+
return native ?? (process.platform === "win32" ? powershellHookLine(hookPowershellCommand(scriptPath, eventArg)) : hookCommandFor(scriptPath, eventArg));
|
|
1996
|
+
}
|
|
1997
|
+
function wiredGrokHookWords() {
|
|
1998
|
+
let config;
|
|
1999
|
+
try {
|
|
2000
|
+
config = JSON.parse(stripBom(fs8.readFileSync(grokConfigPath(), "utf8")));
|
|
2001
|
+
} catch {
|
|
2002
|
+
return [];
|
|
2003
|
+
}
|
|
2004
|
+
const scriptPath = grokHookScriptPath();
|
|
2005
|
+
const expected = new Set(GROK_HOOK_EVENTS.map((e) => grokHookCommandFor(scriptPath, GROK_EVENT_ARG[e], { sync: false })));
|
|
2006
|
+
const out = [];
|
|
2007
|
+
for (const groups of Object.values(config?.hooks ?? {})) {
|
|
2008
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
2009
|
+
for (const h of Array.isArray(g?.hooks) ? g.hooks : []) {
|
|
2010
|
+
if (typeof h?.command === "string") out.push({ words: splitHookCommand(h.command, process.platform === "win32" ? "powershell" : "sh"), current: expected.has(h.command) });
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
return out;
|
|
2015
|
+
}
|
|
2016
|
+
function buildConfig(scriptPath) {
|
|
2017
|
+
const hooks = {};
|
|
2018
|
+
for (const event of GROK_HOOK_EVENTS) {
|
|
2019
|
+
hooks[event] = [
|
|
2020
|
+
{ matcher: "", hooks: [{ type: "command", command: grokHookCommandFor(scriptPath, GROK_EVENT_ARG[event]) }] }
|
|
2021
|
+
];
|
|
2022
|
+
}
|
|
2023
|
+
return { hooks };
|
|
2024
|
+
}
|
|
2025
|
+
function installGrok() {
|
|
2026
|
+
return recordCreatedBy(grokArtifactPaths(), installGrokFiles);
|
|
2027
|
+
}
|
|
2028
|
+
function grokArtifactPaths() {
|
|
2029
|
+
return [grokConfigPath(), grokHookScriptPath(), grokLegacyHookScriptPath()];
|
|
2030
|
+
}
|
|
2031
|
+
function installGrokFiles() {
|
|
2032
|
+
const configPath = grokConfigPath();
|
|
2033
|
+
const scriptPath = grokHookScriptPath();
|
|
2034
|
+
const config = buildConfig(scriptPath);
|
|
2035
|
+
for (const command of Object.values(config.hooks).flatMap((groups) => (groups ?? []).flatMap((g) => g.hooks.map((h) => h.command)))) {
|
|
2036
|
+
const span = hookCommandRewrittenSpan(command, "grok");
|
|
2037
|
+
if (span !== void 0) {
|
|
2038
|
+
throw new HookCommandRewriteError(`Grok CLI substitutes ${span} in a hook command with the value of that environment variable when it loads the hook, whatever the quoting, and refuses to run the hook when it is unset, so the hook command token-goat would write (${command}) would not run token-goat. Nothing was written. Move the path that contains ${echoedValue(span)} (the home directory, token-goat's install directory, or Node's) to one without a $ name, then run \`token-goat install --grok\` again.`);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
const forwarderChanged = writeIfDifferent(grokLegacyHookScriptPath(), legacyShimForwarder("{}"));
|
|
2042
|
+
const scriptChanged = writeIfDifferent(scriptPath, GROK_HOOK_SCRIPT);
|
|
2043
|
+
const desiredText = `${JSON.stringify(config, null, 2)}
|
|
2044
|
+
`;
|
|
2045
|
+
const configChanged = writeIfDifferent(configPath, desiredText, true);
|
|
2046
|
+
return { configPath, hookScriptPath: scriptPath, alreadyInstalled: !scriptChanged && !forwarderChanged && !configChanged };
|
|
2047
|
+
}
|
|
2048
|
+
function uninstallGrok() {
|
|
2049
|
+
const configPath = grokConfigPath();
|
|
2050
|
+
const scriptPath = grokHookScriptPath();
|
|
2051
|
+
let removedAny = false;
|
|
2052
|
+
try {
|
|
2053
|
+
fs8.unlinkSync(configPath);
|
|
2054
|
+
removedAny = true;
|
|
2055
|
+
} catch {
|
|
2056
|
+
}
|
|
2057
|
+
for (const p of [scriptPath, grokLegacyHookScriptPath()]) {
|
|
2058
|
+
try {
|
|
2059
|
+
fs8.unlinkSync(p);
|
|
2060
|
+
removedAny = true;
|
|
2061
|
+
} catch {
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
removeCreatedBackups(configPath);
|
|
2065
|
+
removeCreatedTree(grokArtifactPaths());
|
|
2066
|
+
return removedAny;
|
|
2067
|
+
}
|
|
2068
|
+
function isGrokInstalled() {
|
|
2069
|
+
return fs8.existsSync(grokConfigPath()) && fs8.existsSync(grokHookScriptPath());
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// src/bridges/antigravity_install.ts
|
|
2073
|
+
init_define_import_meta_env();
|
|
2074
|
+
import * as fs9 from "node:fs";
|
|
2075
|
+
import * as os8 from "node:os";
|
|
2076
|
+
import * as path9 from "node:path";
|
|
2077
|
+
var HOOK_NAME = "token-goat";
|
|
2078
|
+
var PLUGIN_DIR_NAME = "token-goat";
|
|
2079
|
+
var SHIM_FILE2 = "token-goat-hook.cmd";
|
|
2080
|
+
var HOOK_TIMEOUT_SECONDS = 30;
|
|
2081
|
+
var SHIM_MARKER = "--harness antigravity";
|
|
2082
|
+
var ANTIGRAVITY_HOOK_EVENTS = ["PreToolUse", "PostToolUse"];
|
|
2083
|
+
var ANTIGRAVITY_EVENT_ARG = {
|
|
2084
|
+
PreToolUse: "pre_tool_use",
|
|
2085
|
+
PostToolUse: "post_tool_use"
|
|
2086
|
+
};
|
|
2087
|
+
var AntigravitySettingsParseError = class extends Error {
|
|
2088
|
+
};
|
|
2089
|
+
function geminiDir() {
|
|
2090
|
+
return path9.join(os8.homedir(), ".gemini");
|
|
2091
|
+
}
|
|
2092
|
+
function geminiConfigDir() {
|
|
2093
|
+
return path9.join(geminiDir(), "config");
|
|
2094
|
+
}
|
|
2095
|
+
function antigravityPluginsDir() {
|
|
2096
|
+
return path9.join(geminiConfigDir(), "plugins");
|
|
2097
|
+
}
|
|
2098
|
+
function antigravityPluginDir() {
|
|
2099
|
+
return path9.join(antigravityPluginsDir(), PLUGIN_DIR_NAME);
|
|
2100
|
+
}
|
|
2101
|
+
function antigravityHooksPath() {
|
|
2102
|
+
return path9.join(antigravityPluginDir(), "hooks.json");
|
|
2103
|
+
}
|
|
2104
|
+
function pluginManifestPath() {
|
|
2105
|
+
return path9.join(antigravityPluginDir(), "plugin.json");
|
|
2106
|
+
}
|
|
2107
|
+
function shimPath() {
|
|
2108
|
+
return path9.join(antigravityPluginDir(), SHIM_FILE2);
|
|
2109
|
+
}
|
|
2110
|
+
function readJsonObject(p, label, opts = {}) {
|
|
2111
|
+
const refuse = (problem, detail) => new AntigravitySettingsParseError(
|
|
2112
|
+
(opts.command === "uninstall" ? `Antigravity ${label} ${echoedValue(p)} is unreadable: it ${problem}. Uninstall left it and its backups untouched; fix the file and run uninstall again.` : `Antigravity ${label} ${echoedValue(p)} ${problem}. Fix or back up the file before running install.`) + (detail === void 0 ? "" : ` (${detail})`)
|
|
2113
|
+
);
|
|
2114
|
+
let raw;
|
|
2115
|
+
try {
|
|
2116
|
+
raw = fs9.readFileSync(p, "utf8");
|
|
2117
|
+
} catch (e) {
|
|
2118
|
+
const code = e.code;
|
|
2119
|
+
if (opts.strict === true && code !== "ENOENT" && code !== "ENOTDIR") throw refuse("exists but cannot be read", code ?? extractErrorMessage(e));
|
|
2120
|
+
return void 0;
|
|
2121
|
+
}
|
|
2122
|
+
let parsed;
|
|
2123
|
+
try {
|
|
2124
|
+
parsed = JSON.parse(stripBom(raw));
|
|
2125
|
+
} catch (e) {
|
|
2126
|
+
if (opts.strict === true) throw refuse("exists but contains invalid JSON", extractErrorMessage(e));
|
|
2127
|
+
return void 0;
|
|
2128
|
+
}
|
|
2129
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
2130
|
+
if (opts.strict === true) throw refuse("does not contain a JSON object at the top level");
|
|
2131
|
+
return void 0;
|
|
2132
|
+
}
|
|
2133
|
+
function shimContent() {
|
|
2134
|
+
const entryPath = process.argv[1];
|
|
2135
|
+
const escape = (value) => quoteShellPath(value).replace(/%/g, "%%");
|
|
2136
|
+
const invocation = entryPath ? `${escape(process.execPath)} ${escape(entryPath)}` : "token-goat";
|
|
2137
|
+
return `@${invocation} hook %1 ${SHIM_MARKER}\r
|
|
2138
|
+
@exit /b %ERRORLEVEL%\r
|
|
2139
|
+
`;
|
|
2140
|
+
}
|
|
2141
|
+
function antigravityHookCommand(eventArg, platform = process.platform) {
|
|
2142
|
+
if (platform === "win32") return `.\\${SHIM_FILE2} ${eventArg}`;
|
|
2143
|
+
const entryPath = process.argv[1];
|
|
2144
|
+
if (!entryPath) return `token-goat hook ${eventArg} ${SHIM_MARKER}`;
|
|
2145
|
+
return `${quoteShellPath(process.execPath)} ${quoteShellPath(entryPath)} hook ${eventArg} ${SHIM_MARKER}`;
|
|
2146
|
+
}
|
|
2147
|
+
function desiredHookEntry() {
|
|
2148
|
+
const entry = {};
|
|
2149
|
+
for (const event of ANTIGRAVITY_HOOK_EVENTS) {
|
|
2150
|
+
entry[event] = [{ matcher: "*", hooks: [{ type: "command", command: antigravityHookCommand(ANTIGRAVITY_EVENT_ARG[event]), timeout: HOOK_TIMEOUT_SECONDS }] }];
|
|
2151
|
+
}
|
|
2152
|
+
return entry;
|
|
2153
|
+
}
|
|
2154
|
+
function readShim() {
|
|
2155
|
+
try {
|
|
2156
|
+
return fs9.readFileSync(shimPath(), "utf8");
|
|
2157
|
+
} catch {
|
|
2158
|
+
return void 0;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
function installAntigravity() {
|
|
2162
|
+
const pluginDir = antigravityPluginDir();
|
|
2163
|
+
const hooksPath = antigravityHooksPath();
|
|
2164
|
+
const manifestPath = pluginManifestPath();
|
|
2165
|
+
const hooksFile = readJsonObject(hooksPath, "plugin hooks file", { strict: true, command: "install" });
|
|
2166
|
+
const manifest = readJsonObject(manifestPath, "plugin manifest", { strict: true, command: "install" });
|
|
2167
|
+
let changed = false;
|
|
2168
|
+
for (const dir of [geminiDir(), geminiConfigDir(), antigravityPluginsDir(), pluginDir]) ensureDirRecordingCreation(dir);
|
|
2169
|
+
if (manifest === void 0) {
|
|
2170
|
+
writeJsonSettings(manifestPath, { name: PLUGIN_DIR_NAME });
|
|
2171
|
+
recordCreatedConfig(manifestPath);
|
|
2172
|
+
changed = true;
|
|
2173
|
+
}
|
|
2174
|
+
if (process.platform === "win32") {
|
|
2175
|
+
const content = shimContent();
|
|
2176
|
+
const existing = readShim();
|
|
2177
|
+
if (existing !== content) {
|
|
2178
|
+
atomicWriteText(shimPath(), content);
|
|
2179
|
+
if (existing === void 0) recordCreatedConfig(shimPath());
|
|
2180
|
+
changed = true;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
const desired = desiredHookEntry();
|
|
2184
|
+
const current = hooksFile?.[HOOK_NAME];
|
|
2185
|
+
if (JSON.stringify(current) !== JSON.stringify(desired)) {
|
|
2186
|
+
writeJsonSettings(hooksPath, { ...hooksFile ?? {}, [HOOK_NAME]: desired });
|
|
2187
|
+
if (hooksFile === void 0) recordCreatedConfig(hooksPath);
|
|
2188
|
+
changed = true;
|
|
2189
|
+
}
|
|
2190
|
+
return { pluginDir, alreadyInstalled: !changed };
|
|
2191
|
+
}
|
|
2192
|
+
function uninstallAntigravity() {
|
|
2193
|
+
const pluginDir = antigravityPluginDir();
|
|
2194
|
+
const hooksPath = antigravityHooksPath();
|
|
2195
|
+
const manifestPath = pluginManifestPath();
|
|
2196
|
+
const hooksFile = readJsonObject(hooksPath, "plugin hooks file", { strict: true, command: "uninstall" });
|
|
2197
|
+
let removed = false;
|
|
2198
|
+
if (hooksFile !== void 0 && Object.prototype.hasOwnProperty.call(hooksFile, HOOK_NAME)) {
|
|
2199
|
+
const rest = { ...hooksFile };
|
|
2200
|
+
delete rest[HOOK_NAME];
|
|
2201
|
+
removed = true;
|
|
2202
|
+
if (Object.keys(rest).length === 0 && takeCreatedConfig(hooksPath)) {
|
|
2203
|
+
fs9.unlinkSync(hooksPath);
|
|
2204
|
+
} else {
|
|
2205
|
+
writeJsonSettings(hooksPath, rest);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
removeCreatedBackups(hooksPath);
|
|
2209
|
+
const shim = readShim();
|
|
2210
|
+
if (shim?.includes(SHIM_MARKER) === true) {
|
|
2211
|
+
fs9.unlinkSync(shimPath());
|
|
2212
|
+
takeCreatedConfig(shimPath());
|
|
2213
|
+
removed = true;
|
|
2214
|
+
}
|
|
2215
|
+
const manifest = readJsonObject(manifestPath, "plugin manifest");
|
|
2216
|
+
if (manifest !== void 0 && hasCreatedConfig(manifestPath) && JSON.stringify(manifest) === JSON.stringify({ name: PLUGIN_DIR_NAME }) && !fs9.existsSync(hooksPath)) {
|
|
2217
|
+
fs9.unlinkSync(manifestPath);
|
|
2218
|
+
takeCreatedConfig(manifestPath);
|
|
2219
|
+
removed = true;
|
|
2220
|
+
}
|
|
2221
|
+
removeCreatedBackups(manifestPath);
|
|
2222
|
+
for (const dir of [pluginDir, antigravityPluginsDir(), geminiConfigDir(), geminiDir()]) removeCreatedIfEmpty(dir);
|
|
2223
|
+
return removed;
|
|
2224
|
+
}
|
|
2225
|
+
function isAntigravityInstalled() {
|
|
2226
|
+
const hooksFile = readJsonObject(antigravityHooksPath(), "plugin hooks file");
|
|
2227
|
+
if (hooksFile !== void 0 && Object.prototype.hasOwnProperty.call(hooksFile, HOOK_NAME)) return true;
|
|
2228
|
+
return readShim()?.includes(SHIM_MARKER) === true;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// src/bridges/vscode_install.ts
|
|
2232
|
+
init_define_import_meta_env();
|
|
2233
|
+
import * as fs10 from "node:fs";
|
|
2234
|
+
import * as os9 from "node:os";
|
|
2235
|
+
import * as path10 from "node:path";
|
|
2236
|
+
var VSCODE_GUIDANCE_BEGIN = "<!-- token-goat-vscode-begin -->";
|
|
2237
|
+
var VSCODE_GUIDANCE_END = "<!-- token-goat-vscode-end -->";
|
|
2238
|
+
var BEGIN = VSCODE_GUIDANCE_BEGIN;
|
|
2239
|
+
var END = VSCODE_GUIDANCE_END;
|
|
2240
|
+
function vscodeUserConfigDir() {
|
|
2241
|
+
if (process.platform === "win32") {
|
|
2242
|
+
const appData = process.env["APPDATA"];
|
|
2243
|
+
const base = appData !== void 0 && appData.trim() !== "" ? appData : path10.join(os9.homedir(), "AppData", "Roaming");
|
|
2244
|
+
return path10.join(base, "Code", "User");
|
|
2245
|
+
}
|
|
2246
|
+
if (process.platform === "darwin") {
|
|
2247
|
+
return path10.join(os9.homedir(), "Library", "Application Support", "Code", "User");
|
|
2248
|
+
}
|
|
2249
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
2250
|
+
return path10.join(xdg !== void 0 && path10.isAbsolute(xdg) ? xdg : path10.join(os9.homedir(), ".config"), "Code", "User");
|
|
2251
|
+
}
|
|
2252
|
+
function vscodeUserMcpPath() {
|
|
2253
|
+
return path10.join(vscodeUserConfigDir(), "mcp.json");
|
|
2254
|
+
}
|
|
2255
|
+
function vscodeProjectMcpPath(projectRoot = process.cwd()) {
|
|
2256
|
+
return path10.join(path10.resolve(projectRoot), ".vscode", "mcp.json");
|
|
2257
|
+
}
|
|
2258
|
+
function vscodeMcpPath(opts = {}) {
|
|
2259
|
+
return opts.project === true ? vscodeProjectMcpPath(opts.projectRoot) : vscodeUserMcpPath();
|
|
2260
|
+
}
|
|
2261
|
+
function otherScopeMcpPath(opts) {
|
|
2262
|
+
return opts.project === true ? vscodeUserMcpPath() : vscodeProjectMcpPath(opts.projectRoot);
|
|
2263
|
+
}
|
|
2264
|
+
function vscodeInstructionsPath(opts = {}) {
|
|
2265
|
+
return opts.project === true ? path10.join(path10.resolve(opts.projectRoot ?? process.cwd()), ".github", "copilot-instructions.md") : path10.join(os9.homedir(), ".copilot", "instructions", "token-goat.instructions.md");
|
|
2266
|
+
}
|
|
2267
|
+
var USER_INSTRUCTIONS_FRONTMATTER = "---\ndescription: 'token-goat: when to use its MCP tools and CLI instead of reading whole files'\napplyTo: '**'\n---\n";
|
|
2268
|
+
function vscodeHooksDir(opts = {}) {
|
|
2269
|
+
return opts.project === true ? path10.join(canonicalProjectRoot(path10.resolve(opts.projectRoot ?? process.cwd())), ".github", "hooks") : path10.join(os9.homedir(), ".copilot", "hooks");
|
|
2270
|
+
}
|
|
2271
|
+
function vscodeScopeFromFlags(flags) {
|
|
2272
|
+
return { project: flags.user !== true };
|
|
2273
|
+
}
|
|
2274
|
+
function vscodeHooksInstalled(opts = {}) {
|
|
2275
|
+
return readCopilotHooksOwners(vscodeHooksDir(opts)).has("vscode");
|
|
2276
|
+
}
|
|
2277
|
+
var CLAUDE_HOOKS_SETTING = "chat.useClaudeHooks";
|
|
2278
|
+
function vscodeUserSettingsPath() {
|
|
2279
|
+
return path10.join(vscodeUserConfigDir(), "settings.json");
|
|
2280
|
+
}
|
|
2281
|
+
function vscodeUsesClaudeHooks(settingsPath = vscodeUserSettingsPath()) {
|
|
2282
|
+
return readSettingsObject(settingsPath)?.[CLAUDE_HOOKS_SETTING] === true;
|
|
2283
|
+
}
|
|
2284
|
+
function vscodeRunsTokenGoatClaudeHooks(settingsPath = vscodeUserSettingsPath()) {
|
|
2285
|
+
return vscodeUsesClaudeHooks(settingsPath) && claudeHooksInstalledAnyScope();
|
|
2286
|
+
}
|
|
2287
|
+
function readSettingsObject(settingsPath) {
|
|
2288
|
+
try {
|
|
2289
|
+
const prev = parseJsonOrJsonc(stripBom(fs10.readFileSync(settingsPath, "utf8")), { allowTrailingComma: true });
|
|
2290
|
+
return prev === null || typeof prev !== "object" || Array.isArray(prev) ? null : prev;
|
|
2291
|
+
} catch {
|
|
2292
|
+
return null;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
function disableVscodeClaudeHooks(settingsPath = vscodeUserSettingsPath()) {
|
|
2296
|
+
const prev = readSettingsObject(settingsPath);
|
|
2297
|
+
if (prev?.[CLAUDE_HOOKS_SETTING] !== true) return false;
|
|
2298
|
+
withInstallScope(void 0, () => {
|
|
2299
|
+
writeSettingsKeepingComments(settingsPath, { ...prev, [CLAUDE_HOOKS_SETTING]: false }, { allowTrailingComma: true });
|
|
2300
|
+
});
|
|
2301
|
+
recordTurnedOffSetting(settingsPath, CLAUDE_HOOKS_SETTING);
|
|
2302
|
+
return true;
|
|
2303
|
+
}
|
|
2304
|
+
function restoreVscodeClaudeHooks(settingsPath = vscodeUserSettingsPath()) {
|
|
2305
|
+
if (!takeTurnedOffSetting(settingsPath, CLAUDE_HOOKS_SETTING)) return false;
|
|
2306
|
+
const current = readSettingsObject(settingsPath);
|
|
2307
|
+
if (current === null || current[CLAUDE_HOOKS_SETTING] !== false) return false;
|
|
2308
|
+
try {
|
|
2309
|
+
withInstallScope(void 0, () => {
|
|
2310
|
+
writeSettingsKeepingComments(settingsPath, { ...current, [CLAUDE_HOOKS_SETTING]: true }, { allowTrailingComma: true });
|
|
2311
|
+
});
|
|
2312
|
+
} catch (e) {
|
|
2313
|
+
recordTurnedOffSetting(settingsPath, CLAUDE_HOOKS_SETTING);
|
|
2314
|
+
throw e;
|
|
2315
|
+
}
|
|
2316
|
+
return true;
|
|
2317
|
+
}
|
|
2318
|
+
function readConfig(filePath) {
|
|
2319
|
+
return readServersJson(filePath, "VS Code");
|
|
2320
|
+
}
|
|
2321
|
+
var updateConfig = setTokenGoatServer;
|
|
2322
|
+
function otherScopeHasManagedServer(opts) {
|
|
2323
|
+
const otherPath = otherScopeMcpPath(opts);
|
|
2324
|
+
if (opts.project !== true && !projectPathIsConsultable(otherPath, path10.resolve(opts.projectRoot ?? process.cwd()))) return false;
|
|
2325
|
+
return hasManagedServer(otherPath, "VS Code");
|
|
2326
|
+
}
|
|
2327
|
+
function vscodeDecoderConfigured(opts = {}) {
|
|
2328
|
+
const checkedPaths = [vscodeUserMcpPath()];
|
|
2329
|
+
if (opts.projectRoot !== void 0) {
|
|
2330
|
+
const projectPath = vscodeProjectMcpPath(opts.projectRoot);
|
|
2331
|
+
if (projectPathIsConsultable(projectPath, path10.resolve(opts.projectRoot))) checkedPaths.push(projectPath);
|
|
2332
|
+
}
|
|
2333
|
+
return { configured: checkedPaths.some((candidate) => hasManagedServer(candidate, "VS Code")), checkedPaths };
|
|
2334
|
+
}
|
|
2335
|
+
function writeGuidance(filePath, userScope) {
|
|
2336
|
+
let created = false;
|
|
2337
|
+
if (userScope && !fs10.existsSync(filePath)) {
|
|
2338
|
+
ensureDirSync(path10.dirname(filePath));
|
|
2339
|
+
atomicWriteText(filePath, USER_INSTRUCTIONS_FRONTMATTER);
|
|
2340
|
+
created = true;
|
|
2341
|
+
}
|
|
2342
|
+
const body = [
|
|
2343
|
+
BEGIN,
|
|
2344
|
+
buildGuidanceBody("VS Code\u2019s supported MCP integration and its built-in file-read tools", { gdrive: loadConfig().gdrive?.enabled ?? false }),
|
|
2345
|
+
"",
|
|
2346
|
+
"**Compressed payloads:** a message containing a token-goat payload block (recognizable by a `recovery: token-goat retrieve <id>` line) is compressed text, not an answer. Call the MCP tool `retrieve_text` with that id to recover the original text, then answer the question the message asks using the recovered text. Never present the raw payload to the user as the response; if the `retrieve_text` tool is unavailable (the MCP server is not running, or the chat is not in Agent mode), say so plainly and ask the user to switch to Agent mode or run `token-goat install --vscode`.",
|
|
2347
|
+
"",
|
|
2348
|
+
"VS Code support: token-goat install --vscode configures a stdio MCP server under the servers root key in your user-profile mcp.json by default (add --project for the workspace .vscode/mcp.json instead), and agent hooks that see VS Code\u2019s built-in tool calls. The hooks can deny a repeated read, add a hint, and shrink an image before view_image loads it; they cannot fold or trim what a built-in read returns, and they leave terminal commands unchanged.",
|
|
2349
|
+
END
|
|
2350
|
+
].join("\n");
|
|
2351
|
+
return upsertDelimitedBlock(filePath, BEGIN, END, body) || created;
|
|
2352
|
+
}
|
|
2353
|
+
function assertProjectTargetsAreInTheProject(opts) {
|
|
2354
|
+
if (opts.project !== true) return;
|
|
2355
|
+
const root = path10.resolve(opts.projectRoot ?? process.cwd());
|
|
2356
|
+
for (const target of [vscodeMcpPath(opts), vscodeInstructionsPath(opts), ...copilotHooksFilePaths(vscodeHooksDir(opts))]) {
|
|
2357
|
+
assertProjectScopeTarget(target, root);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
function installVscode(opts = {}) {
|
|
2361
|
+
return withInstallScope(projectScopeRoot(opts), () => installVscodeScoped(opts));
|
|
2362
|
+
}
|
|
2363
|
+
function installVscodeScoped(opts) {
|
|
2364
|
+
assertProjectTargetsAreInTheProject(opts);
|
|
2365
|
+
const scope = opts.project === true ? "project" : "user";
|
|
2366
|
+
const mcpPath = vscodeMcpPath(opts);
|
|
2367
|
+
const instructionsPath = vscodeInstructionsPath(opts);
|
|
2368
|
+
let migratedFromUserScope = false;
|
|
2369
|
+
if (scope === "project" && (otherScopeHasManagedServer(opts) || vscodeHooksInstalled())) {
|
|
2370
|
+
migratedFromUserScope = uninstallVscode({ keepBackups: true });
|
|
2371
|
+
} else if (otherScopeHasManagedServer(opts)) {
|
|
2372
|
+
const otherPath = otherScopeMcpPath(opts);
|
|
2373
|
+
throw new Error(
|
|
2374
|
+
`token-goat is already registered in VS Code project scope (${otherPath}). Installing into user scope too would register it twice and duplicate its tool schemas in this workspace. Run \`token-goat uninstall --vscode --project\` first if you want to move it, or drop --vscode from this run.`
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
const config = readConfig(mcpPath);
|
|
2378
|
+
const existingServers = config.value["servers"];
|
|
2379
|
+
if (existingServers !== void 0 && (existingServers === null || typeof existingServers !== "object" || Array.isArray(existingServers))) {
|
|
2380
|
+
throw new Error(`malformed VS Code MCP JSON at ${mcpPath}: servers must be an object`);
|
|
2381
|
+
}
|
|
2382
|
+
const servers = existingServers ?? {};
|
|
2383
|
+
const current = servers["token-goat"];
|
|
2384
|
+
if (current !== void 0 && !isManagedServer(current)) {
|
|
2385
|
+
throw new Error(`VS Code MCP JSON at ${mcpPath} already has a non-token-goat-managed server named "token-goat"`);
|
|
2386
|
+
}
|
|
2387
|
+
const next = updateConfig(config.text, managedServer());
|
|
2388
|
+
const mcpExisted = fs10.existsSync(mcpPath);
|
|
2389
|
+
ensureDirSync(path10.dirname(mcpPath));
|
|
2390
|
+
if (config.text !== next) {
|
|
2391
|
+
backupFile(mcpPath);
|
|
2392
|
+
writeConfigText(mcpPath, next);
|
|
2393
|
+
noteRootKeyCreation(mcpPath, config);
|
|
2394
|
+
if (!mcpExisted) recordCreatedConfig(mcpPath);
|
|
2395
|
+
}
|
|
2396
|
+
const guidanceDir = path10.dirname(instructionsPath);
|
|
2397
|
+
const guidanceDirExisted = fs10.existsSync(guidanceDir);
|
|
2398
|
+
const guidanceExisted = fs10.existsSync(instructionsPath);
|
|
2399
|
+
const guidanceChanged = writeGuidance(instructionsPath, scope === "user");
|
|
2400
|
+
if (scope === "project") {
|
|
2401
|
+
if (!guidanceDirExisted && fs10.existsSync(guidanceDir)) recordCreatedConfig(guidanceDir);
|
|
2402
|
+
if (!guidanceExisted && fs10.existsSync(instructionsPath)) recordCreatedConfig(instructionsPath);
|
|
2403
|
+
}
|
|
2404
|
+
if (scope === "project") syncVisualStudioProjectGuidance(instructionsPath);
|
|
2405
|
+
const hooks = installCopilotHooksFile(vscodeHooksDir(opts), "vscode");
|
|
2406
|
+
let disabledClaudeHooks = false;
|
|
2407
|
+
if (scope === "user" && vscodeRunsTokenGoatClaudeHooks()) {
|
|
2408
|
+
disabledClaudeHooks = disableVscodeClaudeHooks();
|
|
2409
|
+
}
|
|
2410
|
+
return {
|
|
2411
|
+
mcpPath,
|
|
2412
|
+
instructionsPath,
|
|
2413
|
+
hooksConfigPath: hooks.configPath,
|
|
2414
|
+
alreadyInstalled: config.text === next && !guidanceChanged && !hooks.changed && !migratedFromUserScope && !disabledClaudeHooks,
|
|
2415
|
+
scope,
|
|
2416
|
+
migratedFromUserScope,
|
|
2417
|
+
disabledClaudeHooks
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
function uninstallVscode(opts = {}) {
|
|
2421
|
+
return withInstallScope(projectScopeRoot(opts), () => uninstallVscodeScoped(opts));
|
|
2422
|
+
}
|
|
2423
|
+
function uninstallVscodeScoped(opts) {
|
|
2424
|
+
assertProjectTargetsAreInTheProject(opts);
|
|
2425
|
+
const mcpPath = vscodeMcpPath(opts);
|
|
2426
|
+
let removed = false;
|
|
2427
|
+
if (fs10.existsSync(mcpPath)) {
|
|
2428
|
+
const config = readConfig(mcpPath);
|
|
2429
|
+
const servers = config.value["servers"];
|
|
2430
|
+
if (servers !== void 0 && (servers === null || typeof servers !== "object" || Array.isArray(servers))) {
|
|
2431
|
+
throw new Error(`malformed VS Code MCP JSON at ${mcpPath}: servers must be an object`);
|
|
2432
|
+
}
|
|
2433
|
+
if (servers && isManagedServer(servers["token-goat"])) {
|
|
2434
|
+
const next = dropEmptyServers(updateConfig(config.text, void 0), mcpPath);
|
|
2435
|
+
if (/^\s*\{\s*\}\s*$/.test(next) && takeCreatedConfig(mcpPath)) {
|
|
2436
|
+
removeFileInScope(mcpPath);
|
|
2437
|
+
if (opts.project === true) {
|
|
2438
|
+
removeEmptyDirInScope(path10.dirname(mcpPath));
|
|
2439
|
+
}
|
|
2440
|
+
} else {
|
|
2441
|
+
backupFile(mcpPath);
|
|
2442
|
+
writeConfigText(mcpPath, next);
|
|
2443
|
+
}
|
|
2444
|
+
if (opts.keepBackups !== true) removeCreatedBackups(mcpPath);
|
|
2445
|
+
removed = true;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
const instructionsPath = vscodeInstructionsPath(opts);
|
|
2449
|
+
if (stripDelimitedBlock(instructionsPath, BEGIN, END, opts.keepBackups === true)) {
|
|
2450
|
+
removed = true;
|
|
2451
|
+
if (opts.project !== true && fs10.readFileSync(instructionsPath, "utf8").trim() === USER_INSTRUCTIONS_FRONTMATTER.trim()) removeFileInScope(instructionsPath);
|
|
2452
|
+
}
|
|
2453
|
+
if (opts.project === true) syncVisualStudioProjectGuidance(instructionsPath);
|
|
2454
|
+
if (releaseCopilotHooksFile(vscodeHooksDir(opts), "vscode", opts.keepBackups === true)) removed = true;
|
|
2455
|
+
if (opts.project === true) {
|
|
2456
|
+
removeCreatedIfEmpty(instructionsPath);
|
|
2457
|
+
removeCreatedIfEmpty(path10.dirname(instructionsPath));
|
|
2458
|
+
}
|
|
2459
|
+
return removed;
|
|
2460
|
+
}
|
|
2461
|
+
function cleanupDeprecatedVscodeProjectMcp(projectRoot) {
|
|
2462
|
+
const root = path10.resolve(projectRoot ?? process.cwd());
|
|
2463
|
+
return withInstallScope(root, () => {
|
|
2464
|
+
const projectMcp = path10.join(root, ".vscode", "mcp.json");
|
|
2465
|
+
if (!fs10.existsSync(projectMcp)) return false;
|
|
2466
|
+
const text = fs10.readFileSync(projectMcp, "utf8");
|
|
2467
|
+
if (isResidueServersJson(text) && takeCreatedConfig(projectMcp)) {
|
|
2468
|
+
removeFileInScope(projectMcp);
|
|
2469
|
+
removeEmptyDirInScope(path10.dirname(projectMcp));
|
|
2470
|
+
removeCreatedBackups(projectMcp);
|
|
2471
|
+
return true;
|
|
2472
|
+
}
|
|
2473
|
+
return false;
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// src/cli_doctor_platforms.ts
|
|
2478
|
+
init_define_import_meta_env();
|
|
2479
|
+
import * as fs13 from "node:fs";
|
|
2480
|
+
import * as path13 from "node:path";
|
|
2481
|
+
|
|
2482
|
+
// src/bridges/cursor_install.ts
|
|
2483
|
+
init_define_import_meta_env();
|
|
2484
|
+
import * as fs11 from "node:fs";
|
|
2485
|
+
import * as os10 from "node:os";
|
|
2486
|
+
import * as path11 from "node:path";
|
|
2487
|
+
var MCP_SERVERS_KEY = "mcpServers";
|
|
2488
|
+
var TOKEN_GOAT_ENTRY_KEY = "token-goat";
|
|
2489
|
+
function cursorUserConfigDir() {
|
|
2490
|
+
return path11.join(os10.homedir(), ".cursor");
|
|
2491
|
+
}
|
|
2492
|
+
function cursorMcpPath(opts = {}) {
|
|
2493
|
+
if (opts.project === true) {
|
|
2494
|
+
return path11.join(opts.projectRoot ?? process.cwd(), ".cursor", "mcp.json");
|
|
2495
|
+
}
|
|
2496
|
+
return path11.join(cursorUserConfigDir(), "mcp.json");
|
|
2497
|
+
}
|
|
2498
|
+
function cursorManagedServer() {
|
|
2499
|
+
return { command: process.execPath, args: [bundledCliPath(), "mcp-serve"] };
|
|
2500
|
+
}
|
|
2501
|
+
function isCursorManagedServer(value) {
|
|
2502
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
2503
|
+
const entry = value;
|
|
2504
|
+
const args = entry["args"];
|
|
2505
|
+
return entry["command"] === process.execPath && Array.isArray(args) && args.length === 2 && typeof args[0] === "string" && path11.basename(args[0]).toLowerCase() === "token-goat.mjs" && args[1] === "mcp-serve";
|
|
2506
|
+
}
|
|
2507
|
+
function installCursor(opts = {}) {
|
|
2508
|
+
return withInstallScope(projectScopeRoot(opts), () => installCursorScoped(opts));
|
|
2509
|
+
}
|
|
2510
|
+
function installCursorScoped(opts) {
|
|
2511
|
+
const scope = opts.project === true ? "project" : "user";
|
|
2512
|
+
const mcpPath = cursorMcpPath(opts);
|
|
2513
|
+
const config = readServersJson(mcpPath, "Cursor");
|
|
2514
|
+
const current = serversOf(config, mcpPath, "Cursor", MCP_SERVERS_KEY)[TOKEN_GOAT_ENTRY_KEY];
|
|
2515
|
+
if (current !== void 0 && !isCursorManagedServer(current)) {
|
|
2516
|
+
throw new Error(`Cursor mcp.json already has a ${echoedValue(TOKEN_GOAT_ENTRY_KEY)} MCP server entry token-goat did not write, at ${mcpPath}; remove it manually first`);
|
|
2517
|
+
}
|
|
2518
|
+
const desired = cursorManagedServer();
|
|
2519
|
+
const nextText = setTokenGoatServer(config.text, desired, MCP_SERVERS_KEY);
|
|
2520
|
+
const alreadyInstalled = config.text === nextText;
|
|
2521
|
+
if (!alreadyInstalled) {
|
|
2522
|
+
recordCreatedBy([mcpPath], () => {
|
|
2523
|
+
ensureDirSync(path11.dirname(mcpPath));
|
|
2524
|
+
backupFile(mcpPath);
|
|
2525
|
+
writeConfigText(mcpPath, nextText);
|
|
2526
|
+
noteRootKeyCreation(mcpPath, config, MCP_SERVERS_KEY);
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
return { mcpPath, alreadyInstalled, scope };
|
|
2530
|
+
}
|
|
2531
|
+
function uninstallCursor(opts = {}) {
|
|
2532
|
+
return withInstallScope(projectScopeRoot(opts), () => uninstallCursorScoped(opts));
|
|
2533
|
+
}
|
|
2534
|
+
function uninstallCursorScoped(opts) {
|
|
2535
|
+
const mcpPath = cursorMcpPath(opts);
|
|
2536
|
+
if (!fs11.existsSync(mcpPath)) return false;
|
|
2537
|
+
const config = readServersJson(mcpPath, "Cursor");
|
|
2538
|
+
if (!isCursorManagedServer(serversOf(config, mcpPath, "Cursor", MCP_SERVERS_KEY)[TOKEN_GOAT_ENTRY_KEY])) return false;
|
|
2539
|
+
const next = dropEmptyServers(setTokenGoatServer(config.text, void 0, MCP_SERVERS_KEY), mcpPath, MCP_SERVERS_KEY);
|
|
2540
|
+
if (/^\s*\{\s*\}\s*$/.test(next) && takeCreatedConfig(mcpPath)) {
|
|
2541
|
+
removeFileInScope(mcpPath);
|
|
2542
|
+
} else {
|
|
2543
|
+
backupFile(mcpPath);
|
|
2544
|
+
writeConfigText(mcpPath, next);
|
|
2545
|
+
}
|
|
2546
|
+
removeCreatedBackups(mcpPath);
|
|
2547
|
+
removeCreatedTree([mcpPath]);
|
|
2548
|
+
return true;
|
|
2549
|
+
}
|
|
2550
|
+
function isCursorInstalled(opts = {}) {
|
|
2551
|
+
return hasManagedServer(cursorMcpPath(opts), "Cursor", MCP_SERVERS_KEY, isCursorManagedServer);
|
|
2552
|
+
}
|
|
2553
|
+
function cursorManagedEntry(mcpPath) {
|
|
2554
|
+
const entry = managedServerEntry(mcpPath, "Cursor", MCP_SERVERS_KEY, isCursorManagedServer);
|
|
2555
|
+
return entry === null ? null : { command: entry["command"] };
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// src/bridges/zed_install.ts
|
|
2559
|
+
init_define_import_meta_env();
|
|
2560
|
+
import * as fs12 from "node:fs";
|
|
2561
|
+
import * as os11 from "node:os";
|
|
2562
|
+
import * as path12 from "node:path";
|
|
2563
|
+
var CONTEXT_SERVERS_KEY = "context_servers";
|
|
2564
|
+
var TOKEN_GOAT_ENTRY_KEY2 = "token-goat";
|
|
2565
|
+
var ZED_TIMEOUT_MS = 3e4;
|
|
2566
|
+
function zedConfigDir() {
|
|
2567
|
+
if (process.platform === "win32") {
|
|
2568
|
+
const appData = process.env["APPDATA"];
|
|
2569
|
+
const base2 = appData !== void 0 && appData.trim() !== "" ? appData : path12.join(os11.homedir(), "AppData", "Roaming");
|
|
2570
|
+
return path12.join(base2, "Zed");
|
|
2571
|
+
}
|
|
2572
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"];
|
|
2573
|
+
const base = xdgConfig !== void 0 && xdgConfig.trim() !== "" ? xdgConfig : path12.join(os11.homedir(), ".config");
|
|
2574
|
+
return path12.join(base, "zed");
|
|
2575
|
+
}
|
|
2576
|
+
function zedSettingsPath() {
|
|
2577
|
+
return path12.join(zedConfigDir(), "settings.json");
|
|
2578
|
+
}
|
|
2579
|
+
function zedShimPath() {
|
|
2580
|
+
return path12.join(zedConfigDir(), process.platform === "win32" ? "token-goat-mcp.cmd" : "token-goat-mcp.sh");
|
|
2581
|
+
}
|
|
2582
|
+
function buildShimScript() {
|
|
2583
|
+
const nodePath = process.execPath;
|
|
2584
|
+
const cliPath = bundledCliPath();
|
|
2585
|
+
if (process.platform === "win32") {
|
|
2586
|
+
return `@echo off\r
|
|
2587
|
+
"${nodePath}" "${cliPath}" mcp-serve\r
|
|
2588
|
+
`;
|
|
2589
|
+
}
|
|
2590
|
+
return `#!/bin/sh
|
|
2591
|
+
exec "${nodePath}" "${cliPath}" mcp-serve
|
|
2592
|
+
`;
|
|
2593
|
+
}
|
|
2594
|
+
function zedManagedServer() {
|
|
2595
|
+
return { command: zedShimPath(), timeout: ZED_TIMEOUT_MS };
|
|
2596
|
+
}
|
|
2597
|
+
function isZedManagedServer(value) {
|
|
2598
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
2599
|
+
const entry = value;
|
|
2600
|
+
return entry["command"] === zedShimPath() && typeof entry["timeout"] === "number";
|
|
2601
|
+
}
|
|
2602
|
+
function installZed() {
|
|
2603
|
+
return recordCreatedBy([zedSettingsPath(), zedShimPath()], installZedFiles);
|
|
2604
|
+
}
|
|
2605
|
+
function installZedFiles() {
|
|
2606
|
+
const settingsPath = zedSettingsPath();
|
|
2607
|
+
const shimPath2 = zedShimPath();
|
|
2608
|
+
const config = readServersJson(settingsPath, "Zed");
|
|
2609
|
+
const current = serversOf(config, settingsPath, "Zed", CONTEXT_SERVERS_KEY)[TOKEN_GOAT_ENTRY_KEY2];
|
|
2610
|
+
if (current !== void 0 && !isZedManagedServer(current)) {
|
|
2611
|
+
throw new Error(`Zed settings.json already has a ${echoedValue(TOKEN_GOAT_ENTRY_KEY2)} context server entry token-goat did not write, at ${settingsPath}; remove it manually first`);
|
|
2612
|
+
}
|
|
2613
|
+
const priorShim = snapshotShim(shimPath2);
|
|
2614
|
+
const shimChanged = writeIfDifferent(shimPath2, buildShimScript());
|
|
2615
|
+
if (process.platform !== "win32") fs12.chmodSync(shimPath2, 493);
|
|
2616
|
+
const desired = zedManagedServer();
|
|
2617
|
+
const alreadyInstalled = current !== void 0 && !shimChanged;
|
|
2618
|
+
if (!alreadyInstalled) {
|
|
2619
|
+
try {
|
|
2620
|
+
const nextText = setTokenGoatServer(config.text, desired, CONTEXT_SERVERS_KEY);
|
|
2621
|
+
backupFile(settingsPath);
|
|
2622
|
+
writeConfigText(settingsPath, nextText);
|
|
2623
|
+
} catch (err) {
|
|
2624
|
+
try {
|
|
2625
|
+
restoreShim(shimPath2, priorShim, shimChanged);
|
|
2626
|
+
} catch {
|
|
2627
|
+
}
|
|
2628
|
+
throw err;
|
|
2629
|
+
}
|
|
2630
|
+
noteRootKeyCreation(settingsPath, config, CONTEXT_SERVERS_KEY);
|
|
2631
|
+
}
|
|
2632
|
+
return { settingsPath, shimPath: shimPath2, alreadyInstalled };
|
|
2633
|
+
}
|
|
2634
|
+
function snapshotShim(shimPath2) {
|
|
2635
|
+
let mode;
|
|
2636
|
+
try {
|
|
2637
|
+
mode = fs12.statSync(shimPath2).mode & 511;
|
|
2638
|
+
} catch (err) {
|
|
2639
|
+
if (err.code === "ENOENT") return { existed: false, bytes: void 0, mode: void 0 };
|
|
2640
|
+
}
|
|
2641
|
+
let bytes;
|
|
2642
|
+
try {
|
|
2643
|
+
bytes = fs12.readFileSync(shimPath2);
|
|
2644
|
+
} catch {
|
|
2645
|
+
bytes = void 0;
|
|
2646
|
+
}
|
|
2647
|
+
return { existed: true, bytes, mode };
|
|
2648
|
+
}
|
|
2649
|
+
function restoreShim(shimPath2, prior, shimChanged) {
|
|
2650
|
+
if (!prior.existed) {
|
|
2651
|
+
fs12.rmSync(shimPath2, { force: true });
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
if (shimChanged && prior.bytes !== void 0) atomicWriteBytes(shimPath2, prior.bytes);
|
|
2655
|
+
if (prior.mode !== void 0 && process.platform !== "win32") fs12.chmodSync(shimPath2, prior.mode);
|
|
2656
|
+
}
|
|
2657
|
+
function uninstallZed() {
|
|
2658
|
+
const settingsPath = zedSettingsPath();
|
|
2659
|
+
const shimPath2 = zedShimPath();
|
|
2660
|
+
let entryRemoved = false;
|
|
2661
|
+
let shimRemoved;
|
|
2662
|
+
try {
|
|
2663
|
+
if (fs12.existsSync(settingsPath)) {
|
|
2664
|
+
const config = readServersJson(settingsPath, "Zed");
|
|
2665
|
+
if (isZedManagedServer(serversOf(config, settingsPath, "Zed", CONTEXT_SERVERS_KEY)[TOKEN_GOAT_ENTRY_KEY2])) {
|
|
2666
|
+
const next = dropEmptyServers(setTokenGoatServer(config.text, void 0, CONTEXT_SERVERS_KEY), settingsPath, CONTEXT_SERVERS_KEY);
|
|
2667
|
+
if (/^\s*\{\s*\}\s*$/.test(next) && takeCreatedConfig(settingsPath)) {
|
|
2668
|
+
fs12.rmSync(settingsPath, { force: true });
|
|
2669
|
+
} else {
|
|
2670
|
+
backupFile(settingsPath);
|
|
2671
|
+
writeConfigText(settingsPath, next);
|
|
2672
|
+
}
|
|
2673
|
+
removeCreatedBackups(settingsPath);
|
|
2674
|
+
entryRemoved = true;
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
} finally {
|
|
2678
|
+
shimRemoved = fs12.existsSync(shimPath2);
|
|
2679
|
+
if (shimRemoved) fs12.rmSync(shimPath2, { force: true });
|
|
2680
|
+
removeCreatedTree([settingsPath, shimPath2]);
|
|
2681
|
+
}
|
|
2682
|
+
return entryRemoved || shimRemoved;
|
|
2683
|
+
}
|
|
2684
|
+
function isZedInstalled() {
|
|
2685
|
+
return hasManagedServer(zedSettingsPath(), "Zed", CONTEXT_SERVERS_KEY, isZedManagedServer);
|
|
2686
|
+
}
|
|
2687
|
+
function zedManagedEntry(settingsPath) {
|
|
2688
|
+
const entry = managedServerEntry(settingsPath, "Zed", CONTEXT_SERVERS_KEY, isZedManagedServer);
|
|
2689
|
+
return entry === null ? null : { command: entry["command"] };
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// src/cli_doctor_platforms.ts
|
|
2693
|
+
function globalMcpConfigPath() {
|
|
2694
|
+
return copilotMcpConfigPath();
|
|
2695
|
+
}
|
|
2696
|
+
function checkGlobalMcpConfig(configPath = globalMcpConfigPath()) {
|
|
2697
|
+
if (!fs13.existsSync(configPath)) {
|
|
2698
|
+
return { name: "Global MCP configuration", status: "ok", message: `no global Copilot MCP configuration found at ${displaySafeText(configPath)}` };
|
|
2699
|
+
}
|
|
2700
|
+
let parsed;
|
|
2701
|
+
try {
|
|
2702
|
+
parsed = JSON.parse(stripBom(fs13.readFileSync(configPath, "utf8")));
|
|
2703
|
+
} catch {
|
|
2704
|
+
return {
|
|
2705
|
+
name: "Global MCP configuration",
|
|
2706
|
+
status: "warn",
|
|
2707
|
+
message: `could not read global Copilot MCP configuration at ${displaySafeText(configPath)}; unable to audit heavy launchers.`
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2711
|
+
return {
|
|
2712
|
+
name: "Global MCP configuration",
|
|
2713
|
+
status: "warn",
|
|
2714
|
+
message: `global Copilot MCP configuration at ${displaySafeText(configPath)} has an unsupported format; unable to audit heavy launchers.`
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
const configuredServers = parsed["mcpServers"];
|
|
2718
|
+
if (typeof configuredServers !== "object" || configuredServers === null || Array.isArray(configuredServers)) {
|
|
2719
|
+
return { name: "Global MCP configuration", status: "ok", message: `no global stdio MCP servers configured at ${displaySafeText(configPath)}` };
|
|
2720
|
+
}
|
|
2721
|
+
let chromeDevTools = 0;
|
|
2722
|
+
let playwright = 0;
|
|
2723
|
+
for (const server of Object.values(configuredServers)) {
|
|
2724
|
+
if (typeof server !== "object" || server === null || Array.isArray(server)) continue;
|
|
2725
|
+
const entry = server;
|
|
2726
|
+
const rawCommand = entry["command"];
|
|
2727
|
+
const command = typeof rawCommand === "string" ? rawCommand : "";
|
|
2728
|
+
const args = entry["args"];
|
|
2729
|
+
if (!/\bnpx(?:\.cmd)?\b/i.test(command) || !Array.isArray(args)) continue;
|
|
2730
|
+
const invocation = args.filter((arg) => typeof arg === "string").join(" ");
|
|
2731
|
+
if (/\bchrome-devtools-mcp\b/i.test(invocation)) chromeDevTools += 1;
|
|
2732
|
+
if (/@playwright[\\/]mcp\b/i.test(invocation)) playwright += 1;
|
|
2733
|
+
}
|
|
2734
|
+
if (chromeDevTools > 0 || playwright > 0) {
|
|
2735
|
+
const launchers = [];
|
|
2736
|
+
if (chromeDevTools > 0) launchers.push(`${chromeDevTools} Chrome DevTools MCP launcher${chromeDevTools === 1 ? "" : "s"}`);
|
|
2737
|
+
if (playwright > 0) launchers.push(`${playwright} Playwright MCP launcher${playwright === 1 ? "" : "s"}`);
|
|
2738
|
+
return {
|
|
2739
|
+
name: "Global MCP configuration",
|
|
2740
|
+
status: "warn",
|
|
2741
|
+
message: `${launchers.join(" and ")} configured at ${displaySafeText(configPath)}. Move heavy launchers to project scope or remove them when not actively needed.`
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
return { name: "Global MCP configuration", status: "ok", message: `no known heavy global MCP launchers configured at ${displaySafeText(configPath)}` };
|
|
2745
|
+
}
|
|
2746
|
+
var VSCODE_USER_SCOPE_MIGRATED_NOTE = "Moved the VS Code integration from user scope to this project. It used to live in ~/.copilot/hooks, where VS Code resolves its working directory to the FIRST folder of a multi-root workspace and nothing else, so read hints, image shrinking and edit interception were silently doing nothing for every other folder. The shared ~/.copilot/hooks files stay in place if `token-goat install --copilot` still needs them.";
|
|
2747
|
+
var VSCODE_PROJECT_SCOPE_COVERAGE_NOTE = "This covers this project only. Run `token-goat install --vscode` once in each project you want it in, or `token-goat install --vscode --user` for one install covering every project (single-root workspaces only \u2014 see below).";
|
|
2748
|
+
var VSCODE_USER_SCOPE_MULTIROOT_NOTE = "NOTE: a user-scope install works in every project, but VS Code runs it with the first folder of a multi-root workspace as its working directory, so it does nothing for the other folders. Use `token-goat install --vscode` (project scope, the default) in each folder that needs it.";
|
|
2749
|
+
function checkVscodeUserScopeHooks(userScope, projectScope) {
|
|
2750
|
+
if (!userScope) return null;
|
|
2751
|
+
return {
|
|
2752
|
+
name: "VS Code hooks scope",
|
|
2753
|
+
status: "warn",
|
|
2754
|
+
message: projectScope ? "token-goat VS Code hooks are installed in BOTH ~/.copilot/hooks and this project's .github/hooks, and VS Code runs every hooks file it finds, so each hook fires twice. The user-scope copy is also pinned to the first folder of a multi-root workspace. Run `token-goat uninstall --vscode --user` to keep only the project install." : "token-goat VS Code hooks are installed in user scope (~/.copilot/hooks). VS Code runs them with the FIRST folder of a multi-root workspace as their working directory, so read hints, image shrinking and edit interception do nothing for any other folder. Run `token-goat install --vscode` in each project to move it to project scope."
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
var VSCODE_DOUBLE_FIRE_NOTE = 'NOTE: VS Code has chat.useClaudeHooks turned on, so it also runs the token-goat hooks in ~/.claude/settings.json and each one fires twice. token-goat left it alone because it is a user-wide VS Code setting, and turning it off also stops every other Claude hook you have in VS Code. If you see hooks run twice, set "chat.useClaudeHooks": false in your VS Code user settings.';
|
|
2758
|
+
var VSCODE_CLAUDE_HOOKS_TURNED_OFF_TEXT = "Turned off chat.useClaudeHooks in your VS Code user settings so token-goat hooks do not run twice. That setting covers every Claude hook in VS Code, not only the token-goat ones. To turn it back on, set " + fencedCommand('"chat.useClaudeHooks": true') + " in VS Code settings, or run " + fencedCommand("token-goat uninstall --vscode --user") + " to put it back.";
|
|
2759
|
+
var VSCODE_CLAUDE_HOOKS_TURNED_OFF_NOTE = `NOTE: ${VSCODE_CLAUDE_HOOKS_TURNED_OFF_TEXT}`;
|
|
2760
|
+
var VSCODE_CLAUDE_HOOKS_RESTORED_NOTE = "Turned chat.useClaudeHooks back on in your VS Code user settings, as it was before token-goat turned it off.";
|
|
2761
|
+
function checkVscodeClaudeHooks(useClaudeHooks, claudeHooksInstalled, vscodeHooks) {
|
|
2762
|
+
if (!useClaudeHooks || !claudeHooksInstalled) return null;
|
|
2763
|
+
const vscodeHooksInstalled2 = vscodeHooks.user || vscodeHooks.project;
|
|
2764
|
+
if (vscodeHooks.project && !vscodeHooks.user) {
|
|
2765
|
+
return {
|
|
2766
|
+
name: "VS Code hooks",
|
|
2767
|
+
status: "ok",
|
|
2768
|
+
message: `VS Code has chat.useClaudeHooks on and token-goat hooks are in both ~/.claude/settings.json and this project's .github/hooks, so each hook fires twice in VS Code. token-goat leaves the setting alone for a project-scope install because it is user-wide and also stops every other Claude hook you have in VS Code. If you see hooks run twice, set "chat.useClaudeHooks": false in your VS Code user settings.`
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
return {
|
|
2772
|
+
name: "VS Code hooks",
|
|
2773
|
+
status: "warn",
|
|
2774
|
+
message: vscodeHooksInstalled2 ? "VS Code has chat.useClaudeHooks on and token-goat hooks are in both ~/.claude/settings.json and the Copilot hooks file, so each hook fires twice in VS Code. Turn chat.useClaudeHooks off in VS Code settings, or run `token-goat doctor --repair` to do it." : "VS Code has chat.useClaudeHooks on, so it runs the token-goat hooks from ~/.claude/settings.json in Claude Code wire format, which VS Code reads only in part. Run `token-goat install --vscode` and turn chat.useClaudeHooks off in VS Code settings."
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
function checkClaudeHookEvents(gaps) {
|
|
2778
|
+
const name = "Claude Code hook events";
|
|
2779
|
+
if (gaps.user === null && gaps.project === null) return null;
|
|
2780
|
+
const scopes = [
|
|
2781
|
+
{ scope: "user", events: gaps.user, run: "token-goat install" },
|
|
2782
|
+
{ scope: "project", events: gaps.project, run: "token-goat install --project" }
|
|
2783
|
+
];
|
|
2784
|
+
const missing = [];
|
|
2785
|
+
const outdated = [];
|
|
2786
|
+
const broken = [];
|
|
2787
|
+
for (const { scope, events, run } of scopes) {
|
|
2788
|
+
if (events === null) continue;
|
|
2789
|
+
if (events.broken.length > 0) broken.push(`${scope} scope wires ${events.broken.join(", ")} to a native hook client binary that no longer exists; run: ${run}`);
|
|
2790
|
+
if (events.missing.length > 0) missing.push(`${scope} scope lacks ${events.missing.join(", ")}; run: ${run}`);
|
|
2791
|
+
if (events.outdated.length > 0) outdated.push(`${scope} scope wires ${events.outdated.join(", ")} to a token-goat hook command this build no longer writes; run: ${run}`);
|
|
2792
|
+
}
|
|
2793
|
+
if (broken.length === 0 && missing.length === 0 && outdated.length === 0) return { name, status: "ok", message: "every event this build handles is wired" };
|
|
2794
|
+
const sentences = [];
|
|
2795
|
+
if (broken.length > 0) sentences.push(`${broken.join(". ")}. Claude Code cannot start those hooks, so each of those events reports a hook error until then`);
|
|
2796
|
+
if (missing.length > 0) sentences.push(`${missing.join(". ")}. Those events never reach token-goat until then`);
|
|
2797
|
+
if (outdated.length > 0) sentences.push(`${outdated.join(". ")}. Those events still reach token-goat through the older command until the install rewrites it`);
|
|
2798
|
+
return { name, status: broken.length > 0 ? "fail" : "warn", message: `${sentences.join(". ")}; restart any running session afterwards.` };
|
|
2799
|
+
}
|
|
2800
|
+
function dedupeByResolvedPath(paths) {
|
|
2801
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2802
|
+
return paths.filter((p) => {
|
|
2803
|
+
const key = normalizePath(path13.resolve(p)).toLowerCase();
|
|
2804
|
+
if (seen.has(key)) return false;
|
|
2805
|
+
seen.add(key);
|
|
2806
|
+
return true;
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
function checkVisualStudio(mcpPaths, alsoReadPaths = []) {
|
|
2810
|
+
const found = dedupeByResolvedPath(mcpPaths).flatMap((mcpPath) => {
|
|
2811
|
+
const entry = visualStudioManagedEntry(mcpPath);
|
|
2812
|
+
return entry === null ? [] : [{ mcpPath, ...entry }];
|
|
2813
|
+
});
|
|
2814
|
+
if (found.length === 0) return null;
|
|
2815
|
+
const stale = found.find((e) => !fs13.existsSync(e.command) || !fs13.existsSync(e.bundlePath));
|
|
2816
|
+
if (stale !== void 0) {
|
|
2817
|
+
return {
|
|
2818
|
+
name: "Visual Studio",
|
|
2819
|
+
status: "warn",
|
|
2820
|
+
message: `the token-goat MCP entry in ${displaySafeText(stale.mcpPath)} points at ${displaySafeText(stale.command)} ${displaySafeText(stale.bundlePath)}, which no longer exists; run ${fencedCommand("token-goat uninstall --visualstudio")} and then ${fencedCommand("token-goat install --visualstudio")} again (add -p for the project entry).`
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
const registered = dedupeByResolvedPath([...found.map((e) => e.mcpPath), ...alsoReadPaths.filter((p) => visualStudioManagedEntry(p) !== null)]);
|
|
2824
|
+
if (registered.length > 1) {
|
|
2825
|
+
return {
|
|
2826
|
+
name: "Visual Studio",
|
|
2827
|
+
status: "warn",
|
|
2828
|
+
message: `Visual Studio reads ${registered.map(displaySafeText).join(" and ")}, and each registers token-goat, so it lists the token-goat server more than once. Keep one: ${fencedCommand("token-goat uninstall --vscode -p")} drops the .vscode/mcp.json entry, ${fencedCommand("token-goat uninstall --visualstudio")} (add -p for the project entry) drops a Visual Studio one.`
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
return {
|
|
2832
|
+
name: "Visual Studio",
|
|
2833
|
+
status: "ok",
|
|
2834
|
+
message: `MCP server registered in ${found.map((e) => displaySafeText(e.mcpPath)).join(", ")}. Visual Studio runs no token-goat hooks: it gets the MCP tools and instructions only, and only once the token-goat tools are ticked in the chat Tools picker.`
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
function checkZed(settingsPath) {
|
|
2838
|
+
const entry = zedManagedEntry(settingsPath);
|
|
2839
|
+
if (entry === null) return null;
|
|
2840
|
+
const shimPath2 = entry.command;
|
|
2841
|
+
if (!fs13.existsSync(shimPath2)) {
|
|
2842
|
+
return {
|
|
2843
|
+
name: "Zed",
|
|
2844
|
+
status: "warn",
|
|
2845
|
+
message: `the token-goat MCP entry in ${displaySafeText(settingsPath)} points at ${displaySafeText(shimPath2)}, which no longer exists; run \`token-goat uninstall --zed\` and then \`token-goat install --zed\` again.`
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
const script = fs13.readFileSync(shimPath2, "utf8");
|
|
2849
|
+
const referencedPaths = [...script.matchAll(/"([^"]+)"/g)].map((m) => m[1]).filter((p) => typeof p === "string" && p.length > 0);
|
|
2850
|
+
const stalePath = referencedPaths.find((p) => !fs13.existsSync(p));
|
|
2851
|
+
if (stalePath !== void 0) {
|
|
2852
|
+
return {
|
|
2853
|
+
name: "Zed",
|
|
2854
|
+
status: "warn",
|
|
2855
|
+
message: `the token-goat shim at ${displaySafeText(shimPath2)} points at ${displaySafeText(stalePath)}, which no longer exists; run \`token-goat uninstall --zed\` and then \`token-goat install --zed\` again.`
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
return {
|
|
2859
|
+
name: "Zed",
|
|
2860
|
+
status: "ok",
|
|
2861
|
+
message: `MCP context server registered in ${displaySafeText(settingsPath)}. Zed runs no token-goat hooks: it gets the MCP tools only, and only once the token-goat server is enabled in Zed's Agent panel.`
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
function checkCursor(mcpPath, claudeHooksInstalled) {
|
|
2865
|
+
const entry = cursorManagedEntry(mcpPath);
|
|
2866
|
+
if (entry === null) return null;
|
|
2867
|
+
return {
|
|
2868
|
+
name: "Cursor",
|
|
2869
|
+
status: "ok",
|
|
2870
|
+
message: claudeHooksInstalled ? `MCP server registered in ${displaySafeText(mcpPath)}. Cursor also imports the token-goat hooks already installed in ~/.claude/settings.json by default, so hooks work in Cursor too with no separate hooks.json entry.` : `MCP server registered in ${displaySafeText(mcpPath)}. Cursor writes and runs no token-goat hooks here: it imports Claude Code hooks from ~/.claude/settings.json by default, but none are installed there yet -- run \`token-goat install\` to get hooks in Cursor too.`
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
function checkStrayClaudeMdBlocks(searchRoot) {
|
|
2874
|
+
const strays = findStrayClaudeMdBlocks(searchRoot);
|
|
2875
|
+
if (strays.length === 0) {
|
|
2876
|
+
return { name: "CLAUDE.md block", status: "ok", message: "no stray copies outside CLAUDE.md" };
|
|
2877
|
+
}
|
|
2878
|
+
return {
|
|
2879
|
+
name: "CLAUDE.md block",
|
|
2880
|
+
status: "warn",
|
|
2881
|
+
message: `${strays.length} stray cop${strays.length === 1 ? "y" : "ies"} outside CLAUDE.md (never refreshed by install, never removed by uninstall, will go stale): ${strays.join(", ")}`
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
function checkVscodeProjectMcp(projectRoot = process.cwd()) {
|
|
2885
|
+
const mcpPath = path13.join(path13.resolve(projectRoot), ".vscode", "mcp.json");
|
|
2886
|
+
if (!fs13.existsSync(mcpPath)) return null;
|
|
2887
|
+
if (hasManagedServer(mcpPath, "VS Code")) return null;
|
|
2888
|
+
try {
|
|
2889
|
+
const content = fs13.readFileSync(mcpPath, "utf8");
|
|
2890
|
+
if (isResidueServersJson(content)) {
|
|
2891
|
+
return {
|
|
2892
|
+
name: "VS Code project MCP",
|
|
2893
|
+
status: "warn",
|
|
2894
|
+
message: `empty residue file at ${displaySafeText(mcpPath)} triggers Copilot CLI deprecation warnings; delete it or run \`token-goat uninstall --vscode\``
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
return {
|
|
2898
|
+
name: "VS Code project MCP",
|
|
2899
|
+
status: "warn",
|
|
2900
|
+
message: `${displaySafeText(mcpPath)} is deprecated by Copilot CLI v1.0.84+; migrate configured servers to .mcp.json or .github/mcp.json (https://gh.io/copilotcli-mcpmigrate)`
|
|
2901
|
+
};
|
|
2902
|
+
} catch {
|
|
2903
|
+
return null;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
function checkClaudeHooksGone(wired, activity) {
|
|
2907
|
+
if (wired || activity === null || activity.count === 0 || activity.lastTs === null) return null;
|
|
2908
|
+
if (activity.uninstalledTs !== null && activity.uninstalledTs >= activity.lastTs) return null;
|
|
2909
|
+
const last = new Date(activity.lastTs * 1e3).toISOString().replace("T", " ").slice(0, 16);
|
|
2910
|
+
return {
|
|
2911
|
+
name: "Claude Code hooks",
|
|
2912
|
+
status: "warn",
|
|
2913
|
+
message: `Claude Code ran ${countNoun(activity.count, "token-goat hook")} in the last 7 days, the last at ${last} UTC, but no token-goat hook is wired in ~/.claude/settings.json or this project's .claude/settings.json now, and no uninstall was recorded. The file was probably overwritten or deleted. Run: token-goat install (or token-goat install --project), then restart any running session.`
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
export {
|
|
2918
|
+
installGemini,
|
|
2919
|
+
uninstallGemini,
|
|
2920
|
+
isGeminiInstalled,
|
|
2921
|
+
installQwen,
|
|
2922
|
+
uninstallQwen,
|
|
2923
|
+
isQwenInstalled,
|
|
2924
|
+
kimiConfigPath,
|
|
2925
|
+
kimiHookScriptPath,
|
|
2926
|
+
installKimi,
|
|
2927
|
+
uninstallKimi,
|
|
2928
|
+
wiredKimiHookWords,
|
|
2929
|
+
isKimiInstalled,
|
|
2930
|
+
installPi,
|
|
2931
|
+
uninstallPi,
|
|
2932
|
+
isPiInstalled,
|
|
2933
|
+
installOpencode,
|
|
2934
|
+
uninstallOpencode,
|
|
2935
|
+
isOpencodeInstalled,
|
|
2936
|
+
installOpenclaw,
|
|
2937
|
+
uninstallOpenclaw,
|
|
2938
|
+
isOpenclawInstalled,
|
|
2939
|
+
GROK_HOOK_SCRIPT,
|
|
2940
|
+
grokConfigPath,
|
|
2941
|
+
grokHookScriptPath,
|
|
2942
|
+
wiredGrokHookWords,
|
|
2943
|
+
installGrok,
|
|
2944
|
+
uninstallGrok,
|
|
2945
|
+
isGrokInstalled,
|
|
2946
|
+
installAntigravity,
|
|
2947
|
+
uninstallAntigravity,
|
|
2948
|
+
isAntigravityInstalled,
|
|
2949
|
+
vscodeScopeFromFlags,
|
|
2950
|
+
vscodeHooksInstalled,
|
|
2951
|
+
vscodeUsesClaudeHooks,
|
|
2952
|
+
vscodeRunsTokenGoatClaudeHooks,
|
|
2953
|
+
disableVscodeClaudeHooks,
|
|
2954
|
+
restoreVscodeClaudeHooks,
|
|
2955
|
+
otherScopeHasManagedServer,
|
|
2956
|
+
vscodeDecoderConfigured,
|
|
2957
|
+
installVscode,
|
|
2958
|
+
uninstallVscode,
|
|
2959
|
+
cleanupDeprecatedVscodeProjectMcp,
|
|
2960
|
+
cursorMcpPath,
|
|
2961
|
+
installCursor,
|
|
2962
|
+
uninstallCursor,
|
|
2963
|
+
isCursorInstalled,
|
|
2964
|
+
zedSettingsPath,
|
|
2965
|
+
installZed,
|
|
2966
|
+
uninstallZed,
|
|
2967
|
+
isZedInstalled,
|
|
2968
|
+
checkGlobalMcpConfig,
|
|
2969
|
+
VSCODE_USER_SCOPE_MIGRATED_NOTE,
|
|
2970
|
+
VSCODE_PROJECT_SCOPE_COVERAGE_NOTE,
|
|
2971
|
+
VSCODE_USER_SCOPE_MULTIROOT_NOTE,
|
|
2972
|
+
checkVscodeUserScopeHooks,
|
|
2973
|
+
VSCODE_DOUBLE_FIRE_NOTE,
|
|
2974
|
+
VSCODE_CLAUDE_HOOKS_TURNED_OFF_TEXT,
|
|
2975
|
+
VSCODE_CLAUDE_HOOKS_TURNED_OFF_NOTE,
|
|
2976
|
+
VSCODE_CLAUDE_HOOKS_RESTORED_NOTE,
|
|
2977
|
+
checkVscodeClaudeHooks,
|
|
2978
|
+
checkClaudeHookEvents,
|
|
2979
|
+
checkVisualStudio,
|
|
2980
|
+
checkZed,
|
|
2981
|
+
checkCursor,
|
|
2982
|
+
checkStrayClaudeMdBlocks,
|
|
2983
|
+
checkVscodeProjectMcp,
|
|
2984
|
+
checkClaudeHooksGone
|
|
2985
|
+
};
|