@openagents-org/agent-launcher 0.2.131 → 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.
@@ -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
+ };
@@ -17,7 +17,7 @@ const { execSync, spawnSync } = require('child_process');
17
17
 
18
18
  const PKG_NAME = '@openagents-org/agent-launcher';
19
19
  const CACHE_FILE = path.join(os.homedir(), '.openagents', '.update-check.json');
20
- const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
20
+ const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
21
21
 
22
22
  function currentVersion() {
23
23
  return require('../package.json').version;
@@ -161,8 +161,8 @@ function promptYes(question, timeoutMs = 30000) {
161
161
  }
162
162
 
163
163
  /**
164
- * Check, notify, and offer to update. Exits the process on successful update.
165
- * Safe to call at the top of any user-facing CLI command.
164
+ * Check and print a warning if a newer version is available.
165
+ * Never prompts or auto-updates — users run `agn update` explicitly.
166
166
  */
167
167
  async function notifyAndMaybeUpdate() {
168
168
  let info;
@@ -170,29 +170,9 @@ async function notifyAndMaybeUpdate() {
170
170
  if (!info || !info.isNewer) return;
171
171
 
172
172
  process.stderr.write(
173
- `\n[launcher] Update available: ${info.current} → ${info.latest}\n`
173
+ `\n[launcher] Update available: ${info.current} → ${info.latest}\n` +
174
+ '[launcher] Run `agn update` to upgrade.\n\n'
174
175
  );
175
-
176
- const interactive = process.stdin.isTTY && process.stdout.isTTY;
177
- if (!interactive) {
178
- process.stderr.write(
179
- '[launcher] Run `agn update` (or re-run install.sh) to upgrade.\n\n'
180
- );
181
- return;
182
- }
183
-
184
- const accepted = await promptYes('[launcher] Update now? [Y/n] ');
185
- if (!accepted) {
186
- process.stderr.write('[launcher] Skipped. Run `agn update` later to upgrade.\n\n');
187
- return;
188
- }
189
-
190
- const ok = runUpdate();
191
- if (ok) {
192
- process.stderr.write(`[launcher] Updated to ${info.latest}. Re-run your command.\n`);
193
- process.exit(0);
194
- }
195
- process.stderr.write('[launcher] Update failed — continuing with current version.\n\n');
196
176
  }
197
177
 
198
178
  module.exports = {
@@ -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
  */
@@ -433,6 +457,7 @@ class WorkspaceClient {
433
457
  agentName: (a.address || '').replace('openagents:', ''),
434
458
  role: a.role || 'member',
435
459
  status: a.status || 'offline',
460
+ enabledSkills: a.enabled_skills || null,
436
461
  }));
437
462
  }
438
463
 
@@ -659,6 +684,74 @@ class WorkspaceClient {
659
684
  return data.data || data;
660
685
  }
661
686
 
687
+ // ── Notifications ──
688
+
689
+ async createNotification(workspaceId, token, { title, message, priority, channel, source } = {}) {
690
+ const body = {
691
+ title,
692
+ message,
693
+ priority: priority || 'normal',
694
+ network: workspaceId,
695
+ source: source || 'openagents:unknown',
696
+ };
697
+ if (channel) body.channel = channel;
698
+ const data = await this._post('/v1/notifications', body, this._wsHeaders(token));
699
+ return data.data || data;
700
+ }
701
+
702
+ async listNotifications(workspaceId, token, { limit = 50 } = {}) {
703
+ const params = new URLSearchParams({
704
+ network: workspaceId,
705
+ limit: String(limit),
706
+ });
707
+ const data = await this._get(`/v1/notifications?${params}`, this._wsHeaders(token));
708
+ return data.data || data;
709
+ }
710
+
711
+ // ── Knowledge Base ──
712
+
713
+ async listKnowledge(workspaceId, token, { limit = 100 } = {}) {
714
+ const params = new URLSearchParams({
715
+ network: workspaceId,
716
+ limit: String(limit),
717
+ });
718
+ const data = await this._get(`/v1/knowledge?${params}`, this._wsHeaders(token));
719
+ return data.data || data;
720
+ }
721
+
722
+ async getKnowledge(workspaceId, token, entryId) {
723
+ const params = new URLSearchParams({ network: workspaceId });
724
+ const data = await this._get(`/v1/knowledge/${entryId}?${params}`, this._wsHeaders(token));
725
+ return data.data || data;
726
+ }
727
+
728
+ async getKnowledgeBySlug(workspaceId, token, slug) {
729
+ const params = new URLSearchParams({ network: workspaceId });
730
+ const data = await this._get(`/v1/knowledge/by-slug/${slug}?${params}`, this._wsHeaders(token));
731
+ return data.data || data;
732
+ }
733
+
734
+ async createKnowledge(workspaceId, token, { title, content, description, source } = {}) {
735
+ const body = { title, content, network: workspaceId, source: source || 'openagents:unknown' };
736
+ if (description) body.description = description;
737
+ const data = await this._post('/v1/knowledge', body, this._wsHeaders(token));
738
+ return data.data || data;
739
+ }
740
+
741
+ async updateKnowledge(workspaceId, token, entryId, { title, content, description, source } = {}) {
742
+ const body = { network: workspaceId, source: source || 'openagents:unknown' };
743
+ if (title !== undefined) body.title = title;
744
+ if (content !== undefined) body.content = content;
745
+ if (description !== undefined) body.description = description;
746
+ const data = await this._put(`/v1/knowledge/${entryId}`, body, this._wsHeaders(token));
747
+ return data.data || data;
748
+ }
749
+
750
+ async deleteKnowledge(workspaceId, token, entryId) {
751
+ const data = await this._delete(`/v1/knowledge/${entryId}`, this._wsHeaders(token), workspaceId);
752
+ return data.data || data;
753
+ }
754
+
662
755
  // ── Internal helpers ──
663
756
 
664
757
  /**