@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.132",
3
+ "version": "0.2.134",
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
  // ------------------------------------------------------------------
@@ -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'),
@@ -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
- const token = positional[1] || flags.token;
282
- if (!name || !token) {
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
- 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
 
@@ -406,29 +414,72 @@ class Installer {
406
414
  await this.installNodejs(onData);
407
415
  }
408
416
 
409
- // Resolve npm command
410
- let cmd = rawCmd;
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
- // Use --save so npm tracks the package in package.json (prevents pruning on next install)
415
- const args = rawCmd.replace('npm install', 'install --loglevel=verbose --save').replace(' -g ', ` --prefix "${prefixDir}" `);
416
- cmd = this._resolveNpmCommand(args);
417
- } else if (rawCmd.startsWith('pip install') || rawCmd.startsWith('pipx install')) {
418
- cmd = rawCmd; // pip commands stay as-is
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(`$ ${cmd}\n\n`);
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
- const proc = spawn(cmd, [], { shell, env, cwd: installCwd, stdio: ['ignore', 'pipe', 'pipe'] });
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: cmd });
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: ${cmd}\n\n${tail}`
467
- : `Install failed with exit code ${code}\nCommand: ${cmd}`;
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.PATH || '').includes(d)) {
795
- env.PATH = d + sep + (env.PATH || '');
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
- exec(cmd, {
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
- env.PATH = extra.join(SEP) + SEP + (env.PATH || '');
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 (!agent.workspace) items.push({ key: 'c', label: 'Connect' });
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
- const ws = r.workspace || '';
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 (!agent.workspace) actions.push({ label: 'Connect to Workspace', key: 'connect' });
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.configured && !a.workspace) showConnectWorkspaceScreen(a.name);
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 };
@@ -104,32 +104,75 @@ function detectInstallPrefix() {
104
104
  return null;
105
105
  }
106
106
 
107
- function findNpmBin() {
108
- const nodeDir = path.dirname(process.execPath);
109
- for (const name of ['npm', 'npm.cmd']) {
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 (fs.existsSync(candidate)) return candidate;
121
+ if (exists(candidate)) return candidate;
112
122
  }
113
- try {
114
- return execSync(process.platform === 'win32' ? 'where npm' : 'which npm', {
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
- } catch { return 'npm'; }
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 prefix = detectInstallPrefix();
126
- const npmBin = findNpmBin();
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 = spawnSync(npmBin, args, { stdio: 'inherit' });
132
- return r.status === 0;
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
  };
@@ -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
  */