@openagents-org/agent-launcher 0.1.0

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/src/paths.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Cross-platform PATH detection.
3
+ *
4
+ * Finds binary directories for Node.js version managers (nvm, fnm, volta),
5
+ * package managers (npm, Homebrew, pip), and standard system locations.
6
+ * Used by installer.js (binary detection) and daemon.js (agent spawning).
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const { execSync } = require('child_process');
14
+
15
+ const IS_WINDOWS = process.platform === 'win32';
16
+ const IS_MACOS = process.platform === 'darwin';
17
+ const SEP = IS_WINDOWS ? ';' : ':';
18
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
19
+
20
+ /**
21
+ * Get all extra binary directories that should be checked beyond process.env.PATH.
22
+ * Returns deduplicated list of existing directories.
23
+ */
24
+ function getExtraBinDirs() {
25
+ const dirs = [];
26
+
27
+ if (IS_WINDOWS) {
28
+ _addWindowsPaths(dirs);
29
+ } else {
30
+ _addUnixPaths(dirs);
31
+ if (IS_MACOS) {
32
+ _addMacPaths(dirs);
33
+ }
34
+ }
35
+
36
+ // Common: ~/.local/bin (pipx, user installs)
37
+ _push(dirs, path.join(HOME, '.local', 'bin'));
38
+
39
+ // Also add the directory containing the current node binary
40
+ try {
41
+ const nodeDir = path.dirname(process.execPath);
42
+ if (nodeDir) _push(dirs, nodeDir);
43
+ } catch {}
44
+
45
+ // Filter to existing directories only, deduplicate
46
+ const seen = new Set();
47
+ const currentPATH = process.env.PATH || '';
48
+ return dirs.filter(d => {
49
+ if (!d || seen.has(d)) return false;
50
+ // Skip if already in PATH (case-insensitive on Windows)
51
+ if (IS_WINDOWS ? currentPATH.toLowerCase().includes(d.toLowerCase()) : currentPATH.includes(d)) return false;
52
+ seen.add(d);
53
+ try {
54
+ return fs.statSync(d).isDirectory();
55
+ } catch {
56
+ return false;
57
+ }
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Build a full PATH string that includes all extra bin dirs prepended.
63
+ */
64
+ function getEnhancedPATH() {
65
+ const extra = getExtraBinDirs();
66
+ const current = process.env.PATH || '';
67
+ if (extra.length === 0) return current;
68
+ return extra.join(SEP) + SEP + current;
69
+ }
70
+
71
+ /**
72
+ * Build an env object with enhanced PATH for spawning subprocesses.
73
+ */
74
+ function getEnhancedEnv(baseEnv) {
75
+ const env = { ...(baseEnv || process.env) };
76
+ const extra = getExtraBinDirs();
77
+ if (extra.length > 0) {
78
+ env.PATH = extra.join(SEP) + SEP + (env.PATH || '');
79
+ }
80
+ if (IS_WINDOWS) {
81
+ // Force UTF-8 output from child processes on non-English Windows locales
82
+ env.PYTHONIOENCODING = env.PYTHONIOENCODING || 'utf-8';
83
+ env.PYTHONUTF8 = env.PYTHONUTF8 || '1';
84
+ env.LANG = env.LANG || 'en_US.UTF-8';
85
+ // Ensure ComSpec points to cmd.exe (Electron may not set it)
86
+ if (!env.ComSpec) {
87
+ const sysRoot = env.SystemRoot || 'C:\\Windows';
88
+ env.ComSpec = path.join(sysRoot, 'System32', 'cmd.exe');
89
+ }
90
+ }
91
+ return env;
92
+ }
93
+
94
+ /**
95
+ * Find a binary by name. Returns full path or null.
96
+ */
97
+ function whichBinary(name) {
98
+ if (!name) return null;
99
+ const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
100
+ try {
101
+ const result = execSync(cmd, {
102
+ encoding: 'utf-8',
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ env: { ...process.env, PATH: getEnhancedPATH() },
105
+ timeout: 5000,
106
+ }).trim();
107
+ return result.split(/\r?\n/)[0] || null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // ---- Windows paths ----
114
+
115
+ function _addWindowsPaths(dirs) {
116
+ const appData = process.env.APPDATA || '';
117
+ const localAppData = process.env.LOCALAPPDATA || '';
118
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
119
+ const sysRoot = process.env.SystemRoot || 'C:\\Windows';
120
+
121
+ // System32 (cmd.exe, powershell, etc) — Electron may not have it
122
+ _push(dirs, path.join(sysRoot, 'System32'));
123
+
124
+ // npm global bin
125
+ if (appData) _push(dirs, path.join(appData, 'npm'));
126
+
127
+ // Node.js install
128
+ _push(dirs, path.join(programFiles, 'nodejs'));
129
+
130
+ // nvm for Windows
131
+ const nvmHome = process.env.NVM_HOME;
132
+ if (nvmHome) {
133
+ _push(dirs, nvmHome);
134
+ // nvm symlink dir
135
+ const nvmSymlink = process.env.NVM_SYMLINK || path.join(programFiles, 'nodejs');
136
+ _push(dirs, nvmSymlink);
137
+ }
138
+
139
+ // fnm
140
+ if (localAppData) _push(dirs, path.join(localAppData, 'fnm_multishells'));
141
+ const fnmDir = process.env.FNM_DIR || path.join(appData, 'fnm');
142
+ if (fnmDir) {
143
+ // fnm aliases — current version
144
+ try {
145
+ const defaultDir = path.join(fnmDir, 'aliases', 'default');
146
+ if (fs.existsSync(defaultDir)) _push(dirs, defaultDir);
147
+ } catch {}
148
+ }
149
+
150
+ // volta
151
+ const voltaHome = process.env.VOLTA_HOME || path.join(localAppData, 'Volta');
152
+ _push(dirs, path.join(voltaHome, 'bin'));
153
+
154
+ // Git (needed for some installers)
155
+ _push(dirs, path.join(programFiles, 'Git', 'cmd'));
156
+ _push(dirs, path.join(programFiles, 'Git', 'bin'));
157
+
158
+ // Python/pip
159
+ if (localAppData) {
160
+ _push(dirs, path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts'));
161
+ _push(dirs, path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts'));
162
+ _push(dirs, path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts'));
163
+ }
164
+ }
165
+
166
+ // ---- Unix paths ----
167
+
168
+ function _addUnixPaths(dirs) {
169
+ // Standard
170
+ _push(dirs, '/usr/local/bin');
171
+ _push(dirs, '/usr/bin');
172
+
173
+ // npm global (varies by install method)
174
+ _push(dirs, path.join(HOME, '.npm-global', 'bin'));
175
+ _push(dirs, path.join(HOME, '.openagents', 'npm-global', 'bin'));
176
+
177
+ // nvm
178
+ const nvmDir = process.env.NVM_DIR || path.join(HOME, '.nvm');
179
+ try {
180
+ // Find current nvm version
181
+ const defaultPath = path.join(nvmDir, 'alias', 'default');
182
+ if (fs.existsSync(defaultPath)) {
183
+ const version = fs.readFileSync(defaultPath, 'utf-8').trim();
184
+ // Resolve alias like 'lts/*' or version number
185
+ const resolved = _resolveNvmVersion(nvmDir, version);
186
+ if (resolved) _push(dirs, path.join(nvmDir, 'versions', 'node', resolved, 'bin'));
187
+ }
188
+ // Also try current symlink
189
+ _push(dirs, path.join(nvmDir, 'current', 'bin'));
190
+ } catch {}
191
+
192
+ // fnm
193
+ const fnmDir = process.env.FNM_DIR || path.join(HOME, '.fnm');
194
+ try {
195
+ const defaultDir = path.join(fnmDir, 'aliases', 'default');
196
+ if (fs.existsSync(defaultDir)) {
197
+ const target = fs.realpathSync(defaultDir);
198
+ _push(dirs, path.join(target, 'bin'));
199
+ }
200
+ } catch {}
201
+
202
+ // volta
203
+ const voltaHome = process.env.VOLTA_HOME || path.join(HOME, '.volta');
204
+ _push(dirs, path.join(voltaHome, 'bin'));
205
+
206
+ // pip/pipx user installs
207
+ _push(dirs, path.join(HOME, '.local', 'bin'));
208
+
209
+ // cargo
210
+ _push(dirs, path.join(HOME, '.cargo', 'bin'));
211
+ }
212
+
213
+ // ---- macOS-specific ----
214
+
215
+ function _addMacPaths(dirs) {
216
+ // Homebrew (Apple Silicon + Intel)
217
+ _push(dirs, '/opt/homebrew/bin');
218
+ _push(dirs, '/opt/homebrew/sbin');
219
+ _push(dirs, '/usr/local/bin');
220
+ _push(dirs, '/usr/local/sbin');
221
+
222
+ // MacPorts
223
+ _push(dirs, '/opt/local/bin');
224
+
225
+ // pkgx
226
+ _push(dirs, path.join(HOME, '.pkgx', 'bin'));
227
+ }
228
+
229
+ // ---- Helpers ----
230
+
231
+ function _push(arr, dir) {
232
+ if (dir) arr.push(dir);
233
+ }
234
+
235
+ function _resolveNvmVersion(nvmDir, alias) {
236
+ // Handle direct version like 'v22.14.0'
237
+ if (alias.startsWith('v')) {
238
+ return alias;
239
+ }
240
+ // Handle aliases like 'lts/*', 'lts/jod', 'default', numeric '22'
241
+ try {
242
+ // Try reading alias file
243
+ const aliasFile = path.join(nvmDir, 'alias', alias.replace('/', path.sep));
244
+ if (fs.existsSync(aliasFile)) {
245
+ const target = fs.readFileSync(aliasFile, 'utf-8').trim();
246
+ return _resolveNvmVersion(nvmDir, target);
247
+ }
248
+ } catch {}
249
+
250
+ // Try finding latest matching version in versions dir
251
+ try {
252
+ const versionsDir = path.join(nvmDir, 'versions', 'node');
253
+ if (fs.existsSync(versionsDir)) {
254
+ const versions = fs.readdirSync(versionsDir)
255
+ .filter(v => v.startsWith('v'))
256
+ .sort()
257
+ .reverse();
258
+ const match = versions.find(v => v.startsWith('v' + alias));
259
+ if (match) return match;
260
+ // Just return the latest
261
+ if (versions.length > 0) return versions[0];
262
+ }
263
+ } catch {}
264
+
265
+ return null;
266
+ }
267
+
268
+ module.exports = {
269
+ getExtraBinDirs,
270
+ getEnhancedPATH,
271
+ getEnhancedEnv,
272
+ whichBinary,
273
+ IS_WINDOWS,
274
+ IS_MACOS,
275
+ SEP,
276
+ };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DEFAULT_REGISTRY_URL = 'https://endpoint.openagents.org/v1/agent-registry';
7
+ const CACHE_FILE = 'agent_catalog.json';
8
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
9
+
10
+ /**
11
+ * Agent registry — fetches the catalog of available agent types.
12
+ *
13
+ * Priority: remote API → local cache (24h) → bundled registry.json
14
+ */
15
+ class Registry {
16
+ constructor(configDir, registryUrl) {
17
+ this.configDir = configDir;
18
+ this.registryUrl = registryUrl || DEFAULT_REGISTRY_URL;
19
+ this.cacheFile = path.join(configDir, CACHE_FILE);
20
+ this._catalog = null; // in-memory cache
21
+ }
22
+
23
+ /**
24
+ * Get the full agent catalog. Tries remote, then cache, then bundled.
25
+ * @returns {Promise<object[]>}
26
+ */
27
+ async getCatalog() {
28
+ if (this._catalog) return this._catalog;
29
+
30
+ // Try cache first (avoids network on every call)
31
+ const cached = this._loadCache();
32
+ if (cached) {
33
+ this._catalog = cached;
34
+ // Refresh in background if stale (but still return cached)
35
+ this._refreshInBackground();
36
+ return cached;
37
+ }
38
+
39
+ // No cache — try remote
40
+ const remote = await this._fetchRemote();
41
+ if (remote) {
42
+ this._catalog = remote;
43
+ return remote;
44
+ }
45
+
46
+ // Fallback to bundled
47
+ this._catalog = this._loadBundled();
48
+ return this._catalog;
49
+ }
50
+
51
+ /**
52
+ * Get catalog synchronously (cache or bundled only, no network).
53
+ */
54
+ getCatalogSync() {
55
+ if (this._catalog) return this._catalog;
56
+ const cached = this._loadCache();
57
+ if (cached) { this._catalog = cached; return cached; }
58
+ this._catalog = this._loadBundled();
59
+ return this._catalog;
60
+ }
61
+
62
+ /**
63
+ * Get env field definitions for an agent type.
64
+ */
65
+ getEnvFields(agentType) {
66
+ const catalog = this.getCatalogSync();
67
+ const entry = catalog.find((e) => e.name === agentType);
68
+ return entry ? (entry.env_config || []) : [];
69
+ }
70
+
71
+ /**
72
+ * Get resolve_env rules for an agent type.
73
+ */
74
+ getResolveRules(agentType) {
75
+ const catalog = this.getCatalogSync();
76
+ const entry = catalog.find((e) => e.name === agentType);
77
+ if (!entry || !entry.resolve_env) return [];
78
+ return entry.resolve_env.rules || [];
79
+ }
80
+
81
+ /**
82
+ * Get a single catalog entry by name.
83
+ */
84
+ getEntry(agentType) {
85
+ const catalog = this.getCatalogSync();
86
+ const entry = catalog.find((e) => e.name === agentType) || null;
87
+ // If the cached entry is missing install info, merge with bundled
88
+ if (entry && !entry.install) {
89
+ const bundled = this._loadBundled();
90
+ const bundledEntry = bundled.find((e) => e.name === agentType);
91
+ if (bundledEntry && bundledEntry.install) {
92
+ return { ...entry, install: bundledEntry.install };
93
+ }
94
+ }
95
+ return entry;
96
+ }
97
+
98
+ /**
99
+ * Force refresh from remote API.
100
+ */
101
+ async refresh() {
102
+ const remote = await this._fetchRemote();
103
+ if (remote) this._catalog = remote;
104
+ return this._catalog || this.getCatalogSync();
105
+ }
106
+
107
+ // -- Internal --
108
+
109
+ _loadCache() {
110
+ try {
111
+ if (!fs.existsSync(this.cacheFile)) return null;
112
+ const stat = fs.statSync(this.cacheFile);
113
+ if (Date.now() - stat.mtimeMs > CACHE_TTL_MS) return null;
114
+ const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8'));
115
+ return Array.isArray(data) ? data : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ _saveCache(data) {
122
+ try {
123
+ fs.mkdirSync(this.configDir, { recursive: true });
124
+ fs.writeFileSync(this.cacheFile, JSON.stringify(data, null, 2), 'utf-8');
125
+ } catch {}
126
+ }
127
+
128
+ _loadBundled() {
129
+ try {
130
+ const bundledPath = path.join(__dirname, '..', 'registry.json');
131
+ if (fs.existsSync(bundledPath)) {
132
+ return JSON.parse(fs.readFileSync(bundledPath, 'utf-8'));
133
+ }
134
+ } catch {}
135
+ return [];
136
+ }
137
+
138
+ async _fetchRemote() {
139
+ try {
140
+ const data = await httpGetJson(this.registryUrl, 5000);
141
+ // API returns { data: [...] } or directly [...]
142
+ const catalog = Array.isArray(data) ? data : (data.data || []);
143
+ if (catalog.length > 0) {
144
+ this._saveCache(catalog);
145
+ }
146
+ return catalog;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ _refreshInBackground() {
153
+ // Check if cache is older than TTL
154
+ try {
155
+ const stat = fs.statSync(this.cacheFile);
156
+ if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) return;
157
+ } catch {
158
+ return;
159
+ }
160
+ // Fire and forget
161
+ this._fetchRemote().catch(() => {});
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Simple HTTP GET that returns parsed JSON. No external dependencies.
167
+ */
168
+ function httpGetJson(url, timeoutMs = 5000) {
169
+ return new Promise((resolve, reject) => {
170
+ const parsedUrl = new URL(url);
171
+ const transport = parsedUrl.protocol === 'https:' ? require('https') : require('http');
172
+
173
+ const req = transport.get(url, { timeout: timeoutMs }, (res) => {
174
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
175
+ // Follow redirect
176
+ httpGetJson(res.headers.location, timeoutMs).then(resolve, reject);
177
+ return;
178
+ }
179
+ if (res.statusCode >= 400) {
180
+ reject(new Error(`HTTP ${res.statusCode}`));
181
+ res.resume();
182
+ return;
183
+ }
184
+ let data = '';
185
+ res.on('data', (chunk) => { data += chunk; });
186
+ res.on('end', () => {
187
+ try { resolve(JSON.parse(data)); }
188
+ catch (e) { reject(e); }
189
+ });
190
+ });
191
+
192
+ req.on('error', reject);
193
+ req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
194
+ });
195
+ }
196
+
197
+ module.exports = { Registry };