@yancyyu/openhermit 1.6.29 → 1.6.30
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/dist-renderer/assets/{ProjectEditorOverlay-CQm6jUR1.js → ProjectEditorOverlay-DsQt4FHy.js} +1 -1
- package/dist-renderer/assets/{TeamGraphOverlay-h0WDfifv.js → TeamGraphOverlay-BjZC53xf.js} +1 -1
- package/dist-renderer/assets/{_basePickBy-CgG_tjgX.js → _basePickBy-CrWocIjq.js} +1 -1
- package/dist-renderer/assets/{_baseUniq-DwPTU9lP.js → _baseUniq-B6d8ysWi.js} +1 -1
- package/dist-renderer/assets/{arc-7nIrGRzY.js → arc-DAIYCFP8.js} +1 -1
- package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-BYhA6Ev2.js → architectureDiagram-VXUJARFQ-B3UudXJh.js} +1 -1
- package/dist-renderer/assets/{blockDiagram-VD42YOAC-BVpZUGDg.js → blockDiagram-VD42YOAC-DbptKQ4W.js} +1 -1
- package/dist-renderer/assets/{c4Diagram-YG6GDRKO-DsdreMQ9.js → c4Diagram-YG6GDRKO-C4WQuZpV.js} +1 -1
- package/dist-renderer/assets/channel-DbjZvWii.js +1 -0
- package/dist-renderer/assets/{chunk-4BX2VUAB-CcoAs7Jd.js → chunk-4BX2VUAB-Dp7fVpI_.js} +1 -1
- package/dist-renderer/assets/{chunk-55IACEB6-CGGAOoXd.js → chunk-55IACEB6-B8KGfbAy.js} +1 -1
- package/dist-renderer/assets/{chunk-B4BG7PRW-FhpTEPvD.js → chunk-B4BG7PRW-BG1oJrjA.js} +1 -1
- package/dist-renderer/assets/{chunk-DI55MBZ5-DoYySbm1.js → chunk-DI55MBZ5-DRmxNjht.js} +1 -1
- package/dist-renderer/assets/{chunk-FMBD7UC4-e9l2tGHG.js → chunk-FMBD7UC4-D6VLvy16.js} +1 -1
- package/dist-renderer/assets/{chunk-QN33PNHL-DeiXVTCy.js → chunk-QN33PNHL-DZou1667.js} +1 -1
- package/dist-renderer/assets/{chunk-QZHKN3VN-DC2UJLJM.js → chunk-QZHKN3VN-CghmasSh.js} +1 -1
- package/dist-renderer/assets/{chunk-TZMSLE5B-BHFD9eZI.js → chunk-TZMSLE5B-B7apcMPK.js} +1 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-D_FGxxsl.js +1 -0
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-D_FGxxsl.js +1 -0
- package/dist-renderer/assets/clone-CJ1kxO2J.js +1 -0
- package/dist-renderer/assets/{cose-bilkent-S5V4N54A-BdybQraU.js → cose-bilkent-S5V4N54A-05e5uQDp.js} +1 -1
- package/dist-renderer/assets/{dagre-6UL2VRFP-DdF3pwM3.js → dagre-6UL2VRFP-B06bRykF.js} +1 -1
- package/dist-renderer/assets/{diagram-PSM6KHXK-B9Ldd3nh.js → diagram-PSM6KHXK-CY7VYQ7c.js} +1 -1
- package/dist-renderer/assets/{diagram-QEK2KX5R-XEqkrbpu.js → diagram-QEK2KX5R-BjKEH7dD.js} +1 -1
- package/dist-renderer/assets/{diagram-S2PKOQOG-CipwtY59.js → diagram-S2PKOQOG-Bf4ELS1_.js} +1 -1
- package/dist-renderer/assets/{erDiagram-Q2GNP2WA-BB-2ISGo.js → erDiagram-Q2GNP2WA-DJ753_L9.js} +1 -1
- package/dist-renderer/assets/{flowDiagram-NV44I4VS-B8XmJ0u2.js → flowDiagram-NV44I4VS-B71S-lC-.js} +1 -1
- package/dist-renderer/assets/{ganttDiagram-JELNMOA3-D-8XglBb.js → ganttDiagram-JELNMOA3-C_U42mSZ.js} +1 -1
- package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-DL4ChakD.js → gitGraphDiagram-V2S2FVAM-DKUJU4Ns.js} +1 -1
- package/dist-renderer/assets/{graph-BiFNoBjP.js → graph-DY3qbzqj.js} +1 -1
- package/dist-renderer/assets/{index-BowUl0Jb.js → index-BlOrAXp3.js} +542 -532
- package/dist-renderer/assets/{index-6m1ZAymG.js → index-Bs27J5gB.js} +1 -1
- package/dist-renderer/assets/{index-Dp3kJTEe.js → index-C8B_nKOF.js} +1 -1
- package/dist-renderer/assets/index-CmZPUEhS.css +1 -0
- package/dist-renderer/assets/{index-TOpt_T7A.js → index-DLKyDr4T.js} +1 -1
- package/dist-renderer/assets/{index-qNBNjW4K.js → index-Dhsk3_DD.js} +1 -1
- package/dist-renderer/assets/{index-vAykq1H1.js → index-GpUvV2xs.js} +1 -1
- package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DRIBfHDi.js → infoDiagram-HS3SLOUP-BNs0y3IG.js} +1 -1
- package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-BOMiigU4.js → journeyDiagram-XKPGCS4Q-CqPnw4UV.js} +1 -1
- package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-DDxeyjod.js → kanban-definition-3W4ZIXB7-SLlzcUJ2.js} +1 -1
- package/dist-renderer/assets/{layout-DNANbrI4.js → layout-BZLlNmbr.js} +1 -1
- package/dist-renderer/assets/{linear-DxEJi1yT.js → linear-qz6v45xy.js} +1 -1
- package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-nBfGriW8.js → mindmap-definition-VGOIOE7T-B1-kmEWV.js} +1 -1
- package/dist-renderer/assets/{pieDiagram-ADFJNKIX-Din5j6sV.js → pieDiagram-ADFJNKIX-B8a02iNx.js} +1 -1
- package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-DMVK2BEQ.js → quadrantDiagram-AYHSOK5B-BKv1Xfou.js} +1 -1
- package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-6SC94Gg_.js → requirementDiagram-UZGBJVZJ-B3DUpZi2.js} +1 -1
- package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-CD2gghhu.js → sankeyDiagram-TZEHDZUN-DmPzuTsy.js} +1 -1
- package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BnhkN7nZ.js → sequenceDiagram-WL72ISMW-Bo7RelRb.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-Bn8XdYX-.js → stateDiagram-FKZM4ZOC-1epX98gV.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-1b6sI1_g.js → stateDiagram-v2-4FDKWEC3-03Ym9PTr.js} +1 -1
- package/dist-renderer/assets/{timeline-definition-IT6M3QCI-CNs3RPoa.js → timeline-definition-IT6M3QCI-r6isC62H.js} +1 -1
- package/dist-renderer/assets/treemap-GDKQZRPO-CGKpOUF2.js +162 -0
- package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-B8o5J2f3.js → xychartDiagram-PRI3JC2R-t4-rwdAw.js} +1 -1
- package/dist-renderer/index.html +2 -2
- package/package.json +4 -1
- package/src/main/ipc/extensions.ts +353 -0
- package/src/main/server.ts +209 -6
- package/src/main/services/extensions/ExtensionFacadeService.ts +135 -0
- package/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts +190 -0
- package/src/main/services/extensions/catalog/McpCatalogAggregator.ts +150 -0
- package/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts +381 -0
- package/src/main/services/extensions/catalog/PluginCatalogService.ts +392 -0
- package/src/main/services/extensions/credentials/CredentialService.ts +343 -0
- package/src/main/services/extensions/install/McpInstallService.ts +407 -0
- package/src/main/services/extensions/install/PluginInstallService.ts +198 -0
- package/src/main/services/extensions/runtime/ClaudeCodeAdapter.ts +199 -0
- package/src/main/services/extensions/runtime/CodexAdapter.ts +100 -0
- package/src/main/services/extensions/runtime/CursorAdapter.ts +154 -0
- package/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +172 -0
- package/src/main/services/extensions/runtime/GeminiAdapter.ts +91 -0
- package/src/main/services/extensions/runtime/HarnessInstallAdapter.ts +49 -0
- package/src/main/services/extensions/runtime/McpConfigStateReader.ts +209 -0
- package/src/main/services/extensions/runtime/OpenCodeAdapter.ts +91 -0
- package/src/main/services/extensions/runtime/adapterRegistry.ts +54 -0
- package/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +214 -0
- package/src/main/services/extensions/runtime/mcpRuntimeJson.ts +45 -0
- package/src/main/services/extensions/skills/SkillImportService.ts +155 -0
- package/src/main/services/extensions/skills/SkillMetadataParser.ts +323 -0
- package/src/main/services/extensions/skills/SkillPlanService.ts +411 -0
- package/src/main/services/extensions/skills/SkillReviewService.ts +73 -0
- package/src/main/services/extensions/skills/SkillRootsResolver.ts +49 -0
- package/src/main/services/extensions/skills/SkillScaffoldService.ts +89 -0
- package/src/main/services/extensions/skills/SkillScanner.ts +117 -0
- package/src/main/services/extensions/skills/SkillValidator.ts +69 -0
- package/src/main/services/extensions/skills/SkillsCatalogService.ts +92 -0
- package/src/main/services/extensions/skills/SkillsMutationService.ts +146 -0
- package/src/main/services/extensions/skills/SkillsWatcherService.ts +134 -0
- package/src/main/services/extensions/state/McpInstallationStateService.ts +42 -0
- package/src/main/services/extensions/state/PluginInstallationStateService.ts +281 -0
- package/src/main/services/identity/AgentTeamsIdentityStore.ts +218 -0
- package/src/main/services/runtime/providerAwareCliEnv.ts +60 -0
- package/src/main/services/team/ClaudeBinaryResolver.ts +469 -0
- package/src/main/services/team/ClaudeDoctorProbe.ts +0 -0
- package/src/main/services/team/cliFlavor.ts +54 -0
- package/src/main/services/teams-mvp/TaskDispatchService.ts +3 -0
- package/src/main/utils/atomicWrite.ts +72 -0
- package/src/main/utils/childProcess.ts +554 -0
- package/src/main/utils/cliEnv.ts +54 -0
- package/src/main/utils/cliPathMerge.ts +97 -0
- package/src/main/utils/pathDecoder.ts +664 -0
- package/src/main/utils/pathValidation.ts +432 -0
- package/src/main/utils/shellEnv.ts +331 -0
- package/src/renderer/api/httpClient.ts +61 -0
- package/src/renderer/components/extensions/ExtensionStoreView.tsx +59 -34
- package/src/renderer/components/extensions/ExtensionsSubTabTrigger.tsx +1 -1
- package/src/renderer/components/extensions/common/ExtensionToast.tsx +141 -0
- package/src/renderer/components/extensions/common/HarnessSelector.tsx +71 -0
- package/src/renderer/components/extensions/env/EnvVarPanel.tsx +335 -0
- package/src/renderer/components/extensions/env/ProjectEnvPanel.tsx +239 -0
- package/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +14 -223
- package/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +11 -0
- package/src/renderer/components/extensions/mcp/McpServersPanel.tsx +51 -1
- package/src/renderer/components/extensions/skills/SkillsPanel.tsx +1 -126
- package/src/renderer/components/settings/sections/HarnessSection.tsx +2 -6
- package/src/renderer/components/settings/sections/TaskBusSection.tsx +17 -7
- package/src/renderer/components/sidebar/SidebarSessions.tsx +23 -0
- package/src/renderer/components/sidebar/WorkspaceBrowser.tsx +1 -7
- package/src/renderer/components/team/HarnessSelect.tsx +71 -0
- package/src/renderer/components/team/TeamDetailView.tsx +35 -0
- package/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +21 -12
- package/src/renderer/components/team/dialogs/EditTeamDialog.tsx +8 -13
- package/src/renderer/components/team/kanban/KanbanBoard.tsx +26 -64
- package/src/renderer/components/team/messages/MessagesPanel.tsx +28 -24
- package/src/renderer/components/terminal/TerminalPanel.tsx +156 -0
- package/src/renderer/hooks/useExtensionsTabState.ts +2 -2
- package/src/renderer/store/slices/extensionsSlice.ts +42 -107
- package/src/renderer/store/slices/teamSlice.ts +8 -2
- package/src/shared/types/api.ts +29 -0
- package/src/shared/types/extensions/index.ts +1 -0
- package/src/shared/types/extensions/mcp.ts +2 -0
- package/src/shared/types/extensions/plugin.ts +2 -1
- package/src/shared/types/extensions/skill.ts +7 -0
- package/src/shared/utils/providerExtensionCapabilities.ts +1 -1
- package/dist-renderer/assets/channel-C0SqeFU7.js +0 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-DWew1HpM.js +0 -1
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-DWew1HpM.js +0 -1
- package/dist-renderer/assets/clone-Dm-k63Yr.js +0 -1
- package/dist-renderer/assets/index-BhellmRb.css +0 -1
- package/dist-renderer/assets/treemap-GDKQZRPO-DU_yr827.js +0 -162
- package/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +0 -30
- package/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +0 -27
- package/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +0 -91
- package/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +0 -326
- package/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +0 -43
- package/src/features/recent-projects/main/index.ts +0 -3
- package/src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts +0 -34
- package/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +0 -116
- package/src/features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver.ts +0 -20
- package/src/features/recent-projects/main/infrastructure/identity/normalizeIdentityPath.ts +0 -10
- package/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +0 -143
- package/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +0 -282
- package/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +0 -280
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChildProcess,
|
|
3
|
+
exec,
|
|
4
|
+
execFile,
|
|
5
|
+
type ExecFileOptions,
|
|
6
|
+
type ExecOptions,
|
|
7
|
+
spawn,
|
|
8
|
+
type SpawnOptions,
|
|
9
|
+
spawnSync,
|
|
10
|
+
} from 'child_process';
|
|
11
|
+
import { existsSync, readFileSync } from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Promise wrapper for execFile that always returns { stdout, stderr }.
|
|
16
|
+
* Unlike promisify(execFile), this works correctly with mocked execFile
|
|
17
|
+
* (promisify relies on a custom symbol that mocks don't have).
|
|
18
|
+
*/
|
|
19
|
+
function execFileAsync(
|
|
20
|
+
cmd: string,
|
|
21
|
+
args: string[],
|
|
22
|
+
options: ExecFileOptions = {}
|
|
23
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const { timeout, killSignal, ...execOptions } = options;
|
|
26
|
+
const timeoutMs = typeof timeout === 'number' && timeout > 0 ? timeout : 0;
|
|
27
|
+
const timeoutSignal = normalizeKillSignal(killSignal);
|
|
28
|
+
let child: ChildProcess | null = null;
|
|
29
|
+
let settled = false;
|
|
30
|
+
let stdoutText = '';
|
|
31
|
+
let stderrText = '';
|
|
32
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
33
|
+
child = execFile(cmd, args, execOptions, (err, stdout, stderr) => {
|
|
34
|
+
if (settled) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
settled = true;
|
|
38
|
+
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
|
|
39
|
+
if (err) {
|
|
40
|
+
const normalizedError =
|
|
41
|
+
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
|
|
42
|
+
Object.assign(normalizedError, {
|
|
43
|
+
stdout: String(stdout),
|
|
44
|
+
stderr: String(stderr),
|
|
45
|
+
});
|
|
46
|
+
reject(normalizedError);
|
|
47
|
+
} else resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
48
|
+
});
|
|
49
|
+
if (!settled) {
|
|
50
|
+
trackCliProcess(child);
|
|
51
|
+
if (timeoutMs > 0) {
|
|
52
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
53
|
+
stdoutText += chunk.toString();
|
|
54
|
+
});
|
|
55
|
+
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
56
|
+
stderrText += chunk.toString();
|
|
57
|
+
});
|
|
58
|
+
timeoutHandle = setTimeout(() => {
|
|
59
|
+
if (settled) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
settled = true;
|
|
63
|
+
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
|
|
64
|
+
killProcessTree(child, timeoutSignal);
|
|
65
|
+
const error = new Error(
|
|
66
|
+
`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`
|
|
67
|
+
);
|
|
68
|
+
Object.assign(error, {
|
|
69
|
+
killed: true,
|
|
70
|
+
signal: timeoutSignal,
|
|
71
|
+
stdout: stdoutText,
|
|
72
|
+
stderr: stderrText,
|
|
73
|
+
});
|
|
74
|
+
reject(error);
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
timeoutHandle.unref?.();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Promise wrapper for exec. Used exclusively as a Windows shell fallback
|
|
84
|
+
* when execFile fails with EINVAL on non-ASCII binary paths. The command
|
|
85
|
+
* string is built from a known binary path + args, NOT from user input.
|
|
86
|
+
*/
|
|
87
|
+
function execShellAsync(
|
|
88
|
+
cmd: string,
|
|
89
|
+
options: ExecOptions = {}
|
|
90
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const { timeout, killSignal, ...execOptions } = options;
|
|
93
|
+
const timeoutMs = typeof timeout === 'number' && timeout > 0 ? timeout : 0;
|
|
94
|
+
const timeoutSignal = normalizeKillSignal(killSignal);
|
|
95
|
+
let child: ChildProcess | null = null;
|
|
96
|
+
let settled = false;
|
|
97
|
+
let stdoutText = '';
|
|
98
|
+
let stderrText = '';
|
|
99
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
// eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
|
|
101
|
+
child = exec(cmd, execOptions, (err, stdout, stderr) => {
|
|
102
|
+
if (settled) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
settled = true;
|
|
106
|
+
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
|
|
107
|
+
if (err)
|
|
108
|
+
reject(
|
|
109
|
+
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
|
|
110
|
+
);
|
|
111
|
+
else resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
112
|
+
});
|
|
113
|
+
if (!settled) {
|
|
114
|
+
trackCliProcess(child);
|
|
115
|
+
if (timeoutMs > 0) {
|
|
116
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
117
|
+
stdoutText += chunk.toString();
|
|
118
|
+
});
|
|
119
|
+
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
120
|
+
stderrText += chunk.toString();
|
|
121
|
+
});
|
|
122
|
+
timeoutHandle = setTimeout(() => {
|
|
123
|
+
if (settled) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
settled = true;
|
|
127
|
+
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
|
|
128
|
+
killProcessTree(child, timeoutSignal);
|
|
129
|
+
const error = new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`);
|
|
130
|
+
Object.assign(error, {
|
|
131
|
+
killed: true,
|
|
132
|
+
signal: timeoutSignal,
|
|
133
|
+
stdout: stdoutText,
|
|
134
|
+
stderr: stderrText,
|
|
135
|
+
});
|
|
136
|
+
reject(error);
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
timeoutHandle.unref?.();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cleanupTimedCliProcess(
|
|
145
|
+
child: ChildProcess | null,
|
|
146
|
+
timeoutHandle: ReturnType<typeof setTimeout> | null
|
|
147
|
+
): null {
|
|
148
|
+
if (timeoutHandle) {
|
|
149
|
+
clearTimeout(timeoutHandle);
|
|
150
|
+
}
|
|
151
|
+
untrackCliProcess(child);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Returns true if the string contains any non-ASCII character.
|
|
157
|
+
*/
|
|
158
|
+
function containsNonAscii(str: string): boolean {
|
|
159
|
+
return [...str].some((c) => c.charCodeAt(0) > 127);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* On Windows, batch launchers need cmd.exe, and creating a process whose
|
|
164
|
+
* path contains non-ASCII characters will often fail with `spawn EINVAL`.
|
|
165
|
+
* Detect both cases so callers can launch through a shell when needed.
|
|
166
|
+
*/
|
|
167
|
+
function needsShell(binaryPath: string): boolean {
|
|
168
|
+
if (process.platform !== 'win32') return false;
|
|
169
|
+
if (!binaryPath) return false;
|
|
170
|
+
const extension = path.extname(binaryPath).toLowerCase();
|
|
171
|
+
return extension === '.cmd' || extension === '.bat' || containsNonAscii(binaryPath);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface DirectWindowsLauncher {
|
|
175
|
+
command: string;
|
|
176
|
+
argsPrefix: string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isWindowsBatchLauncher(binaryPath: string): boolean {
|
|
180
|
+
const extension = path.extname(binaryPath).toLowerCase();
|
|
181
|
+
return extension === '.cmd' || extension === '.bat';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveCmdPathTemplate(template: string, launcherDir: string): string {
|
|
185
|
+
const dirWithSep = launcherDir.endsWith(path.sep) ? launcherDir : `${launcherDir}${path.sep}`;
|
|
186
|
+
return path.resolve(
|
|
187
|
+
template
|
|
188
|
+
.replace(/%SCRIPT_DIR%/gi, dirWithSep)
|
|
189
|
+
.replace(/%~dp0/gi, dirWithSep)
|
|
190
|
+
.replace(/%dp0%/gi, dirWithSep)
|
|
191
|
+
.replace(/\\/g, path.sep)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveGeneratedBunLauncher(
|
|
196
|
+
content: string,
|
|
197
|
+
launcherDir: string
|
|
198
|
+
): DirectWindowsLauncher | null {
|
|
199
|
+
if (!/\bbun\s+"%TARGET%"\s+%\*/i.test(content)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const targetMatch = /set\s+"TARGET=([^"]+)"/i.exec(content);
|
|
203
|
+
const targetTemplate = targetMatch?.[1];
|
|
204
|
+
if (!targetTemplate) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const target = resolveCmdPathTemplate(targetTemplate, launcherDir);
|
|
209
|
+
if (!existsSync(target)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return { command: 'bun', argsPrefix: [target] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
|
|
216
|
+
const scriptMatch = /"%_prog%"\s+"([^"]+(?:\.(?:cjs|mjs|js))?)"\s+%\*/i.exec(content);
|
|
217
|
+
const scriptTemplate = scriptMatch?.[1];
|
|
218
|
+
if (!scriptTemplate) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const scriptPath = resolveCmdPathTemplate(scriptTemplate, launcherDir);
|
|
223
|
+
if (!existsSync(scriptPath)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const localNode = path.join(launcherDir, 'node.exe');
|
|
228
|
+
return {
|
|
229
|
+
command: existsSync(localNode) ? localNode : 'node',
|
|
230
|
+
argsPrefix: [scriptPath],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
|
|
235
|
+
const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1];
|
|
236
|
+
if (!nativeTarget) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const target = resolveCmdPathTemplate(nativeTarget, launcherDir);
|
|
241
|
+
if (!existsSync(target)) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { command: target, argsPrefix: [] };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Some Windows launchers are thin wrappers around a real JS entrypoint.
|
|
250
|
+
* Running that entrypoint directly with an argv array avoids cmd.exe's
|
|
251
|
+
* percent expansion, which cannot safely represent args like `%PATH%`.
|
|
252
|
+
*/
|
|
253
|
+
function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher | null {
|
|
254
|
+
if (process.platform !== 'win32' || !isWindowsBatchLauncher(binaryPath)) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const content = readFileSync(binaryPath, 'utf8');
|
|
260
|
+
const launcherDir = path.dirname(binaryPath);
|
|
261
|
+
return (
|
|
262
|
+
resolveGeneratedBunLauncher(content, launcherDir) ??
|
|
263
|
+
resolveNpmNodeShim(content, launcherDir) ??
|
|
264
|
+
resolveNpmNativeShim(content, launcherDir)
|
|
265
|
+
);
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Quote an argument for cmd.exe shell invocation on Windows.
|
|
273
|
+
*
|
|
274
|
+
* cmd.exe rules:
|
|
275
|
+
* - Double-quote args containing spaces or special characters
|
|
276
|
+
* - Inside double quotes, escape literal `"` as `\"` for the target argv parser
|
|
277
|
+
* - Double trailing backslashes so they do not escape the closing quote
|
|
278
|
+
* - `%` is expanded as env var even inside double quotes. Keep it outside
|
|
279
|
+
* quoted chunks and escape it as `^%`.
|
|
280
|
+
* - `^`, `&`, `|`, `<`, `>` are safe inside double quotes
|
|
281
|
+
*
|
|
282
|
+
* Our callers only pass controlled strings (binary paths, CLI flags),
|
|
283
|
+
* NOT arbitrary user input.
|
|
284
|
+
*/
|
|
285
|
+
function quoteCmdChunk(chunk: string): string {
|
|
286
|
+
const escaped = chunk
|
|
287
|
+
.replace(/(\\*)"/g, (_match, backslashes: string) => `${backslashes}${backslashes}\\"`)
|
|
288
|
+
.replace(/(\\+)$/g, '$1$1');
|
|
289
|
+
return `"${escaped}"`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function quoteWindowsCmdArg(arg: string): string {
|
|
293
|
+
if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
|
|
294
|
+
return arg.split('%').map(quoteCmdChunk).join('^%');
|
|
295
|
+
}
|
|
296
|
+
return arg;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function quoteArg(arg: string): string {
|
|
300
|
+
return quoteWindowsCmdArg(arg);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Env vars injected into every spawned Claude CLI process. */
|
|
304
|
+
const CLI_ENV_DEFAULTS: Record<string, string> = {
|
|
305
|
+
CLAUDE_HOOK_JUDGE_MODE: 'true',
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const activeCliProcesses = new Set<ChildProcess>();
|
|
309
|
+
|
|
310
|
+
export function untrackCliProcess(child: ChildProcess | null): void {
|
|
311
|
+
if (child) {
|
|
312
|
+
activeCliProcesses.delete(child);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function trackCliProcess<T extends ChildProcess>(child: T): T {
|
|
317
|
+
activeCliProcesses.add(child);
|
|
318
|
+
const cleanup = (): void => {
|
|
319
|
+
activeCliProcesses.delete(child);
|
|
320
|
+
};
|
|
321
|
+
child.once?.('exit', cleanup);
|
|
322
|
+
child.once?.('close', cleanup);
|
|
323
|
+
child.once?.('error', cleanup);
|
|
324
|
+
return child;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function killTrackedCliProcesses(signal: NodeJS.Signals = 'SIGKILL'): void {
|
|
328
|
+
for (const child of Array.from(activeCliProcesses)) {
|
|
329
|
+
try {
|
|
330
|
+
killProcessTree(child, signal);
|
|
331
|
+
} catch {
|
|
332
|
+
// Best effort during shutdown.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Apply shared CLI process defaults without overriding explicit caller choices. */
|
|
338
|
+
function withCliProcessDefaults<
|
|
339
|
+
T extends {
|
|
340
|
+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
341
|
+
windowsHide?: boolean;
|
|
342
|
+
},
|
|
343
|
+
>(options: T): T & { windowsHide: boolean } {
|
|
344
|
+
return {
|
|
345
|
+
...options,
|
|
346
|
+
windowsHide: options.windowsHide ?? true,
|
|
347
|
+
env: { ...(options.env ?? process.env), ...CLI_ENV_DEFAULTS },
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Execute a CLI binary, falling back to running the command through a
|
|
353
|
+
* shell on Windows if the normal path-based spawn fails.
|
|
354
|
+
*
|
|
355
|
+
* The return value matches the shape of Node's `execFile` promise: an
|
|
356
|
+
* object with `stdout` and `stderr` strings.
|
|
357
|
+
*/
|
|
358
|
+
export interface ExecCliOptions extends ExecFileOptions {
|
|
359
|
+
/**
|
|
360
|
+
* Some generated Windows launchers are safe to run directly, but callers can
|
|
361
|
+
* force the .cmd/.bat path when they need the launcher environment exactly.
|
|
362
|
+
*/
|
|
363
|
+
preferShellForWindowsBatch?: boolean;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function execCli(
|
|
367
|
+
binaryPath: string | null,
|
|
368
|
+
args: string[],
|
|
369
|
+
options: ExecCliOptions = {}
|
|
370
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
371
|
+
if (!binaryPath) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
'Claude CLI binary path is null. Resolve the binary via ClaudeBinaryResolver before calling execCli.'
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const target = binaryPath;
|
|
377
|
+
const { preferShellForWindowsBatch = false, ...execOptions } = options;
|
|
378
|
+
const opts = withCliProcessDefaults(execOptions);
|
|
379
|
+
const directLauncher =
|
|
380
|
+
preferShellForWindowsBatch && isWindowsBatchLauncher(target)
|
|
381
|
+
? null
|
|
382
|
+
: resolveDirectWindowsLauncher(target);
|
|
383
|
+
if (directLauncher) {
|
|
384
|
+
const result = await execFileAsync(
|
|
385
|
+
directLauncher.command,
|
|
386
|
+
[...directLauncher.argsPrefix, ...args],
|
|
387
|
+
opts
|
|
388
|
+
);
|
|
389
|
+
return { stdout: String(result.stdout), stderr: String(result.stderr) };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// attempt the normal execFile path first
|
|
393
|
+
if (!needsShell(target)) {
|
|
394
|
+
try {
|
|
395
|
+
const result = await execFileAsync(target, args, opts);
|
|
396
|
+
return { stdout: String(result.stdout), stderr: String(result.stderr) };
|
|
397
|
+
} catch (err: unknown) {
|
|
398
|
+
// fall through to shell fallback only when the error matches the
|
|
399
|
+
// Windows "invalid argument" problem; otherwise rethrow.
|
|
400
|
+
const code =
|
|
401
|
+
err && typeof err === 'object' && 'code' in err
|
|
402
|
+
? (err as { code?: string }).code
|
|
403
|
+
: undefined;
|
|
404
|
+
if (code !== 'EINVAL') {
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// shell fallback (Windows only; others shouldn't reach here)
|
|
411
|
+
const cmd = [target, ...args].map(quoteArg).join(' ');
|
|
412
|
+
const shellResult = await execShellAsync(cmd, opts as unknown as ExecOptions);
|
|
413
|
+
return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Spawn a child process. If the initial `spawn()` call throws
|
|
418
|
+
* synchronously with EINVAL on Windows, retry using a shell-based
|
|
419
|
+
* command string. The returned `ChildProcess` is whatever the
|
|
420
|
+
* underlying call returned; listeners may safely be attached to it.
|
|
421
|
+
*/
|
|
422
|
+
export function spawnCli(
|
|
423
|
+
binaryPath: string,
|
|
424
|
+
args: string[],
|
|
425
|
+
options: SpawnOptions = {}
|
|
426
|
+
): ReturnType<typeof spawn> {
|
|
427
|
+
const opts = withCliProcessDefaults(options);
|
|
428
|
+
const directLauncher = resolveDirectWindowsLauncher(binaryPath);
|
|
429
|
+
if (directLauncher) {
|
|
430
|
+
const directOpts = { ...opts };
|
|
431
|
+
delete directOpts.shell;
|
|
432
|
+
return trackCliProcess(
|
|
433
|
+
spawn(directLauncher.command, [...directLauncher.argsPrefix, ...args], directOpts)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (process.platform === 'win32' && needsShell(binaryPath)) {
|
|
438
|
+
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
|
|
439
|
+
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
|
|
440
|
+
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
return trackCliProcess(spawn(binaryPath, args, opts));
|
|
445
|
+
} catch (err: unknown) {
|
|
446
|
+
const code =
|
|
447
|
+
err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined;
|
|
448
|
+
if (process.platform === 'win32' && code === 'EINVAL') {
|
|
449
|
+
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
|
|
450
|
+
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
|
|
451
|
+
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Kill a child process and its entire process tree.
|
|
459
|
+
*
|
|
460
|
+
* On Windows with `shell: true`, `child.kill()` only kills the intermediate
|
|
461
|
+
* `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned.
|
|
462
|
+
* `taskkill /T /F /PID` recursively kills the entire process tree.
|
|
463
|
+
*
|
|
464
|
+
* On macOS/Linux, kill the child and descendants by PID so shell wrappers
|
|
465
|
+
* and spawned grandchildren do not survive a timeout or team stop.
|
|
466
|
+
*/
|
|
467
|
+
export function killProcessTree(
|
|
468
|
+
child: ChildProcess | null | undefined,
|
|
469
|
+
signal?: NodeJS.Signals
|
|
470
|
+
): void {
|
|
471
|
+
if (!child?.pid) {
|
|
472
|
+
// Process is null, never started, or already exited
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (process.platform === 'win32') {
|
|
477
|
+
try {
|
|
478
|
+
const taskkillPath = path.join(
|
|
479
|
+
process.env.SystemRoot ?? 'C:\\Windows',
|
|
480
|
+
'System32',
|
|
481
|
+
'taskkill.exe'
|
|
482
|
+
);
|
|
483
|
+
execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], { windowsHide: true }, () => {
|
|
484
|
+
// Best-effort - ignore errors (process may have already exited)
|
|
485
|
+
});
|
|
486
|
+
return;
|
|
487
|
+
} catch {
|
|
488
|
+
// taskkill failed, fall through to standard kill
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const childPid = child.pid;
|
|
493
|
+
const descendants = getDescendantProcessIds(childPid);
|
|
494
|
+
const targetSignal = signal ?? 'SIGTERM';
|
|
495
|
+
for (const pid of [childPid, ...descendants.reverse()]) {
|
|
496
|
+
try {
|
|
497
|
+
process.kill(pid, targetSignal);
|
|
498
|
+
} catch {
|
|
499
|
+
// Best-effort - process may have already exited.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function normalizeKillSignal(signal: ExecFileOptions['killSignal']): NodeJS.Signals {
|
|
505
|
+
return typeof signal === 'string' ? signal : 'SIGTERM';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function getDescendantProcessIds(parentPid: number): number[] {
|
|
509
|
+
if (process.platform === 'win32') {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
const result = spawnSync('ps', ['-axo', 'pid=,ppid='], {
|
|
515
|
+
encoding: 'utf8',
|
|
516
|
+
windowsHide: true,
|
|
517
|
+
});
|
|
518
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
519
|
+
return [];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const childrenByParent = new Map<number, number[]>();
|
|
523
|
+
for (const line of result.stdout.split('\n')) {
|
|
524
|
+
const match = /^(\d+)\s+(\d+)$/.exec(line.trim());
|
|
525
|
+
if (!match) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const pid = Number(match[1]);
|
|
529
|
+
const ppid = Number(match[2]);
|
|
530
|
+
const children = childrenByParent.get(ppid);
|
|
531
|
+
if (children) {
|
|
532
|
+
children.push(pid);
|
|
533
|
+
} else {
|
|
534
|
+
childrenByParent.set(ppid, [pid]);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const descendants: number[] = [];
|
|
539
|
+
const stack = [...(childrenByParent.get(parentPid) ?? [])];
|
|
540
|
+
const seen = new Set<number>();
|
|
541
|
+
while (stack.length > 0) {
|
|
542
|
+
const pid = stack.pop();
|
|
543
|
+
if (!pid || seen.has(pid) || pid === process.pid) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
seen.add(pid);
|
|
547
|
+
descendants.push(pid);
|
|
548
|
+
stack.push(...(childrenByParent.get(pid) ?? []));
|
|
549
|
+
}
|
|
550
|
+
return descendants;
|
|
551
|
+
} catch {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds an enriched environment for Claude CLI child processes.
|
|
3
|
+
*
|
|
4
|
+
* Packaged Electron apps on macOS receive a minimal PATH (often just /usr/bin:/bin)
|
|
5
|
+
* and may lack USER (needed for macOS Keychain credential lookup).
|
|
6
|
+
* This helper merges the user's interactive-shell env (cached during startup) with
|
|
7
|
+
* common install locations so that `claude` and its subprocesses (node, npx, etc.)
|
|
8
|
+
* can find the tools they need and authenticate properly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
|
|
12
|
+
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
|
13
|
+
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
|
14
|
+
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
|
15
|
+
import { userInfo } from 'os';
|
|
16
|
+
|
|
17
|
+
export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv {
|
|
18
|
+
const shellEnv = getCachedShellEnv();
|
|
19
|
+
const home = getShellPreferredHome();
|
|
20
|
+
let osUsername = '';
|
|
21
|
+
try {
|
|
22
|
+
osUsername = userInfo().username;
|
|
23
|
+
} catch {
|
|
24
|
+
// userInfo() can throw in restricted environments (Docker, no passwd entry)
|
|
25
|
+
}
|
|
26
|
+
const user =
|
|
27
|
+
shellEnv?.USER?.trim() ||
|
|
28
|
+
process.env.USER?.trim() ||
|
|
29
|
+
process.env.USERNAME?.trim() ||
|
|
30
|
+
osUsername ||
|
|
31
|
+
'';
|
|
32
|
+
|
|
33
|
+
// Only set CLAUDE_CONFIG_DIR when the user has configured a custom path.
|
|
34
|
+
// Setting it to the default ~/.claude changes the macOS Keychain namespace
|
|
35
|
+
// that the CLI uses for OAuth credential lookup, causing "not logged in"
|
|
36
|
+
// even though `claude auth login` succeeded without the env var.
|
|
37
|
+
const configDir = getClaudeBasePath();
|
|
38
|
+
const isCustomConfigDir = configDir !== getAutoDetectedClaudeBasePath();
|
|
39
|
+
|
|
40
|
+
return applyAgentTeamsIdentityEnv({
|
|
41
|
+
...process.env,
|
|
42
|
+
...(shellEnv ?? {}),
|
|
43
|
+
HOME: home,
|
|
44
|
+
USERPROFILE: home,
|
|
45
|
+
PATH: buildMergedCliPath(binaryPath),
|
|
46
|
+
...(isCustomConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {}),
|
|
47
|
+
...(user
|
|
48
|
+
? {
|
|
49
|
+
USER: user,
|
|
50
|
+
LOGNAME: shellEnv?.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
|
51
|
+
}
|
|
52
|
+
: {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merged PATH for Claude CLI discovery and child processes.
|
|
3
|
+
* Packaged macOS apps get a minimal PATH; login-shell cache fixes that once warm.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
|
7
|
+
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
|
8
|
+
import { realpathSync } from 'fs';
|
|
9
|
+
import { posix as pathPosix, win32 as pathWin32 } from 'path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a PATH string that prefers the CLI binary directory, then the user's
|
|
13
|
+
* interactive shell PATH (when cached), then common install locations, then the
|
|
14
|
+
* current process PATH.
|
|
15
|
+
*/
|
|
16
|
+
export function buildMergedCliPath(binaryPath?: string | null): string {
|
|
17
|
+
const home = getShellPreferredHome();
|
|
18
|
+
const sep = process.platform === 'win32' ? pathWin32.delimiter : pathPosix.delimiter;
|
|
19
|
+
const pathForBin = process.platform === 'win32' ? pathWin32 : pathPosix;
|
|
20
|
+
const currentPath = process.env.PATH || '';
|
|
21
|
+
const extraDirs: string[] = [];
|
|
22
|
+
const vendorBinDir = pathForBin.join(getClaudeBasePath(), 'local', 'node_modules', '.bin');
|
|
23
|
+
|
|
24
|
+
if (binaryPath) {
|
|
25
|
+
const binDir = pathForBin.dirname(binaryPath);
|
|
26
|
+
extraDirs.push(binDir);
|
|
27
|
+
try {
|
|
28
|
+
const realBinDir = pathForBin.dirname(realpathSync(binaryPath));
|
|
29
|
+
if (realBinDir !== binDir) {
|
|
30
|
+
extraDirs.push(realBinDir);
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
/* symlink resolution failed — ignore */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cachedEnv = getCachedShellEnv();
|
|
38
|
+
if (cachedEnv?.PATH) {
|
|
39
|
+
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (process.platform === 'win32') {
|
|
43
|
+
extraDirs.push(
|
|
44
|
+
vendorBinDir,
|
|
45
|
+
pathWin32.join(home, 'AppData', 'Roaming', 'npm'),
|
|
46
|
+
pathWin32.join(home, 'scoop', 'shims'),
|
|
47
|
+
pathWin32.join(home, '.bun', 'bin'),
|
|
48
|
+
pathWin32.join(home, '.cargo', 'bin'),
|
|
49
|
+
pathWin32.join(home, '.volta', 'bin')
|
|
50
|
+
);
|
|
51
|
+
if (process.env.LOCALAPPDATA) {
|
|
52
|
+
extraDirs.push(
|
|
53
|
+
pathWin32.join(process.env.LOCALAPPDATA, 'Programs', 'claude'),
|
|
54
|
+
pathWin32.join(process.env.LOCALAPPDATA, 'pnpm')
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (process.env.ProgramFiles) {
|
|
58
|
+
extraDirs.push(
|
|
59
|
+
pathWin32.join(process.env.ProgramFiles, 'claude'),
|
|
60
|
+
pathWin32.join(process.env.ProgramFiles, 'nodejs')
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
extraDirs.push(
|
|
65
|
+
vendorBinDir,
|
|
66
|
+
pathPosix.join(home, '.bun', 'bin'),
|
|
67
|
+
pathPosix.join(home, '.local', 'bin'),
|
|
68
|
+
pathPosix.join(home, '.npm-global', 'bin'),
|
|
69
|
+
pathPosix.join(home, '.npm', 'bin'),
|
|
70
|
+
pathPosix.join(home, '.asdf', 'shims'),
|
|
71
|
+
pathPosix.join(home, '.local', 'share', 'mise', 'shims'),
|
|
72
|
+
pathPosix.join(home, '.volta', 'bin'),
|
|
73
|
+
pathPosix.join(home, 'Library', 'pnpm'),
|
|
74
|
+
pathPosix.join(home, '.local', 'share', 'pnpm'),
|
|
75
|
+
pathPosix.join(home, '.cargo', 'bin'),
|
|
76
|
+
pathPosix.join(home, '.nix-profile', 'bin'),
|
|
77
|
+
'/usr/local/bin',
|
|
78
|
+
'/opt/homebrew/bin',
|
|
79
|
+
'/opt/local/bin',
|
|
80
|
+
'/usr/bin',
|
|
81
|
+
'/bin',
|
|
82
|
+
'/usr/sbin',
|
|
83
|
+
'/sbin'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
const merged: string[] = [];
|
|
89
|
+
for (const dir of [...extraDirs, ...currentPath.split(sep)]) {
|
|
90
|
+
if (dir && !seen.has(dir)) {
|
|
91
|
+
seen.add(dir);
|
|
92
|
+
merged.push(dir);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return merged.join(sep);
|
|
97
|
+
}
|