@openagents-org/agent-launcher 0.2.132 → 0.2.133
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/cli.js +33 -2
- package/src/installer.js +43 -3
- package/src/paths.js +15 -0
- package/src/skill-installer.js +382 -0
- 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/cli.js
CHANGED
|
@@ -128,6 +128,16 @@ async function cmdCreate(connector, flags, positional) {
|
|
|
128
128
|
// Signal daemon to pick up the new agent
|
|
129
129
|
try { connector.sendDaemonCommand('reload'); } catch {}
|
|
130
130
|
|
|
131
|
+
// Newly created agents are local-only until connected to a workspace.
|
|
132
|
+
// Without a workspace connection they will not appear in the Workspace Dashboard.
|
|
133
|
+
const created = connector.config.getAgent(name);
|
|
134
|
+
if (created && !created.network) {
|
|
135
|
+
print('');
|
|
136
|
+
print(`Note: '${name}' is local-only and will not appear in the Workspace Dashboard.`);
|
|
137
|
+
print(`To make it visible, connect it to a workspace:`);
|
|
138
|
+
print(` agn connect ${name} <workspace-token>`);
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
if (!connector.isInstalled(type)) {
|
|
132
142
|
if (!flags.install) {
|
|
133
143
|
print(`Runtime '${type}' is not installed. Run: agn install ${type}`);
|
|
@@ -278,9 +288,30 @@ async function cmdRuntimes(connector) {
|
|
|
278
288
|
|
|
279
289
|
async function cmdConnect(connector, flags, positional) {
|
|
280
290
|
const name = positional[0];
|
|
281
|
-
|
|
282
|
-
|
|
291
|
+
// Token resolution order: positional arg / --token flag, then env vars.
|
|
292
|
+
// OPENAGENTS_WORKSPACE_TOKEN is preferred; OA_WORKSPACE_TOKEN is supported
|
|
293
|
+
// for compatibility with the existing mcp-server env var.
|
|
294
|
+
const token = positional[1]
|
|
295
|
+
|| flags.token
|
|
296
|
+
|| process.env.OPENAGENTS_WORKSPACE_TOKEN
|
|
297
|
+
|| process.env.OA_WORKSPACE_TOKEN;
|
|
298
|
+
|
|
299
|
+
if (!name) {
|
|
283
300
|
print('Usage: agn connect <agent-name> <token>');
|
|
301
|
+
process.exitCode = 1;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!token) {
|
|
306
|
+
// No token supplied and none in the environment. Never prompt — keep
|
|
307
|
+
// CI / non-interactive environments from hanging. Print a helpful error
|
|
308
|
+
// explaining why the agent stays invisible and how to fix it.
|
|
309
|
+
print(`Error: no workspace token provided for '${name}'.`);
|
|
310
|
+
print(`Without a token, '${name}' stays local-only and will not appear in the Workspace Dashboard.`);
|
|
311
|
+
print('Provide a token in one of these ways:');
|
|
312
|
+
print(` agn connect ${name} <workspace-token>`);
|
|
313
|
+
print(` OPENAGENTS_WORKSPACE_TOKEN=<workspace-token> agn connect ${name}`);
|
|
314
|
+
process.exitCode = 1;
|
|
284
315
|
return;
|
|
285
316
|
}
|
|
286
317
|
|
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
|
|
|
@@ -418,6 +426,8 @@ class Installer {
|
|
|
418
426
|
cmd = rawCmd; // pip commands stay as-is
|
|
419
427
|
}
|
|
420
428
|
|
|
429
|
+
cmd = this._wrapForWindowsShell(cmd);
|
|
430
|
+
|
|
421
431
|
if (onData) onData(`$ ${cmd}\n\n`);
|
|
422
432
|
|
|
423
433
|
const env = this._buildShellEnv();
|
|
@@ -579,6 +589,8 @@ class Installer {
|
|
|
579
589
|
cmd = this._resolveNpmCommand(args);
|
|
580
590
|
}
|
|
581
591
|
|
|
592
|
+
cmd = this._wrapForWindowsShell(cmd);
|
|
593
|
+
|
|
582
594
|
if (onData) onData(`$ ${cmd}\n\n`);
|
|
583
595
|
|
|
584
596
|
const env = this._buildShellEnv();
|
|
@@ -708,6 +720,12 @@ class Installer {
|
|
|
708
720
|
fs.mkdirSync(this.markersDir, { recursive: true });
|
|
709
721
|
fs.writeFileSync(path.join(this.markersDir, agentType), '', 'utf-8');
|
|
710
722
|
} catch {}
|
|
723
|
+
|
|
724
|
+
// Drop the 30s PATH/whichBinary caches so the very next getInstallInfo
|
|
725
|
+
// sees the freshly-created bin dir (e.g. ~/.cursor/bin) instead of the
|
|
726
|
+
// pre-install snapshot. Without this, install completes but UI keeps
|
|
727
|
+
// showing "not installed" until the cache expires.
|
|
728
|
+
try { clearBinaryLookupCache(); } catch {}
|
|
711
729
|
}
|
|
712
730
|
|
|
713
731
|
_markUninstalled(agentType) {
|
|
@@ -727,6 +745,9 @@ class Installer {
|
|
|
727
745
|
const markerFile = path.join(this.markersDir, agentType);
|
|
728
746
|
if (fs.existsSync(markerFile)) fs.unlinkSync(markerFile);
|
|
729
747
|
} catch {}
|
|
748
|
+
|
|
749
|
+
// Symmetric with _markInstalled: invalidate so detection re-runs cleanly.
|
|
750
|
+
try { clearBinaryLookupCache(); } catch {}
|
|
730
751
|
}
|
|
731
752
|
|
|
732
753
|
// -- Shell env + exec --
|
|
@@ -1040,6 +1061,24 @@ class Installer {
|
|
|
1040
1061
|
return `${npmBin} ${args}`;
|
|
1041
1062
|
}
|
|
1042
1063
|
|
|
1064
|
+
/**
|
|
1065
|
+
* Wrap a command in `powershell.exe -Command "..."` when (a) we're on Windows
|
|
1066
|
+
* and (b) the command uses PowerShell-only tokens like `irm`, `iex`,
|
|
1067
|
+
* `Invoke-RestMethod`, etc. The launcher's install shell is cmd.exe, which
|
|
1068
|
+
* doesn't recognize these aliases — without wrapping, e.g. Cursor's
|
|
1069
|
+
* `irm '…' | iex` exits 255. Skips commands already prefixed with
|
|
1070
|
+
* `powershell`/`powershell.exe` so we don't double-wrap.
|
|
1071
|
+
*/
|
|
1072
|
+
_wrapForWindowsShell(cmd) {
|
|
1073
|
+
if (!cmd || process.platform !== 'win32') return cmd;
|
|
1074
|
+
const trimmed = cmd.trimStart();
|
|
1075
|
+
if (/^("[^"]*\\)?powershell(\.exe)?["']?\s/i.test(trimmed)) return cmd;
|
|
1076
|
+
const psTokens = /\b(irm|iwr|iex|Invoke-RestMethod|Invoke-WebRequest|Invoke-Expression|Expand-Archive|Get-[A-Z]\w*|Set-[A-Z]\w*)\b/;
|
|
1077
|
+
if (!psTokens.test(cmd)) return cmd;
|
|
1078
|
+
const escaped = cmd.replace(/"/g, '\\"');
|
|
1079
|
+
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${escaped}"`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1043
1082
|
_execShell(cmd, timeoutMs = 300000) {
|
|
1044
1083
|
return new Promise((resolve, reject) => {
|
|
1045
1084
|
const env = this._buildShellEnv();
|
|
@@ -1049,7 +1088,8 @@ class Installer {
|
|
|
1049
1088
|
shell = env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
|
|
1050
1089
|
}
|
|
1051
1090
|
|
|
1052
|
-
|
|
1091
|
+
const finalCmd = this._wrapForWindowsShell(cmd);
|
|
1092
|
+
exec(finalCmd, {
|
|
1053
1093
|
encoding: 'utf-8',
|
|
1054
1094
|
timeout: timeoutMs,
|
|
1055
1095
|
shell,
|
package/src/paths.js
CHANGED
|
@@ -264,6 +264,9 @@ function _addUnixPaths(dirs) {
|
|
|
264
264
|
|
|
265
265
|
// cargo
|
|
266
266
|
_push(dirs, path.join(HOME, '.cargo', 'bin'));
|
|
267
|
+
|
|
268
|
+
// Cursor CLI native installer (curl https://cursor.com/install | bash)
|
|
269
|
+
_push(dirs, path.join(HOME, '.cursor', 'bin'));
|
|
267
270
|
}
|
|
268
271
|
|
|
269
272
|
// ---- macOS-specific ----
|
|
@@ -336,11 +339,23 @@ function getCorePrefix() {
|
|
|
336
339
|
return path.join(HOME, '.openagents', 'core');
|
|
337
340
|
}
|
|
338
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Reset the two 30s caches that back binary detection. Call this after
|
|
344
|
+
* install / uninstall so a freshly-created bin dir (e.g. ~/.cursor/bin)
|
|
345
|
+
* isn't masked by a pre-install snapshot of getExtraBinDirs(), and so a
|
|
346
|
+
* `whichBinary(name) === null` cached before install doesn't survive.
|
|
347
|
+
*/
|
|
348
|
+
function clearBinaryLookupCache() {
|
|
349
|
+
extraBinDirsCache = { value: null, at: 0, path: '' };
|
|
350
|
+
whichBinaryCache.clear();
|
|
351
|
+
}
|
|
352
|
+
|
|
339
353
|
module.exports = {
|
|
340
354
|
getExtraBinDirs,
|
|
341
355
|
getEnhancedPATH,
|
|
342
356
|
getEnhancedEnv,
|
|
343
357
|
whichBinary,
|
|
358
|
+
clearBinaryLookupCache,
|
|
344
359
|
getRuntimePrefix,
|
|
345
360
|
getCorePrefix,
|
|
346
361
|
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/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
|
*/
|