@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.132",
3
+ "version": "0.2.133",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
@@ -168,7 +168,7 @@ class CodexAdapter extends BaseAdapter {
168
168
  }
169
169
 
170
170
  _buildSystemContext(channelName) {
171
- return buildOpenclawSystemPrompt({
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, _payload) {
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
- const token = positional[1] || flags.token;
282
- if (!name || !token) {
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
- try { fs.unlinkSync(path.join(this.markersDir, agentType)); } catch {}
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
- exec(cmd, {
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
+ };
@@ -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
  */