@openagents-org/agent-launcher 0.2.132 → 0.2.134
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/package.json +1 -1
- package/src/adapters/base.js +100 -0
- package/src/adapters/codex.js +37 -2
- package/src/adapters/cursor.js +6 -0
- package/src/adapters/gemini.js +6 -0
- package/src/agent-rows.js +73 -0
- package/src/cli.js +36 -3
- package/src/installer.js +160 -26
- package/src/paths.js +31 -2
- package/src/skill-installer.js +382 -0
- package/src/tui.js +7 -48
- package/src/update-check.js +58 -14
- package/src/workspace-client.js +24 -0
package/package.json
CHANGED
package/src/adapters/base.js
CHANGED
|
@@ -230,9 +230,109 @@ class BaseAdapter {
|
|
|
230
230
|
await this._postStatusReport(payload);
|
|
231
231
|
} else if (action === 'routines') {
|
|
232
232
|
await this._postRoutinesReport(payload);
|
|
233
|
+
} else if (action === 'skill.install') {
|
|
234
|
+
await this._handleSkillInstall(payload);
|
|
235
|
+
} else if (action === 'skill.uninstall') {
|
|
236
|
+
await this._handleSkillUninstall(payload);
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Install a Skill Hub catalog skill into this agent's local skills
|
|
242
|
+
* directory, then report the result back to the workspace so the UI can
|
|
243
|
+
* show installing → installed / failed. Errors are logged loudly and
|
|
244
|
+
* surfaced as a `failed` status — never swallowed.
|
|
245
|
+
*
|
|
246
|
+
* payload: { action: "skill.install", skill: { id, name, source_repo, source_path } }
|
|
247
|
+
*/
|
|
248
|
+
async _handleSkillInstall(payload) {
|
|
249
|
+
const installer = require('../skill-installer');
|
|
250
|
+
const skill = (payload && payload.skill) || null;
|
|
251
|
+
const skillId = skill && (skill.id || skill.skill_id);
|
|
252
|
+
if (!skillId) {
|
|
253
|
+
this._log('skill.install: missing skill metadata in payload — ignoring');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this._log(`skill.install: starting install of "${skillId}" (type=${this.agentType}, dir=${this.workingDir || process.cwd()})`);
|
|
257
|
+
|
|
258
|
+
// Best-effort "installing" ping so the UI flips immediately even if the
|
|
259
|
+
// initial DB write from the request hasn't propagated to this client.
|
|
260
|
+
try {
|
|
261
|
+
await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
|
|
262
|
+
skillId, state: 'installing',
|
|
263
|
+
});
|
|
264
|
+
} catch (e) {
|
|
265
|
+
this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const result = installer.installSkill({
|
|
270
|
+
skill,
|
|
271
|
+
agentType: this.agentType,
|
|
272
|
+
workingDir: this.workingDir,
|
|
273
|
+
log: (m) => this._log(`skill.install: ${m}`),
|
|
274
|
+
});
|
|
275
|
+
try {
|
|
276
|
+
await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
|
|
277
|
+
skillId, state: 'installed', path: result.path, partial: result.partial === true,
|
|
278
|
+
});
|
|
279
|
+
} catch (e) {
|
|
280
|
+
this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}`);
|
|
281
|
+
}
|
|
282
|
+
this._log(`skill.install: SUCCESS "${skillId}" → ${result.path}${result.partial ? ' (partial)' : ''}`);
|
|
283
|
+
await this._onSkillsChanged();
|
|
284
|
+
} catch (e) {
|
|
285
|
+
const msg = e && e.message ? e.message : String(e);
|
|
286
|
+
this._log(`skill.install: FAILED "${skillId}": ${msg}`);
|
|
287
|
+
try {
|
|
288
|
+
await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
|
|
289
|
+
skillId, state: 'failed', error: msg,
|
|
290
|
+
});
|
|
291
|
+
} catch (e2) {
|
|
292
|
+
this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Remove a previously-installed skill from disk and report `uninstalled`.
|
|
299
|
+
*/
|
|
300
|
+
async _handleSkillUninstall(payload) {
|
|
301
|
+
const installer = require('../skill-installer');
|
|
302
|
+
const skill = (payload && payload.skill) || null;
|
|
303
|
+
const skillId = skill && (skill.id || skill.skill_id);
|
|
304
|
+
if (!skillId) {
|
|
305
|
+
this._log('skill.uninstall: missing skill metadata in payload — ignoring');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const result = installer.uninstallSkill({
|
|
310
|
+
skill,
|
|
311
|
+
agentType: this.agentType,
|
|
312
|
+
workingDir: this.workingDir,
|
|
313
|
+
log: (m) => this._log(`skill.uninstall: ${m}`),
|
|
314
|
+
});
|
|
315
|
+
this._log(`skill.uninstall: "${skillId}" removed=${result.removed}`);
|
|
316
|
+
try {
|
|
317
|
+
await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
|
|
318
|
+
skillId, state: 'uninstalled',
|
|
319
|
+
});
|
|
320
|
+
} catch (e) {
|
|
321
|
+
this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}`);
|
|
322
|
+
}
|
|
323
|
+
await this._onSkillsChanged();
|
|
324
|
+
} catch (e) {
|
|
325
|
+
const msg = e && e.message ? e.message : String(e);
|
|
326
|
+
this._log(`skill.uninstall: FAILED "${skillId}": ${msg}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Hook for subclasses to react to a change in the installed-skills set
|
|
332
|
+
* (e.g. rebuild prompt context). Default: no-op.
|
|
333
|
+
*/
|
|
334
|
+
async _onSkillsChanged() {}
|
|
335
|
+
|
|
236
336
|
/**
|
|
237
337
|
* Post a chat message back to the requesting channel summarizing agent
|
|
238
338
|
* name, type, agent-launcher version, uptime, and network. Used by the
|
package/src/adapters/codex.js
CHANGED
|
@@ -168,7 +168,7 @@ class CodexAdapter extends BaseAdapter {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
_buildSystemContext(channelName) {
|
|
171
|
-
|
|
171
|
+
const base = buildOpenclawSystemPrompt({
|
|
172
172
|
agentName: this.agentName,
|
|
173
173
|
workspaceId: this.workspaceId,
|
|
174
174
|
channelName,
|
|
@@ -177,6 +177,38 @@ class CodexAdapter extends BaseAdapter {
|
|
|
177
177
|
mode: this._mode,
|
|
178
178
|
disabledModules: this.disabledModules,
|
|
179
179
|
});
|
|
180
|
+
const skillsSection = this._buildInstalledSkillsSection();
|
|
181
|
+
return skillsSection ? `${base}\n\n${skillsSection}` : base;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Codex has no native skills-directory discovery (unlike Claude Code), so
|
|
186
|
+
* we inject installed Skill Hub skills directly into its context. Each
|
|
187
|
+
* skill's SKILL.md lives at <workingDir>/.codex/skills/<id>/SKILL.md and
|
|
188
|
+
* Codex (running with cwd=workingDir, full file access) can read it.
|
|
189
|
+
*/
|
|
190
|
+
_buildInstalledSkillsSection() {
|
|
191
|
+
let skills = [];
|
|
192
|
+
try {
|
|
193
|
+
const installer = require('../skill-installer');
|
|
194
|
+
skills = installer.listInstalledSkills({
|
|
195
|
+
agentType: this.agentType || 'codex',
|
|
196
|
+
workingDir: this.workingDir,
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
if (!skills.length) return '';
|
|
202
|
+
const lines = skills.map((s) => {
|
|
203
|
+
const desc = s.description ? ` — ${s.description}` : '';
|
|
204
|
+
return `- **${s.name}** (\`${s.id}\`)${desc}\n Read its instructions: \`cat ${s.skillMd}\``;
|
|
205
|
+
});
|
|
206
|
+
return (
|
|
207
|
+
'## Installed Skills\n' +
|
|
208
|
+
'You have the following skills installed. When a task matches a skill, ' +
|
|
209
|
+
'read its `SKILL.md` (via the `cat` command shown) and follow it:\n' +
|
|
210
|
+
lines.join('\n')
|
|
211
|
+
);
|
|
180
212
|
}
|
|
181
213
|
|
|
182
214
|
// ------------------------------------------------------------------
|
|
@@ -205,14 +237,17 @@ class CodexAdapter extends BaseAdapter {
|
|
|
205
237
|
} catch {}
|
|
206
238
|
}
|
|
207
239
|
|
|
208
|
-
async _onControlAction(action,
|
|
240
|
+
async _onControlAction(action, payload) {
|
|
209
241
|
if (action === 'stop') {
|
|
210
242
|
for (const [channel, proc] of Object.entries(this._channelProcesses)) {
|
|
211
243
|
await this._stopProcess(proc);
|
|
212
244
|
delete this._channelProcesses[channel];
|
|
213
245
|
try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
|
|
214
246
|
}
|
|
247
|
+
return;
|
|
215
248
|
}
|
|
249
|
+
// Shared actions (status, routines, skill.install, skill.uninstall).
|
|
250
|
+
await super._onControlAction(action, payload);
|
|
216
251
|
}
|
|
217
252
|
|
|
218
253
|
// ------------------------------------------------------------------
|
package/src/adapters/cursor.js
CHANGED
|
@@ -205,7 +205,13 @@ class CursorAdapter extends BaseAdapter {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
// Tier 3: Common install locations
|
|
208
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
208
209
|
const candidates = IS_WINDOWS ? [
|
|
210
|
+
// Windows native installer (install?win32=true) drops the CLI here.
|
|
211
|
+
path.join(localAppData, 'cursor-agent', 'cursor-agent.cmd'),
|
|
212
|
+
path.join(localAppData, 'cursor-agent', 'cursor-agent.exe'),
|
|
213
|
+
path.join(localAppData, 'cursor-agent', 'agent.cmd'),
|
|
214
|
+
path.join(localAppData, 'cursor-agent', 'agent.exe'),
|
|
209
215
|
path.join(home, '.cursor', 'bin', 'cursor-agent.cmd'),
|
|
210
216
|
path.join(home, '.cursor', 'bin', 'cursor-agent.exe'),
|
|
211
217
|
path.join(home, '.cursor', 'bin', 'cursor-agent'),
|
package/src/adapters/gemini.js
CHANGED
|
@@ -195,6 +195,12 @@ class GeminiAdapter extends BaseAdapter {
|
|
|
195
195
|
|
|
196
196
|
const cmd = [geminiBin, '-p', fullPrompt, '-y', '-o', 'stream-json'];
|
|
197
197
|
|
|
198
|
+
// Honor a user-configured model (e.g. when pointing at a relay/proxy whose
|
|
199
|
+
// channels don't match the CLI default). Set via GEMINI_MODEL in env_config.
|
|
200
|
+
const env = this.agentEnv || process.env;
|
|
201
|
+
const model = (env.GEMINI_MODEL || env.GOOGLE_GEMINI_MODEL || '').trim();
|
|
202
|
+
if (model) cmd.push('-m', model);
|
|
203
|
+
|
|
198
204
|
const sessionId = this._channelSessions[channelName];
|
|
199
205
|
if (sessionId && !skipResume) {
|
|
200
206
|
cmd.push('-r', sessionId);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure data helpers for the agent list, shared by the TUI (tui.js) and tests.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of any UI/blessed dependency so the list-rendering logic — in
|
|
5
|
+
* particular the "(local)" labelling and Connect gating — can be unit-tested.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build one display row per configured agent from a connector.
|
|
12
|
+
*
|
|
13
|
+
* `workspace` holds the resolved workspace URL (or '' when the agent is not
|
|
14
|
+
* connected). `workspaceLabel` is what the WORKSPACE column shows: the URL when
|
|
15
|
+
* connected, otherwise "(local)" — matching `agn list` so local-only agents are
|
|
16
|
+
* always visible rather than rendered as a blank cell.
|
|
17
|
+
*/
|
|
18
|
+
function loadAgentRows(connector) {
|
|
19
|
+
const config = connector.config.load();
|
|
20
|
+
const agents = config.agents || [];
|
|
21
|
+
const agentStatuses = connector.getDaemonStatus() || {};
|
|
22
|
+
const pid = connector.getDaemonPid();
|
|
23
|
+
const networks = config.networks || [];
|
|
24
|
+
return agents.map(agent => {
|
|
25
|
+
const info = agentStatuses[agent.name] || {};
|
|
26
|
+
const state = pid ? (info.state || 'stopped') : 'stopped';
|
|
27
|
+
let workspace = '';
|
|
28
|
+
if (agent.network) {
|
|
29
|
+
const net = networks.find(n => n.slug === agent.network || n.id === agent.network);
|
|
30
|
+
if (net) {
|
|
31
|
+
const slug = net.slug || net.id;
|
|
32
|
+
const isLocal = (net.endpoint || '').includes('localhost') || (net.endpoint || '').includes('127.0.0.1');
|
|
33
|
+
if (isLocal) workspace = `${net.endpoint}/${slug}`;
|
|
34
|
+
else workspace = `workspace.openagents.org/${slug}`;
|
|
35
|
+
} else {
|
|
36
|
+
workspace = agent.network;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let notReadyMsg = '';
|
|
40
|
+
let health = null;
|
|
41
|
+
try {
|
|
42
|
+
health = connector.healthCheck(agent.type || 'openclaw');
|
|
43
|
+
if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
|
|
44
|
+
} catch {}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
name: agent.name,
|
|
48
|
+
type: agent.type || 'openclaw',
|
|
49
|
+
state,
|
|
50
|
+
workspace,
|
|
51
|
+
// "(local)" when there's no workspace connection; URL otherwise. `workspace`
|
|
52
|
+
// stays empty for local-only agents so Connect gating still treats them as
|
|
53
|
+
// connectable (see connectAvailable).
|
|
54
|
+
workspaceLabel: workspace || '(local)',
|
|
55
|
+
path: agent.path || '',
|
|
56
|
+
network: agent.network || '',
|
|
57
|
+
lastError: info.last_error || '',
|
|
58
|
+
notReadyMsg,
|
|
59
|
+
health,
|
|
60
|
+
configured: true,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* An agent can be connected to a workspace when it's configured and not already
|
|
67
|
+
* bound to one. Mirrors the footer/menu gating so it can be unit-tested.
|
|
68
|
+
*/
|
|
69
|
+
function connectAvailable(row) {
|
|
70
|
+
return !!(row && row.configured && !row.workspace);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { loadAgentRows, connectAvailable };
|
package/src/cli.js
CHANGED
|
@@ -123,11 +123,24 @@ async function cmdCreate(connector, flags, positional) {
|
|
|
123
123
|
|
|
124
124
|
try {
|
|
125
125
|
connector.addAgent({ name, type, role, path: flags.path || process.cwd() });
|
|
126
|
-
print(`Agent '${name}' created (type: ${type})`);
|
|
127
126
|
|
|
128
127
|
// Signal daemon to pick up the new agent
|
|
129
128
|
try { connector.sendDaemonCommand('reload'); } catch {}
|
|
130
129
|
|
|
130
|
+
// Newly created agents are local-only until connected to a workspace.
|
|
131
|
+
// Without a workspace connection they will not appear in the Workspace Dashboard.
|
|
132
|
+
const created = connector.config.getAgent(name);
|
|
133
|
+
if (created && !created.network) {
|
|
134
|
+
print(`Created local agent: ${name} (type: ${type})`);
|
|
135
|
+
print('');
|
|
136
|
+
print('This agent is local-only and will not appear in Workspace Dashboard yet.');
|
|
137
|
+
print('');
|
|
138
|
+
print('To connect it to a Workspace, run:');
|
|
139
|
+
print(` agn connect ${name} <workspace-token>`);
|
|
140
|
+
} else {
|
|
141
|
+
print(`Agent '${name}' created (type: ${type})`);
|
|
142
|
+
}
|
|
143
|
+
|
|
131
144
|
if (!connector.isInstalled(type)) {
|
|
132
145
|
if (!flags.install) {
|
|
133
146
|
print(`Runtime '${type}' is not installed. Run: agn install ${type}`);
|
|
@@ -278,9 +291,29 @@ async function cmdRuntimes(connector) {
|
|
|
278
291
|
|
|
279
292
|
async function cmdConnect(connector, flags, positional) {
|
|
280
293
|
const name = positional[0];
|
|
281
|
-
|
|
282
|
-
|
|
294
|
+
// Token resolution order: positional arg / --token flag, then env vars.
|
|
295
|
+
// OPENAGENTS_WORKSPACE_TOKEN is preferred; OA_WORKSPACE_TOKEN is supported
|
|
296
|
+
// for compatibility with the existing mcp-server env var.
|
|
297
|
+
const token = positional[1]
|
|
298
|
+
|| flags.token
|
|
299
|
+
|| process.env.OPENAGENTS_WORKSPACE_TOKEN
|
|
300
|
+
|| process.env.OA_WORKSPACE_TOKEN;
|
|
301
|
+
|
|
302
|
+
if (!name) {
|
|
283
303
|
print('Usage: agn connect <agent-name> <token>');
|
|
304
|
+
process.exitCode = 1;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!token) {
|
|
309
|
+
// No token supplied and none in the environment. Never prompt — keep
|
|
310
|
+
// CI / non-interactive environments from hanging. Print a helpful error
|
|
311
|
+
// explaining why the agent stays invisible and how to fix it.
|
|
312
|
+
print('Workspace token is required.');
|
|
313
|
+
print('Local-only agents do not appear in Workspace Dashboard until connected.');
|
|
314
|
+
print('Run:');
|
|
315
|
+
print(` agn connect ${name} <workspace-token>`);
|
|
316
|
+
process.exitCode = 1;
|
|
284
317
|
return;
|
|
285
318
|
}
|
|
286
319
|
|
package/src/installer.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { execSync, exec } = require('child_process');
|
|
7
|
-
const { whichBinary, getEnhancedEnv, getRuntimePrefix } = require('./paths');
|
|
7
|
+
const { whichBinary, getEnhancedEnv, getRuntimePrefix, clearBinaryLookupCache } = require('./paths');
|
|
8
8
|
const { EnvManager } = require('./env');
|
|
9
9
|
|
|
10
10
|
const STATUS_CACHE_TTL_MS = 10000;
|
|
@@ -90,7 +90,15 @@ class Installer {
|
|
|
90
90
|
// Fallback: check if binary exists on PATH (system install)
|
|
91
91
|
const binaryPath = this._whichBinary(agentType);
|
|
92
92
|
if (!binaryPath) {
|
|
93
|
-
|
|
93
|
+
// If a successful install wrote a marker, surface installed=true even
|
|
94
|
+
// when binary detection can't (yet) see the freshly-installed CLI —
|
|
95
|
+
// e.g. PATH caches not yet picking up a brand-new ~/.cursor/bin. The
|
|
96
|
+
// marker is the ground truth of what we just installed; UI was
|
|
97
|
+
// silently showing "not installed" right after a successful install
|
|
98
|
+
// because earlier code aggressively deleted the marker here.
|
|
99
|
+
if (this._hasMarker(agentType)) {
|
|
100
|
+
return { installed: true, managed: true, location: 'marker' };
|
|
101
|
+
}
|
|
94
102
|
return { installed: false, managed: false, location: null };
|
|
95
103
|
}
|
|
96
104
|
|
|
@@ -406,29 +414,72 @@ class Installer {
|
|
|
406
414
|
await this.installNodejs(onData);
|
|
407
415
|
}
|
|
408
416
|
|
|
409
|
-
|
|
410
|
-
|
|
417
|
+
const env = this._buildShellEnv();
|
|
418
|
+
const isWin = process.platform === 'win32';
|
|
419
|
+
|
|
420
|
+
// Build the spawn invocation. Three shapes:
|
|
421
|
+
// 1. npm install via bundled `node npm-cli.js` — argv array, no shell.
|
|
422
|
+
// Path-safe under non-ASCII home dirs on Windows (preferred).
|
|
423
|
+
// 2. npm install via legacy shell string (npm.cmd / npm) — fallback only
|
|
424
|
+
// when no node + npm-cli.js layout is found; OEM-codepage corruption
|
|
425
|
+
// means this breaks on non-ASCII paths, but still works for ASCII.
|
|
426
|
+
// 3. pip / curl / powershell — keep the legacy shell-string behaviour.
|
|
427
|
+
let spawnFile;
|
|
428
|
+
let spawnArgs = [];
|
|
429
|
+
let useShell;
|
|
430
|
+
let installCwd;
|
|
431
|
+
let displayCmd;
|
|
432
|
+
|
|
411
433
|
if (rawCmd.startsWith('npm install')) {
|
|
412
434
|
const prefixDir = getRuntimePrefix(agentType);
|
|
413
435
|
fs.mkdirSync(prefixDir, { recursive: true });
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
436
|
+
installCwd = prefixDir;
|
|
437
|
+
|
|
438
|
+
// `npm install [-g] <pkg…>` → install --save --prefix <dir> <pkg…>
|
|
439
|
+
const pkgArgs = rawCmd
|
|
440
|
+
.replace(/^npm install\s*/, '')
|
|
441
|
+
.replace(/(^|\s)-g(\s|$)/, ' ')
|
|
442
|
+
.trim()
|
|
443
|
+
.split(/\s+/)
|
|
444
|
+
.filter(Boolean);
|
|
445
|
+
// Use --save so npm tracks the package in package.json (prevents pruning
|
|
446
|
+
// on next install).
|
|
447
|
+
const npmArgs = ['install', '--loglevel=verbose', '--save', '--prefix', prefixDir, ...pkgArgs];
|
|
448
|
+
displayCmd = `npm ${npmArgs.join(' ')}`;
|
|
449
|
+
|
|
450
|
+
const direct = this._resolveNodeNpmCli();
|
|
451
|
+
if (direct) {
|
|
452
|
+
spawnFile = direct.node;
|
|
453
|
+
spawnArgs = [direct.npmCli, ...npmArgs];
|
|
454
|
+
useShell = false;
|
|
455
|
+
// Ensure npm's own `node` child lookups (postinstall scripts) resolve
|
|
456
|
+
// to this same runtime by putting its dir first on PATH.
|
|
457
|
+
const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || 'PATH';
|
|
458
|
+
const nodeDir = path.dirname(direct.node);
|
|
459
|
+
if (!(env[pathKey] || '').includes(nodeDir)) {
|
|
460
|
+
env[pathKey] = nodeDir + (isWin ? ';' : ':') + (env[pathKey] || '');
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
// Legacy fallback: quoted prefix inside a shell string.
|
|
464
|
+
const args = `install --loglevel=verbose --save --prefix "${prefixDir}" ${pkgArgs.join(' ')}`;
|
|
465
|
+
spawnFile = this._wrapForWindowsShell(this._resolveNpmCommand(args));
|
|
466
|
+
useShell = isWin ? (env.ComSpec || 'C:\\Windows\\System32\\cmd.exe') : true;
|
|
467
|
+
displayCmd = spawnFile;
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
// pip / curl / powershell — unchanged shell-string behaviour.
|
|
471
|
+
spawnFile = this._wrapForWindowsShell(rawCmd);
|
|
472
|
+
useShell = isWin ? (env.ComSpec || 'C:\\Windows\\System32\\cmd.exe') : true;
|
|
473
|
+
// Set cwd outside System32 on Windows.
|
|
474
|
+
installCwd = os.homedir();
|
|
475
|
+
displayCmd = spawnFile;
|
|
419
476
|
}
|
|
420
477
|
|
|
421
|
-
if (onData) onData(`$ ${
|
|
422
|
-
|
|
423
|
-
const env = this._buildShellEnv();
|
|
424
|
-
const shell = process.platform === 'win32'
|
|
425
|
-
? (process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe')
|
|
426
|
-
: true;
|
|
427
|
-
// Set cwd to runtime prefix dir (avoids running from System32 on Windows)
|
|
428
|
-
const installCwd = rawCmd.startsWith('npm install') ? getRuntimePrefix(agentType) : os.homedir();
|
|
478
|
+
if (onData) onData(`$ ${displayCmd}\n\n`);
|
|
429
479
|
|
|
430
480
|
return new Promise((resolve, reject) => {
|
|
431
|
-
|
|
481
|
+
// windowsHide stops a console window from flashing up on every install.
|
|
482
|
+
const proc = spawn(spawnFile, spawnArgs, { shell: useShell, env, cwd: installCwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
432
483
|
|
|
433
484
|
if (proc.stdout) proc.stdout.setEncoding('utf-8');
|
|
434
485
|
if (proc.stderr) proc.stderr.setEncoding('utf-8');
|
|
@@ -448,7 +499,7 @@ class Installer {
|
|
|
448
499
|
if (code === 0) {
|
|
449
500
|
this._markInstalled(agentType);
|
|
450
501
|
if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
|
|
451
|
-
resolve({ success: true, command:
|
|
502
|
+
resolve({ success: true, command: displayCmd });
|
|
452
503
|
} else {
|
|
453
504
|
// A partial install can leave a placeholder stub at
|
|
454
505
|
// bin/<binary>.exe and npm-generated cmd-shims under
|
|
@@ -463,8 +514,8 @@ class Installer {
|
|
|
463
514
|
} catch {}
|
|
464
515
|
const tail = outputTail.trim();
|
|
465
516
|
const msg = tail
|
|
466
|
-
? `Install failed with exit code ${code}\nCommand: ${
|
|
467
|
-
: `Install failed with exit code ${code}\nCommand: ${
|
|
517
|
+
? `Install failed with exit code ${code}\nCommand: ${displayCmd}\n\n${tail}`
|
|
518
|
+
: `Install failed with exit code ${code}\nCommand: ${displayCmd}`;
|
|
468
519
|
if (onData) onData(`\n${msg}\n`);
|
|
469
520
|
reject(new Error(msg));
|
|
470
521
|
}
|
|
@@ -579,6 +630,8 @@ class Installer {
|
|
|
579
630
|
cmd = this._resolveNpmCommand(args);
|
|
580
631
|
}
|
|
581
632
|
|
|
633
|
+
cmd = this._wrapForWindowsShell(cmd);
|
|
634
|
+
|
|
582
635
|
if (onData) onData(`$ ${cmd}\n\n`);
|
|
583
636
|
|
|
584
637
|
const env = this._buildShellEnv();
|
|
@@ -587,7 +640,7 @@ class Installer {
|
|
|
587
640
|
: true;
|
|
588
641
|
|
|
589
642
|
return new Promise((resolve, reject) => {
|
|
590
|
-
const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
643
|
+
const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
591
644
|
|
|
592
645
|
if (proc.stdout) proc.stdout.setEncoding('utf-8');
|
|
593
646
|
if (proc.stderr) proc.stderr.setEncoding('utf-8');
|
|
@@ -708,6 +761,12 @@ class Installer {
|
|
|
708
761
|
fs.mkdirSync(this.markersDir, { recursive: true });
|
|
709
762
|
fs.writeFileSync(path.join(this.markersDir, agentType), '', 'utf-8');
|
|
710
763
|
} catch {}
|
|
764
|
+
|
|
765
|
+
// Drop the 30s PATH/whichBinary caches so the very next getInstallInfo
|
|
766
|
+
// sees the freshly-created bin dir (e.g. ~/.cursor/bin) instead of the
|
|
767
|
+
// pre-install snapshot. Without this, install completes but UI keeps
|
|
768
|
+
// showing "not installed" until the cache expires.
|
|
769
|
+
try { clearBinaryLookupCache(); } catch {}
|
|
711
770
|
}
|
|
712
771
|
|
|
713
772
|
_markUninstalled(agentType) {
|
|
@@ -727,6 +786,9 @@ class Installer {
|
|
|
727
786
|
const markerFile = path.join(this.markersDir, agentType);
|
|
728
787
|
if (fs.existsSync(markerFile)) fs.unlinkSync(markerFile);
|
|
729
788
|
} catch {}
|
|
789
|
+
|
|
790
|
+
// Symmetric with _markInstalled: invalidate so detection re-runs cleanly.
|
|
791
|
+
try { clearBinaryLookupCache(); } catch {}
|
|
730
792
|
}
|
|
731
793
|
|
|
732
794
|
// -- Shell env + exec --
|
|
@@ -790,9 +852,14 @@ class Installer {
|
|
|
790
852
|
} catch {}
|
|
791
853
|
}
|
|
792
854
|
}
|
|
855
|
+
// On Windows the spread above yields a "Path" key, not "PATH". Writing
|
|
856
|
+
// `env.PATH` would create a duplicate key holding only extraDirs and drop
|
|
857
|
+
// everything else; libuv then resolves spawned binaries against the wrong,
|
|
858
|
+
// truncated value. Update the existing case-insensitive key in place.
|
|
859
|
+
const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || 'PATH';
|
|
793
860
|
for (const d of extraDirs) {
|
|
794
|
-
if (d && !(env
|
|
795
|
-
env
|
|
861
|
+
if (d && !(env[pathKey] || '').includes(d)) {
|
|
862
|
+
env[pathKey] = d + sep + (env[pathKey] || '');
|
|
796
863
|
}
|
|
797
864
|
}
|
|
798
865
|
return env;
|
|
@@ -897,7 +964,7 @@ class Installer {
|
|
|
897
964
|
const proc = spawnProc('powershell', [
|
|
898
965
|
'-NoProfile', '-Command',
|
|
899
966
|
`Expand-Archive -Path '${zipPath}' -DestinationPath '${nodejsDir}' -Force`
|
|
900
|
-
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
967
|
+
], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
901
968
|
if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
902
969
|
if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
903
970
|
proc.on('error', reject);
|
|
@@ -1018,6 +1085,54 @@ class Installer {
|
|
|
1018
1085
|
});
|
|
1019
1086
|
}
|
|
1020
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* Locate a `node` binary plus its `npm-cli.js` so npm can be invoked as
|
|
1090
|
+
* `node npm-cli.js …` with argv passed array-style and NO shell. On Windows
|
|
1091
|
+
* that goes through CreateProcessW (UTF-16), so an install prefix under a
|
|
1092
|
+
* non-ASCII home dir (e.g. `C:\Users\用户名\.openagents\runtimes\…`) is
|
|
1093
|
+
* preserved exactly. The legacy `npm.cmd` shell shim instead has cmd.exe
|
|
1094
|
+
* decode the path bytes with the OEM code page (936/GBK on zh-CN), corrupting
|
|
1095
|
+
* it and silently breaking every install. Returns { node, npmCli } or null
|
|
1096
|
+
* when no usable layout is found (caller falls back to the legacy shell
|
|
1097
|
+
* command, which still works for ASCII paths).
|
|
1098
|
+
*/
|
|
1099
|
+
_resolveNodeNpmCli() {
|
|
1100
|
+
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
1101
|
+
const isWin = process.platform === 'win32';
|
|
1102
|
+
const nodeName = isWin ? 'node.exe' : 'node';
|
|
1103
|
+
|
|
1104
|
+
const nodeCandidates = [];
|
|
1105
|
+
// Bundled portable node (~/.openagents/nodejs, plus nested node-* layouts)
|
|
1106
|
+
const bundledDir = path.join(this.configDir, 'nodejs');
|
|
1107
|
+
nodeCandidates.push(path.join(bundledDir, nodeName));
|
|
1108
|
+
nodeCandidates.push(path.join(bundledDir, 'bin', 'node'));
|
|
1109
|
+
try {
|
|
1110
|
+
for (const e of fs.readdirSync(bundledDir)) {
|
|
1111
|
+
if (e.startsWith('node-')) {
|
|
1112
|
+
nodeCandidates.push(path.join(bundledDir, e, nodeName));
|
|
1113
|
+
nodeCandidates.push(path.join(bundledDir, e, 'bin', 'node'));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
} catch {}
|
|
1117
|
+
// System node — its npm-cli.js lives next to (or one level above) it.
|
|
1118
|
+
try {
|
|
1119
|
+
const sys = whichBinary('node');
|
|
1120
|
+
if (sys) nodeCandidates.push(sys);
|
|
1121
|
+
} catch {}
|
|
1122
|
+
|
|
1123
|
+
for (const node of nodeCandidates) {
|
|
1124
|
+
if (!node || !exists(node)) continue;
|
|
1125
|
+
const dir = path.dirname(node);
|
|
1126
|
+
const npmCli = [
|
|
1127
|
+
path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
1128
|
+
path.join(dir, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
1129
|
+
path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
1130
|
+
].find(exists);
|
|
1131
|
+
if (npmCli && this._canExecute(node)) return { node, npmCli };
|
|
1132
|
+
}
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1021
1136
|
/**
|
|
1022
1137
|
* Resolve the npm CLI command. Uses system npm if available.
|
|
1023
1138
|
*/
|
|
@@ -1040,6 +1155,24 @@ class Installer {
|
|
|
1040
1155
|
return `${npmBin} ${args}`;
|
|
1041
1156
|
}
|
|
1042
1157
|
|
|
1158
|
+
/**
|
|
1159
|
+
* Wrap a command in `powershell.exe -Command "..."` when (a) we're on Windows
|
|
1160
|
+
* and (b) the command uses PowerShell-only tokens like `irm`, `iex`,
|
|
1161
|
+
* `Invoke-RestMethod`, etc. The launcher's install shell is cmd.exe, which
|
|
1162
|
+
* doesn't recognize these aliases — without wrapping, e.g. Cursor's
|
|
1163
|
+
* `irm '…' | iex` exits 255. Skips commands already prefixed with
|
|
1164
|
+
* `powershell`/`powershell.exe` so we don't double-wrap.
|
|
1165
|
+
*/
|
|
1166
|
+
_wrapForWindowsShell(cmd) {
|
|
1167
|
+
if (!cmd || process.platform !== 'win32') return cmd;
|
|
1168
|
+
const trimmed = cmd.trimStart();
|
|
1169
|
+
if (/^("[^"]*\\)?powershell(\.exe)?["']?\s/i.test(trimmed)) return cmd;
|
|
1170
|
+
const psTokens = /\b(irm|iwr|iex|Invoke-RestMethod|Invoke-WebRequest|Invoke-Expression|Expand-Archive|Get-[A-Z]\w*|Set-[A-Z]\w*)\b/;
|
|
1171
|
+
if (!psTokens.test(cmd)) return cmd;
|
|
1172
|
+
const escaped = cmd.replace(/"/g, '\\"');
|
|
1173
|
+
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${escaped}"`;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1043
1176
|
_execShell(cmd, timeoutMs = 300000) {
|
|
1044
1177
|
return new Promise((resolve, reject) => {
|
|
1045
1178
|
const env = this._buildShellEnv();
|
|
@@ -1049,7 +1182,8 @@ class Installer {
|
|
|
1049
1182
|
shell = env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
|
|
1050
1183
|
}
|
|
1051
1184
|
|
|
1052
|
-
|
|
1185
|
+
const finalCmd = this._wrapForWindowsShell(cmd);
|
|
1186
|
+
exec(finalCmd, {
|
|
1053
1187
|
encoding: 'utf-8',
|
|
1054
1188
|
timeout: timeoutMs,
|
|
1055
1189
|
shell,
|
package/src/paths.js
CHANGED
|
@@ -107,7 +107,13 @@ function getEnhancedEnv(baseEnv) {
|
|
|
107
107
|
const env = { ...(baseEnv || process.env) };
|
|
108
108
|
const extra = getExtraBinDirs();
|
|
109
109
|
if (extra.length > 0) {
|
|
110
|
-
|
|
110
|
+
// Spreading process.env on Windows yields a "Path" key (not "PATH"), so a
|
|
111
|
+
// bare `env.PATH = …` would create a SECOND key holding only the extra dirs
|
|
112
|
+
// — no System32 — and libuv picks that truncated one when resolving spawned
|
|
113
|
+
// executables (cmd.exe / where.exe become unfindable). Update the existing
|
|
114
|
+
// case-insensitive path key in place instead.
|
|
115
|
+
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
|
|
116
|
+
env[pathKey] = extra.join(SEP) + SEP + (env[pathKey] || '');
|
|
111
117
|
}
|
|
112
118
|
if (IS_WINDOWS) {
|
|
113
119
|
// Force UTF-8 output from child processes on non-English Windows locales
|
|
@@ -179,8 +185,16 @@ function _addWindowsPaths(dirs) {
|
|
|
179
185
|
// Portable Node.js installed by OpenAgents Launcher
|
|
180
186
|
_push(dirs, path.join(HOME, '.openagents', 'nodejs'));
|
|
181
187
|
|
|
182
|
-
// Cursor CLI native installer
|
|
188
|
+
// Cursor CLI native installer.
|
|
189
|
+
// - ~/.cursor/bin : the curl|bash layout (also used by some setups)
|
|
190
|
+
// - %LOCALAPPDATA%\cursor-agent : where the Windows installer
|
|
191
|
+
// (irm 'https://cursor.com/install?win32=true' | iex) actually drops
|
|
192
|
+
// cursor-agent.cmd / agent.cmd. The installer edits the *registry* PATH,
|
|
193
|
+
// so an already-running launcher/daemon process never sees it via `where`
|
|
194
|
+
// unless we add the dir here explicitly.
|
|
183
195
|
_push(dirs, path.join(HOME, '.cursor', 'bin'));
|
|
196
|
+
if (localAppData) _push(dirs, path.join(localAppData, 'cursor-agent'));
|
|
197
|
+
_push(dirs, path.join(HOME, '.local', 'bin'));
|
|
184
198
|
|
|
185
199
|
// Node.js install
|
|
186
200
|
_push(dirs, path.join(programFiles, 'nodejs'));
|
|
@@ -264,6 +278,9 @@ function _addUnixPaths(dirs) {
|
|
|
264
278
|
|
|
265
279
|
// cargo
|
|
266
280
|
_push(dirs, path.join(HOME, '.cargo', 'bin'));
|
|
281
|
+
|
|
282
|
+
// Cursor CLI native installer (curl https://cursor.com/install | bash)
|
|
283
|
+
_push(dirs, path.join(HOME, '.cursor', 'bin'));
|
|
267
284
|
}
|
|
268
285
|
|
|
269
286
|
// ---- macOS-specific ----
|
|
@@ -336,11 +353,23 @@ function getCorePrefix() {
|
|
|
336
353
|
return path.join(HOME, '.openagents', 'core');
|
|
337
354
|
}
|
|
338
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Reset the two 30s caches that back binary detection. Call this after
|
|
358
|
+
* install / uninstall so a freshly-created bin dir (e.g. ~/.cursor/bin)
|
|
359
|
+
* isn't masked by a pre-install snapshot of getExtraBinDirs(), and so a
|
|
360
|
+
* `whichBinary(name) === null` cached before install doesn't survive.
|
|
361
|
+
*/
|
|
362
|
+
function clearBinaryLookupCache() {
|
|
363
|
+
extraBinDirsCache = { value: null, at: 0, path: '' };
|
|
364
|
+
whichBinaryCache.clear();
|
|
365
|
+
}
|
|
366
|
+
|
|
339
367
|
module.exports = {
|
|
340
368
|
getExtraBinDirs,
|
|
341
369
|
getEnhancedPATH,
|
|
342
370
|
getEnhancedEnv,
|
|
343
371
|
whichBinary,
|
|
372
|
+
clearBinaryLookupCache,
|
|
344
373
|
getRuntimePrefix,
|
|
345
374
|
getCorePrefix,
|
|
346
375
|
IS_WINDOWS,
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Skill installer — installs third-party Agent Skills (Skill Hub catalog
|
|
5
|
+
* entries) into a local agent's skills directory.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities (all independently testable):
|
|
8
|
+
* - Resolve the per-agent-type skills directory.
|
|
9
|
+
* - Fetch the skill's files from its source repo into that directory.
|
|
10
|
+
* - Verify a SKILL.md actually landed (no silent success).
|
|
11
|
+
* - Enumerate already-installed skills (used to inject context for agents,
|
|
12
|
+
* like Codex, that don't auto-discover a skills directory).
|
|
13
|
+
*
|
|
14
|
+
* The "how bytes arrive" step is injected via a `fetcher` so production can
|
|
15
|
+
* use git/https while tests use a local fixture copier. The directory
|
|
16
|
+
* resolution, file verification, and error handling — the parts that decide
|
|
17
|
+
* end-to-end correctness — are exercised directly.
|
|
18
|
+
*
|
|
19
|
+
* A catalog skill is identified by `source_repo` + `source_path`
|
|
20
|
+
* (e.g. "anthropics/skills" + "skills/claude-api"), mirroring the Skill Hub
|
|
21
|
+
* catalog in workspace/backend/app/skill_catalog.py.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { execFileSync } = require('child_process');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the skills directory for a given agent type, rooted at the agent's
|
|
31
|
+
* working directory. Different runtimes look in different places:
|
|
32
|
+
* - claude → <workingDir>/.claude/skills (Claude Code auto-discovers here)
|
|
33
|
+
* - cursor → <workingDir>/.cursor/skills (Cursor auto-discovers here)
|
|
34
|
+
* - codex → <workingDir>/.codex/skills (no native discovery; the Codex
|
|
35
|
+
* adapter injects these into the prompt — see codex.js)
|
|
36
|
+
* - default → <workingDir>/.agent/skills
|
|
37
|
+
*
|
|
38
|
+
* @param {string} agentType
|
|
39
|
+
* @param {string} [workingDir]
|
|
40
|
+
* @returns {string} absolute path to the skills directory
|
|
41
|
+
*/
|
|
42
|
+
function skillsDirForAgentType(agentType, workingDir) {
|
|
43
|
+
const base = workingDir || process.cwd();
|
|
44
|
+
switch ((agentType || '').toLowerCase()) {
|
|
45
|
+
case 'claude':
|
|
46
|
+
return path.join(base, '.claude', 'skills');
|
|
47
|
+
case 'cursor':
|
|
48
|
+
return path.join(base, '.cursor', 'skills');
|
|
49
|
+
case 'codex':
|
|
50
|
+
return path.join(base, '.codex', 'skills');
|
|
51
|
+
default:
|
|
52
|
+
return path.join(base, '.agent', 'skills');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a catalog skill object (snake_case from backend / camelCase from
|
|
58
|
+
* UI) to a stable shape.
|
|
59
|
+
*/
|
|
60
|
+
// Validation patterns. The skill metadata comes from a workspace.agent.control
|
|
61
|
+
// event, which any holder of the workspace token could craft — so the launcher
|
|
62
|
+
// does NOT trust it blindly even though the backend only emits catalog entries.
|
|
63
|
+
// These guards prevent path traversal (a malicious id/source_path escaping the
|
|
64
|
+
// skills dir) and argument injection (a value starting with "-" being read as a
|
|
65
|
+
// git/curl flag).
|
|
66
|
+
const _ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; // no leading dash, no slashes/dots-only
|
|
67
|
+
const _REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; // owner/repo
|
|
68
|
+
const _PATH_SEG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; // each path segment
|
|
69
|
+
|
|
70
|
+
function _assertSafeId(id) {
|
|
71
|
+
if (typeof id !== 'string' || id === '.' || id === '..' || !_ID_RE.test(id)) {
|
|
72
|
+
throw new Error(`unsafe skill id "${id}" (must match ${_ID_RE})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _assertSafeSourceRepo(repo) {
|
|
77
|
+
if (repo && !_REPO_RE.test(repo)) {
|
|
78
|
+
throw new Error(`unsafe source_repo "${repo}" (expected owner/repo)`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _assertSafeSourcePath(sp) {
|
|
83
|
+
if (!sp) return;
|
|
84
|
+
const segs = sp.replace(/^\/+|\/+$/g, '').split('/');
|
|
85
|
+
for (const seg of segs) {
|
|
86
|
+
if (seg === '..' || !_PATH_SEG_RE.test(seg)) {
|
|
87
|
+
throw new Error(`unsafe source_path "${sp}" (segment "${seg}")`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeSkill(skill) {
|
|
93
|
+
if (!skill || typeof skill !== 'object') {
|
|
94
|
+
throw new Error('skill metadata missing');
|
|
95
|
+
}
|
|
96
|
+
const id = skill.id || skill.skill_id || skill.skillId;
|
|
97
|
+
if (!id) throw new Error('skill metadata missing id');
|
|
98
|
+
_assertSafeId(id);
|
|
99
|
+
const sourceRepo = skill.source_repo || skill.sourceRepo || '';
|
|
100
|
+
const sourcePath = skill.source_path || skill.sourcePath || '';
|
|
101
|
+
_assertSafeSourceRepo(sourceRepo);
|
|
102
|
+
_assertSafeSourcePath(sourcePath);
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
name: skill.name || id,
|
|
106
|
+
description: skill.description || '',
|
|
107
|
+
sourceRepo,
|
|
108
|
+
sourcePath,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve <skillsDir>/<id> and assert it stays inside <skillsDir>. Belt-and-
|
|
114
|
+
* suspenders on top of _assertSafeId so we never read/write/delete outside the
|
|
115
|
+
* agent's skills directory.
|
|
116
|
+
*/
|
|
117
|
+
function _safeSkillDir(skillsDir, id) {
|
|
118
|
+
const dest = path.resolve(skillsDir, id);
|
|
119
|
+
const root = path.resolve(skillsDir);
|
|
120
|
+
if (dest !== path.join(root, id) || !dest.startsWith(root + path.sep)) {
|
|
121
|
+
throw new Error(`refusing to operate on "${dest}" outside skills dir "${root}"`);
|
|
122
|
+
}
|
|
123
|
+
return dest;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Default fetcher: download the skill's files from GitHub into `destDir`.
|
|
128
|
+
*
|
|
129
|
+
* Strategy, in order of preference:
|
|
130
|
+
* 1. git sparse-checkout of `<source_repo>` limited to `<source_path>`
|
|
131
|
+
* (gets the full skill directory — scripts, references, assets).
|
|
132
|
+
* 2. Fallback: fetch just `SKILL.md` over HTTPS from raw.githubusercontent.
|
|
133
|
+
*
|
|
134
|
+
* Throws if neither strategy produces a SKILL.md. Never silently no-ops.
|
|
135
|
+
*
|
|
136
|
+
* @param {{skill: object, destDir: string, log?: function}} args
|
|
137
|
+
*/
|
|
138
|
+
function defaultFetcher({ skill, destDir, log }) {
|
|
139
|
+
const { sourceRepo, sourcePath } = skill;
|
|
140
|
+
if (!sourceRepo) {
|
|
141
|
+
throw new Error(`skill "${skill.id}" has no source_repo; cannot fetch`);
|
|
142
|
+
}
|
|
143
|
+
const _log = log || (() => {});
|
|
144
|
+
|
|
145
|
+
// ── Strategy 1: git sparse-checkout ──
|
|
146
|
+
if (_gitAvailable()) {
|
|
147
|
+
let tmp;
|
|
148
|
+
try {
|
|
149
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), `skill-${skill.id}-`));
|
|
150
|
+
const repoUrl = `https://github.com/${sourceRepo}.git`;
|
|
151
|
+
const sp = sourcePath || '.';
|
|
152
|
+
execFileSync('git', ['clone', '--depth', '1', '--filter=blob:none', '--sparse', repoUrl, tmp],
|
|
153
|
+
{ stdio: 'pipe', timeout: 120000 });
|
|
154
|
+
execFileSync('git', ['-C', tmp, 'sparse-checkout', 'set', sp],
|
|
155
|
+
{ stdio: 'pipe', timeout: 60000 });
|
|
156
|
+
const srcDir = path.join(tmp, sp);
|
|
157
|
+
if (!fs.existsSync(srcDir)) {
|
|
158
|
+
throw new Error(`source path "${sp}" not found in ${sourceRepo}`);
|
|
159
|
+
}
|
|
160
|
+
_copyDir(srcDir, destDir);
|
|
161
|
+
_log(`Fetched ${sourceRepo}/${sp} via git sparse-checkout`);
|
|
162
|
+
return { partial: false };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
_log(`git fetch failed (${e && e.message ? e.message : e}); trying raw SKILL.md`);
|
|
165
|
+
} finally {
|
|
166
|
+
if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Strategy 2: raw SKILL.md over HTTPS ──
|
|
171
|
+
const sp = (sourcePath || '').replace(/^\/+|\/+$/g, '');
|
|
172
|
+
for (const branch of ['main', 'master']) {
|
|
173
|
+
const rawUrl = `https://raw.githubusercontent.com/${sourceRepo}/${branch}/${sp ? sp + '/' : ''}SKILL.md`;
|
|
174
|
+
try {
|
|
175
|
+
const body = _httpGetSync(rawUrl);
|
|
176
|
+
if (body && body.trim()) {
|
|
177
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
178
|
+
fs.writeFileSync(path.join(destDir, 'SKILL.md'), body, 'utf-8');
|
|
179
|
+
_log(`Fetched SKILL.md via HTTPS (${branch})`);
|
|
180
|
+
// Only the SKILL.md was retrieved — any bundled scripts/references the
|
|
181
|
+
// skill ships were NOT fetched. Signal "partial" so installSkill can
|
|
182
|
+
// warn; skills that depend on those files may not be fully functional.
|
|
183
|
+
return { partial: true };
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// try next branch
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
throw new Error(`could not fetch skill "${skill.id}" from ${sourceRepo}/${sourcePath}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Install a catalog skill into the agent's skills directory.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} args
|
|
197
|
+
* @param {object} args.skill catalog entry (id, name, source_repo, source_path)
|
|
198
|
+
* @param {string} args.agentType e.g. "claude", "codex"
|
|
199
|
+
* @param {string} [args.workingDir] agent working dir; defaults to cwd
|
|
200
|
+
* @param {function} [args.fetcher] ({skill, destDir, log}) => void; defaults to git/https
|
|
201
|
+
* @param {function} [args.log]
|
|
202
|
+
* @returns {{path: string, skillId: string}}
|
|
203
|
+
* @throws {Error} with a clear, surfaceable message on any failure
|
|
204
|
+
*/
|
|
205
|
+
function installSkill({ skill, agentType, workingDir, fetcher, log }) {
|
|
206
|
+
const norm = normalizeSkill(skill);
|
|
207
|
+
const _log = log || (() => {});
|
|
208
|
+
const skillsDir = skillsDirForAgentType(agentType, workingDir);
|
|
209
|
+
const destDir = _safeSkillDir(skillsDir, norm.id);
|
|
210
|
+
|
|
211
|
+
// Ensure the parent skills directory exists / is writable. A clear error
|
|
212
|
+
// here distinguishes "no permission / bad working dir" from fetch failures.
|
|
213
|
+
try {
|
|
214
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
215
|
+
} catch (e) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`cannot create skills directory "${destDir}" for agent type "${agentType}": ` +
|
|
218
|
+
`${e && e.message ? e.message : e}`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Re-installing: start clean so stale files from a prior version don't linger.
|
|
223
|
+
try {
|
|
224
|
+
for (const entry of fs.readdirSync(destDir)) {
|
|
225
|
+
fs.rmSync(path.join(destDir, entry), { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
|
|
229
|
+
const doFetch = fetcher || defaultFetcher;
|
|
230
|
+
const fetchResult = doFetch({ skill: norm, destDir, log: _log }) || {};
|
|
231
|
+
|
|
232
|
+
// No silent success: a real install must produce a SKILL.md.
|
|
233
|
+
const skillMd = _findSkillMd(destDir);
|
|
234
|
+
if (!skillMd) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`install of "${norm.id}" produced no SKILL.md in ${destDir} ` +
|
|
237
|
+
`(fetched from ${norm.sourceRepo}/${norm.sourcePath})`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// A partial fetch (SKILL.md only, bundled scripts/references missing) is a
|
|
242
|
+
// known degraded state — succeed but WARN loudly so it isn't mistaken for a
|
|
243
|
+
// fully-functional install.
|
|
244
|
+
const partial = fetchResult.partial === true;
|
|
245
|
+
if (partial) {
|
|
246
|
+
_log(
|
|
247
|
+
`WARNING: only SKILL.md was fetched for "${norm.id}" — bundled files ` +
|
|
248
|
+
`(scripts/references) were NOT installed; skills that need them may not work fully`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_log(`Installed skill "${norm.id}" → ${destDir}${partial ? ' (partial: SKILL.md only)' : ''}`);
|
|
253
|
+
return { path: destDir, skillId: norm.id, partial };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Remove an installed skill directory. Idempotent.
|
|
258
|
+
*
|
|
259
|
+
* @returns {{path: string, removed: boolean, skillId: string}}
|
|
260
|
+
*/
|
|
261
|
+
function uninstallSkill({ skill, agentType, workingDir, log }) {
|
|
262
|
+
const norm = normalizeSkill(skill);
|
|
263
|
+
const _log = log || (() => {});
|
|
264
|
+
const destDir = _safeSkillDir(skillsDirForAgentType(agentType, workingDir), norm.id);
|
|
265
|
+
let removed = false;
|
|
266
|
+
if (fs.existsSync(destDir)) {
|
|
267
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
268
|
+
removed = true;
|
|
269
|
+
_log(`Uninstalled skill "${norm.id}" from ${destDir}`);
|
|
270
|
+
}
|
|
271
|
+
return { path: destDir, removed, skillId: norm.id };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Enumerate installed third-party skills under the agent's skills directory.
|
|
276
|
+
* Each returned entry parses the SKILL.md frontmatter for name/description.
|
|
277
|
+
* Used by adapters (e.g. Codex) that must inject skill availability into the
|
|
278
|
+
* model context because the runtime doesn't auto-discover skills.
|
|
279
|
+
*
|
|
280
|
+
* @returns {Array<{id: string, name: string, description: string, path: string, skillMd: string}>}
|
|
281
|
+
*/
|
|
282
|
+
function listInstalledSkills({ agentType, workingDir }) {
|
|
283
|
+
const skillsDir = skillsDirForAgentType(agentType, workingDir);
|
|
284
|
+
let entries;
|
|
285
|
+
try {
|
|
286
|
+
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const ent of entries) {
|
|
292
|
+
if (!ent.isDirectory()) continue;
|
|
293
|
+
const dir = path.join(skillsDir, ent.name);
|
|
294
|
+
const skillMd = _findSkillMd(dir);
|
|
295
|
+
if (!skillMd) continue;
|
|
296
|
+
const meta = _parseSkillFrontmatter(skillMd);
|
|
297
|
+
out.push({
|
|
298
|
+
id: ent.name,
|
|
299
|
+
name: meta.name || ent.name,
|
|
300
|
+
description: meta.description || '',
|
|
301
|
+
path: dir,
|
|
302
|
+
skillMd,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Internals
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
function _gitAvailable() {
|
|
313
|
+
try {
|
|
314
|
+
execFileSync('git', ['--version'], { stdio: 'pipe', timeout: 5000 });
|
|
315
|
+
return true;
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function _findSkillMd(dir) {
|
|
322
|
+
// Prefer a top-level SKILL.md; fall back to any *.md so single-file skills
|
|
323
|
+
// (e.g. fetched as openagents-workspace.md) still count as installed.
|
|
324
|
+
const candidates = ['SKILL.md', 'skill.md', 'Skill.md'];
|
|
325
|
+
for (const c of candidates) {
|
|
326
|
+
const p = path.join(dir, c);
|
|
327
|
+
if (fs.existsSync(p)) return p;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const md = fs.readdirSync(dir).find((f) => f.toLowerCase().endsWith('.md'));
|
|
331
|
+
if (md) return path.join(dir, md);
|
|
332
|
+
} catch {}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function _parseSkillFrontmatter(skillMdPath) {
|
|
337
|
+
try {
|
|
338
|
+
const text = fs.readFileSync(skillMdPath, 'utf-8');
|
|
339
|
+
const m = text.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
340
|
+
if (!m) return {};
|
|
341
|
+
const out = {};
|
|
342
|
+
for (const line of m[1].split('\n')) {
|
|
343
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
344
|
+
if (kv) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
} catch {
|
|
348
|
+
return {};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function _copyDir(src, dest) {
|
|
353
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
354
|
+
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
|
|
355
|
+
const s = path.join(src, ent.name);
|
|
356
|
+
const d = path.join(dest, ent.name);
|
|
357
|
+
if (ent.isDirectory()) _copyDir(s, d);
|
|
358
|
+
else if (ent.isFile()) fs.copyFileSync(s, d);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Blocking GET used only by defaultFetcher's HTTPS fallback (which runs off
|
|
363
|
+
// the hot path inside a try/catch). Uses curl for redirect handling; returns
|
|
364
|
+
// the body string or throws. git sparse-checkout is the primary strategy, so
|
|
365
|
+
// this only matters on hosts that have curl but not git.
|
|
366
|
+
function _httpGetSync(url) {
|
|
367
|
+
try {
|
|
368
|
+
const body = execFileSync('curl', ['-fsSL', url], { timeout: 60000, maxBuffer: 10 * 1024 * 1024 });
|
|
369
|
+
return body.toString('utf-8');
|
|
370
|
+
} catch (e) {
|
|
371
|
+
throw new Error(`HTTPS fetch failed for ${url}: ${e && e.message ? e.message : e}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = {
|
|
376
|
+
skillsDirForAgentType,
|
|
377
|
+
installSkill,
|
|
378
|
+
uninstallSkill,
|
|
379
|
+
listInstalledSkills,
|
|
380
|
+
normalizeSkill,
|
|
381
|
+
defaultFetcher,
|
|
382
|
+
};
|
package/src/tui.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const blessed = require('blessed');
|
|
10
10
|
const { AgentConnector } = require('./index');
|
|
11
|
+
const { loadAgentRows, connectAvailable } = require('./agent-rows');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
const fs = require('fs');
|
|
13
14
|
const { spawn } = require('child_process');
|
|
@@ -64,49 +65,6 @@ function getConnector() {
|
|
|
64
65
|
return new AgentConnector({ configDir });
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
function loadAgentRows(connector) {
|
|
68
|
-
const config = connector.config.load();
|
|
69
|
-
const agents = config.agents || [];
|
|
70
|
-
const agentStatuses = connector.getDaemonStatus() || {};
|
|
71
|
-
const pid = connector.getDaemonPid();
|
|
72
|
-
const networks = config.networks || [];
|
|
73
|
-
return agents.map(agent => {
|
|
74
|
-
const info = agentStatuses[agent.name] || {};
|
|
75
|
-
const state = pid ? (info.state || 'stopped') : 'stopped';
|
|
76
|
-
let workspace = '';
|
|
77
|
-
if (agent.network) {
|
|
78
|
-
const net = networks.find(n => n.slug === agent.network || n.id === agent.network);
|
|
79
|
-
if (net) {
|
|
80
|
-
const slug = net.slug || net.id;
|
|
81
|
-
const isLocal = (net.endpoint || '').includes('localhost') || (net.endpoint || '').includes('127.0.0.1');
|
|
82
|
-
if (isLocal) workspace = `${net.endpoint}/${slug}`;
|
|
83
|
-
else workspace = `workspace.openagents.org/${slug}`;
|
|
84
|
-
} else {
|
|
85
|
-
workspace = agent.network;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
let notReadyMsg = '';
|
|
89
|
-
let health = null;
|
|
90
|
-
try {
|
|
91
|
-
health = connector.healthCheck(agent.type || 'openclaw');
|
|
92
|
-
if (health && !health.ready) notReadyMsg = health.message || 'Not configured';
|
|
93
|
-
} catch {}
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
name: agent.name,
|
|
97
|
-
type: agent.type || 'openclaw',
|
|
98
|
-
state,
|
|
99
|
-
workspace,
|
|
100
|
-
path: agent.path || '',
|
|
101
|
-
network: agent.network || '',
|
|
102
|
-
lastError: info.last_error || '',
|
|
103
|
-
notReadyMsg,
|
|
104
|
-
health,
|
|
105
|
-
configured: true,
|
|
106
|
-
};
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
68
|
function describeHealth(health) {
|
|
111
69
|
if (!health) return '';
|
|
112
70
|
if (!health.ready) return health.message || 'Not configured';
|
|
@@ -271,7 +229,7 @@ function createTUI() {
|
|
|
271
229
|
const envFields = connector.registry.getEnvFields(agent.type);
|
|
272
230
|
if (envFields && envFields.length > 0) items.push({ key: 'e', label: 'Configure' });
|
|
273
231
|
|
|
274
|
-
if (
|
|
232
|
+
if (connectAvailable(agent)) items.push({ key: 'c', label: 'Connect' });
|
|
275
233
|
if (agent.workspace) items.push({ key: 'd', label: 'Disconnect' });
|
|
276
234
|
if (agent.workspace) items.push({ key: 'w', label: 'Workspace' });
|
|
277
235
|
|
|
@@ -322,7 +280,8 @@ function createTUI() {
|
|
|
322
280
|
const items = [];
|
|
323
281
|
for (const r of agentRows) {
|
|
324
282
|
const state = stateMarkup(r.state, !!r.workspace);
|
|
325
|
-
|
|
283
|
+
// Local-only agents (no workspace) show a dimmed "(local)" marker.
|
|
284
|
+
const ws = r.workspace || `{gray-fg}${r.workspaceLabel}{/gray-fg}`;
|
|
326
285
|
items.push(` ${r.name.padEnd(22)} ${r.type.padEnd(14)} ${state.padEnd(30)} ${ws}`);
|
|
327
286
|
// Detail row: working dir + config warning
|
|
328
287
|
const details = [];
|
|
@@ -418,7 +377,7 @@ function createTUI() {
|
|
|
418
377
|
if (isStopped) actions.push({ label: 'Start', key: 'start' });
|
|
419
378
|
if (isRunning) actions.push({ label: 'Stop', key: 'stop' });
|
|
420
379
|
if (agent.workspace) actions.push({ label: 'Open Workspace', key: 'open_workspace' });
|
|
421
|
-
if (
|
|
380
|
+
if (connectAvailable(agent)) actions.push({ label: 'Connect to Workspace', key: 'connect' });
|
|
422
381
|
if (agent.workspace) actions.push({ label: 'Disconnect from Workspace', key: 'disconnect' });
|
|
423
382
|
actions.push({ label: 'Remove', key: 'remove' });
|
|
424
383
|
|
|
@@ -1403,7 +1362,7 @@ function createTUI() {
|
|
|
1403
1362
|
Connect() {
|
|
1404
1363
|
if (currentView !== 'main' || !selectedAgent()) return;
|
|
1405
1364
|
const a = selectedAgent();
|
|
1406
|
-
if (a
|
|
1365
|
+
if (connectAvailable(a)) showConnectWorkspaceScreen(a.name);
|
|
1407
1366
|
},
|
|
1408
1367
|
Disconnect() {
|
|
1409
1368
|
if (currentView !== 'main' || !selectedAgent()) return;
|
|
@@ -1492,4 +1451,4 @@ function createTUI() {
|
|
|
1492
1451
|
}
|
|
1493
1452
|
|
|
1494
1453
|
function run() { createTUI(); }
|
|
1495
|
-
module.exports = { run };
|
|
1454
|
+
module.exports = { run, loadAgentRows, connectAvailable };
|
package/src/update-check.js
CHANGED
|
@@ -104,32 +104,75 @@ function detectInstallPrefix() {
|
|
|
104
104
|
return null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Locate the npm executable next to the running node binary, falling back to
|
|
109
|
+
* a PATH lookup. On Windows the runnable file is `npm.cmd`; the extensionless
|
|
110
|
+
* `npm` shipped alongside it is a Unix shell script that cmd.exe / PowerShell
|
|
111
|
+
* cannot execute, so it must be checked AFTER `npm.cmd`. The opts are injectable
|
|
112
|
+
* to keep this testable across platforms.
|
|
113
|
+
*/
|
|
114
|
+
function findNpmBin(opts = {}) {
|
|
115
|
+
const platform = opts.platform || process.platform;
|
|
116
|
+
const nodeDir = opts.nodeDir || path.dirname(process.execPath);
|
|
117
|
+
const exists = opts.exists || fs.existsSync;
|
|
118
|
+
const names = platform === 'win32' ? ['npm.cmd', 'npm'] : ['npm'];
|
|
119
|
+
for (const name of names) {
|
|
110
120
|
const candidate = path.join(nodeDir, name);
|
|
111
|
-
if (
|
|
121
|
+
if (exists(candidate)) return candidate;
|
|
112
122
|
}
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
// PATH fallback (injectable so tests can exercise the hard fallback
|
|
124
|
+
// deterministically regardless of the host OS).
|
|
125
|
+
const lookup = opts.lookup || (() =>
|
|
126
|
+
execSync(platform === 'win32' ? 'where npm' : 'which npm', {
|
|
115
127
|
encoding: 'utf-8', timeout: 3000,
|
|
116
|
-
}).trim().split(/\r?\n/)[0];
|
|
117
|
-
|
|
128
|
+
}).trim().split(/\r?\n/)[0]);
|
|
129
|
+
try {
|
|
130
|
+
return lookup() || (platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
131
|
+
} catch { return platform === 'win32' ? 'npm.cmd' : 'npm'; }
|
|
118
132
|
}
|
|
119
133
|
|
|
120
134
|
/**
|
|
121
135
|
* Run npm install to update to the latest version. Blocking.
|
|
122
|
-
* Returns true on success.
|
|
136
|
+
* Returns true on success. `opts` are injectable for testing.
|
|
123
137
|
*/
|
|
124
|
-
function runUpdate() {
|
|
125
|
-
const
|
|
126
|
-
const
|
|
138
|
+
function runUpdate(opts = {}) {
|
|
139
|
+
const platform = opts.platform || process.platform;
|
|
140
|
+
const spawn = opts.spawn || spawnSync;
|
|
141
|
+
const npmBin = opts.npmBin || findNpmBin({ platform });
|
|
142
|
+
const prefix = opts.prefix !== undefined ? opts.prefix : detectInstallPrefix();
|
|
127
143
|
const args = ['install', '--no-save', `${PKG_NAME}@latest`];
|
|
128
144
|
if (prefix) args.push('--prefix', prefix);
|
|
129
145
|
else args.push('-g');
|
|
146
|
+
|
|
147
|
+
// On Windows, npm.cmd is a batch file and must be invoked through cmd.exe.
|
|
148
|
+
// Passing the args via cmd.exe with shell:false lets Node quote paths that
|
|
149
|
+
// contain spaces (e.g. C:\Program Files\nodejs\npm.cmd) correctly.
|
|
150
|
+
let cmd, cmdArgs;
|
|
151
|
+
if (platform === 'win32') {
|
|
152
|
+
cmd = process.env.ComSpec || 'cmd.exe';
|
|
153
|
+
cmdArgs = ['/d', '/s', '/c', npmBin, ...args];
|
|
154
|
+
} else {
|
|
155
|
+
cmd = npmBin;
|
|
156
|
+
cmdArgs = args;
|
|
157
|
+
}
|
|
158
|
+
|
|
130
159
|
process.stderr.write(`[launcher] Running: ${npmBin} ${args.join(' ')}\n`);
|
|
131
|
-
const r =
|
|
132
|
-
|
|
160
|
+
const r = spawn(cmd, cmdArgs, { stdio: 'inherit' });
|
|
161
|
+
|
|
162
|
+
// spawn failed to even start the process (ENOENT / EINVAL / EACCES ...).
|
|
163
|
+
if (r && r.error) {
|
|
164
|
+
process.stderr.write(`[launcher] Failed to run npm (${cmd}): ${r.error.message}\n`);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
// npm started but exited non-zero. Its own stderr was already streamed via
|
|
168
|
+
// stdio:'inherit'; surface the exit code so the failure isn't silent.
|
|
169
|
+
if (!r || r.status !== 0) {
|
|
170
|
+
const code = r ? r.status : 'unknown';
|
|
171
|
+
const sig = r && r.signal ? ` (signal ${r.signal})` : '';
|
|
172
|
+
process.stderr.write(`[launcher] npm exited with code ${code}${sig}\n`);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
133
176
|
}
|
|
134
177
|
|
|
135
178
|
/**
|
|
@@ -179,5 +222,6 @@ module.exports = {
|
|
|
179
222
|
checkForUpdate,
|
|
180
223
|
notifyAndMaybeUpdate,
|
|
181
224
|
runUpdate,
|
|
225
|
+
findNpmBin,
|
|
182
226
|
currentVersion,
|
|
183
227
|
};
|
package/src/workspace-client.js
CHANGED
|
@@ -357,6 +357,30 @@ class WorkspaceClient {
|
|
|
357
357
|
}
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Report skill-install progress/result back to the workspace.
|
|
362
|
+
*
|
|
363
|
+
* POST /v1/workspaces/{id}/members/{agentName}/skills/status
|
|
364
|
+
* Body: { skill_id, state: "installing"|"installed"|"failed"|"uninstalled",
|
|
365
|
+
* path?, error? }
|
|
366
|
+
*
|
|
367
|
+
* The backend updates WorkspaceMember.enabled_skills.skill_status so the
|
|
368
|
+
* Skill Hub UI can render installing / installed / failed states. Best
|
|
369
|
+
* effort — returns the updated payload or throws (caller decides).
|
|
370
|
+
*/
|
|
371
|
+
async reportSkillStatus(workspaceId, agentName, token, { skillId, state, path: installPath, error, partial } = {}) {
|
|
372
|
+
const body = { skill_id: skillId, state };
|
|
373
|
+
if (installPath) body.path = installPath;
|
|
374
|
+
if (error) body.error = String(error).slice(0, 2000);
|
|
375
|
+
if (partial) body.partial = true;
|
|
376
|
+
const data = await this._post(
|
|
377
|
+
`/v1/workspaces/${workspaceId}/members/${encodeURIComponent(agentName)}/skills/status`,
|
|
378
|
+
body,
|
|
379
|
+
this._wsHeaders(token),
|
|
380
|
+
);
|
|
381
|
+
return data.data || data;
|
|
382
|
+
}
|
|
383
|
+
|
|
360
384
|
/**
|
|
361
385
|
* Get session/channel info via GET /v1/workspaces/{id}/channels/{name}.
|
|
362
386
|
*/
|