@openagents-org/agent-connector 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/README.md +86 -0
- package/bin/agent-connector.js +4 -0
- package/package.json +31 -0
- package/registry.json +457 -0
- package/src/cli.js +526 -0
- package/src/config.js +299 -0
- package/src/daemon.js +541 -0
- package/src/env.js +111 -0
- package/src/index.js +198 -0
- package/src/installer.js +228 -0
- package/src/registry.js +188 -0
- package/src/utils.js +93 -0
- package/src/workspace-client.js +194 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Config } = require('./config');
|
|
4
|
+
const { EnvManager } = require('./env');
|
|
5
|
+
const { Registry } = require('./registry');
|
|
6
|
+
const { Installer } = require('./installer');
|
|
7
|
+
const { Daemon } = require('./daemon');
|
|
8
|
+
const { WorkspaceClient } = require('./workspace-client');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Main entry point for the agent-connector library.
|
|
12
|
+
* Provides agent management, configuration, and lifecycle control.
|
|
13
|
+
*/
|
|
14
|
+
class AgentConnector {
|
|
15
|
+
constructor(opts = {}) {
|
|
16
|
+
const configDir = opts.configDir || AgentConnector.defaultConfigDir();
|
|
17
|
+
this.config = new Config(configDir);
|
|
18
|
+
this.env = new EnvManager(configDir);
|
|
19
|
+
this.registry = new Registry(configDir, opts.registryUrl);
|
|
20
|
+
this.installer = new Installer(this.registry, configDir);
|
|
21
|
+
this.workspace = new WorkspaceClient(opts.workspaceEndpoint);
|
|
22
|
+
this._configDir = configDir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static defaultConfigDir() {
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
return path.join(os.homedir(), '.openagents');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// -- Registry --
|
|
32
|
+
|
|
33
|
+
async getCatalog() {
|
|
34
|
+
const catalog = await this.registry.getCatalog();
|
|
35
|
+
return catalog.map((entry) => ({
|
|
36
|
+
...entry,
|
|
37
|
+
installed: this.installer.isInstalled(entry.name),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getEnvFields(agentType) {
|
|
42
|
+
return this.registry.getEnvFields(agentType);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// -- Install / Uninstall --
|
|
46
|
+
|
|
47
|
+
async install(agentType) {
|
|
48
|
+
return this.installer.install(agentType);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async uninstall(agentType) {
|
|
52
|
+
return this.installer.uninstall(agentType);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
isInstalled(agentType) {
|
|
56
|
+
return this.installer.isInstalled(agentType);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// -- Agent CRUD --
|
|
60
|
+
|
|
61
|
+
listAgents() {
|
|
62
|
+
const agents = this.config.getAgents();
|
|
63
|
+
const networks = this.config.getNetworks();
|
|
64
|
+
return agents.map((a) => {
|
|
65
|
+
const agentEnv = this.env.load(a.type);
|
|
66
|
+
const network = networks.find((n) => n.slug === a.network || n.id === a.network);
|
|
67
|
+
return {
|
|
68
|
+
name: a.name,
|
|
69
|
+
type: a.type || 'openclaw',
|
|
70
|
+
role: a.role || 'worker',
|
|
71
|
+
network: a.network || null,
|
|
72
|
+
networkName: network ? (network.name || network.slug) : null,
|
|
73
|
+
path: a.path || null,
|
|
74
|
+
env: { ...agentEnv, ...(a.env || {}) },
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
addAgent({ name, type, role, path }) {
|
|
80
|
+
this.config.addAgent({ name, type: type || 'openclaw', role: role || 'worker', path });
|
|
81
|
+
return { success: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
removeAgent(name) {
|
|
85
|
+
this.config.removeAgent(name);
|
|
86
|
+
return { success: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// -- Env config --
|
|
90
|
+
|
|
91
|
+
getAgentEnv(agentType) {
|
|
92
|
+
return this.env.load(agentType);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
saveAgentEnv(agentType, env) {
|
|
96
|
+
this.env.save(agentType, env);
|
|
97
|
+
return { success: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
resolveAgentEnv(agentType, saved) {
|
|
101
|
+
return this.env.resolve(agentType, saved, this.registry);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -- Workspace --
|
|
105
|
+
|
|
106
|
+
listWorkspaces() {
|
|
107
|
+
return this.config.getNetworks().map((n) => ({
|
|
108
|
+
id: n.id,
|
|
109
|
+
slug: n.slug,
|
|
110
|
+
name: n.name || n.slug,
|
|
111
|
+
endpoint: n.endpoint || '',
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
connectWorkspace(agentName, networkSlug) {
|
|
116
|
+
this.config.setAgentNetwork(agentName, networkSlug);
|
|
117
|
+
return { success: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
disconnectWorkspace(agentName) {
|
|
121
|
+
this.config.setAgentNetwork(agentName, null);
|
|
122
|
+
return { success: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -- Daemon lifecycle --
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a Daemon instance for this connector's config.
|
|
129
|
+
*/
|
|
130
|
+
createDaemon() {
|
|
131
|
+
return new Daemon(this.config, this.env, this.registry);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Start daemon in background (daemonize).
|
|
136
|
+
*/
|
|
137
|
+
startDaemon(foregroundArgs) {
|
|
138
|
+
Daemon.daemonize(this._configDir, foregroundArgs);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Stop running daemon.
|
|
143
|
+
*/
|
|
144
|
+
stopDaemon() {
|
|
145
|
+
return Daemon.stopDaemon(this._configDir);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get daemon PID if running, null otherwise.
|
|
150
|
+
*/
|
|
151
|
+
getDaemonPid() {
|
|
152
|
+
return Daemon.readDaemonPid(this._configDir);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get agent status from daemon status file.
|
|
157
|
+
*/
|
|
158
|
+
getDaemonStatus() {
|
|
159
|
+
return this.config.getStatus();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Send a command to the running daemon via daemon.cmd file.
|
|
164
|
+
*/
|
|
165
|
+
sendDaemonCommand(cmd) {
|
|
166
|
+
this.config.writeCommand(cmd);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get daemon logs, optionally filtered by agent name.
|
|
171
|
+
*/
|
|
172
|
+
getLogs(agentName, lines = 200) {
|
|
173
|
+
return this.config.getLogs(agentName, lines);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -- Workspace API --
|
|
177
|
+
|
|
178
|
+
async createWorkspace(opts) {
|
|
179
|
+
return this.workspace.createWorkspace(opts);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async joinWorkspace(agentName, token, opts) {
|
|
183
|
+
return this.workspace.joinNetwork(agentName, token, opts);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async resolveToken(token) {
|
|
187
|
+
return this.workspace.resolveToken(token);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -- LLM test --
|
|
191
|
+
|
|
192
|
+
async testLLM(env) {
|
|
193
|
+
const { testLLMConnection } = require('./utils');
|
|
194
|
+
return testLLMConnection(env);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { AgentConnector, Daemon, WorkspaceClient };
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync, exec } = require('child_process');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manages installation and uninstallation of agent runtimes.
|
|
9
|
+
*
|
|
10
|
+
* Install markers are stored in two places for compatibility with the Python SDK:
|
|
11
|
+
* 1. ~/.openagents/installed_agents.json (JSON array of names)
|
|
12
|
+
* 2. ~/.openagents/installed/<name> (empty marker files)
|
|
13
|
+
*/
|
|
14
|
+
class Installer {
|
|
15
|
+
constructor(registry, configDir) {
|
|
16
|
+
this.registry = registry;
|
|
17
|
+
this.configDir = configDir;
|
|
18
|
+
this.markersFile = path.join(configDir, 'installed_agents.json');
|
|
19
|
+
this.markersDir = path.join(configDir, 'installed');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the current platform key: 'macos', 'linux', or 'windows'.
|
|
24
|
+
*/
|
|
25
|
+
static platform() {
|
|
26
|
+
const p = process.platform;
|
|
27
|
+
if (p === 'darwin') return 'macos';
|
|
28
|
+
if (p === 'win32') return 'windows';
|
|
29
|
+
return 'linux';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if an agent type is installed.
|
|
34
|
+
* Checks binary on PATH first, then marker files.
|
|
35
|
+
*/
|
|
36
|
+
isInstalled(agentType) {
|
|
37
|
+
if (this._whichBinary(agentType)) return true;
|
|
38
|
+
return this._hasMarker(agentType);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find the binary path for an agent type.
|
|
43
|
+
*/
|
|
44
|
+
which(agentType) {
|
|
45
|
+
return this._whichBinary(agentType);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Install an agent runtime.
|
|
50
|
+
* @returns {Promise<{success: boolean, output: string}>}
|
|
51
|
+
*/
|
|
52
|
+
async install(agentType) {
|
|
53
|
+
const entry = this.registry.getEntry(agentType);
|
|
54
|
+
if (!entry || !entry.install) {
|
|
55
|
+
throw new Error(`No install definition for agent type: ${agentType}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cmd = this._getInstallCommand(entry.install);
|
|
59
|
+
if (!cmd) {
|
|
60
|
+
throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const output = await this._execShell(cmd);
|
|
64
|
+
this._markInstalled(agentType);
|
|
65
|
+
return { success: true, output };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Uninstall an agent runtime.
|
|
70
|
+
* @returns {Promise<{success: boolean, output: string}>}
|
|
71
|
+
*/
|
|
72
|
+
async uninstall(agentType) {
|
|
73
|
+
const entry = this.registry.getEntry(agentType);
|
|
74
|
+
if (!entry || !entry.install) {
|
|
75
|
+
throw new Error(`No install definition for agent type: ${agentType}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const installCmd = this._getInstallCommand(entry.install);
|
|
79
|
+
const uninstallCmd = this._deriveUninstallCommand(installCmd);
|
|
80
|
+
if (!uninstallCmd) {
|
|
81
|
+
throw new Error(`Cannot derive uninstall command for ${agentType}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const output = await this._execShell(uninstallCmd);
|
|
85
|
+
this._markUninstalled(agentType);
|
|
86
|
+
return { success: true, output };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get install command for current platform.
|
|
91
|
+
*/
|
|
92
|
+
_getInstallCommand(installCfg) {
|
|
93
|
+
const plat = Installer.platform();
|
|
94
|
+
return installCfg[plat] || installCfg.command || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Derive uninstall command from install command.
|
|
99
|
+
*/
|
|
100
|
+
_deriveUninstallCommand(installCmd) {
|
|
101
|
+
if (!installCmd) return null;
|
|
102
|
+
|
|
103
|
+
// npm install -g <pkg> → npm uninstall -g <pkg>
|
|
104
|
+
if (installCmd.includes('npm install')) {
|
|
105
|
+
return installCmd
|
|
106
|
+
.replace('npm install -g', 'npm uninstall -g')
|
|
107
|
+
.replace('npm install', 'npm uninstall')
|
|
108
|
+
.replace(/@latest/g, '')
|
|
109
|
+
.replace(/@[\d.]+/g, '');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// pip install <pkg> → pip uninstall -y <pkg>
|
|
113
|
+
if (installCmd.includes('pip install') || installCmd.includes('pip3 install')) {
|
|
114
|
+
return installCmd
|
|
115
|
+
.replace('pip install', 'pip uninstall -y')
|
|
116
|
+
.replace('pip3 install', 'pip3 uninstall -y');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// pipx install <pkg> → pipx uninstall <pkg>
|
|
120
|
+
if (installCmd.includes('pipx install')) {
|
|
121
|
+
return installCmd.replace('pipx install', 'pipx uninstall');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Find a binary on PATH.
|
|
129
|
+
*/
|
|
130
|
+
_whichBinary(agentType) {
|
|
131
|
+
const entry = this.registry.getEntry(agentType);
|
|
132
|
+
const binary = entry && entry.install ? entry.install.binary : agentType;
|
|
133
|
+
if (!binary) return null;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const cmd = process.platform === 'win32' ? `where ${binary}` : `which ${binary}`;
|
|
137
|
+
const result = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
138
|
+
return result.split('\n')[0] || null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// -- Markers --
|
|
145
|
+
|
|
146
|
+
_hasMarker(agentType) {
|
|
147
|
+
// Check per-agent marker file first (faster)
|
|
148
|
+
try {
|
|
149
|
+
if (fs.existsSync(path.join(this.markersDir, agentType))) return true;
|
|
150
|
+
} catch {}
|
|
151
|
+
|
|
152
|
+
// Check JSON markers file
|
|
153
|
+
try {
|
|
154
|
+
if (fs.existsSync(this.markersFile)) {
|
|
155
|
+
const data = JSON.parse(fs.readFileSync(this.markersFile, 'utf-8'));
|
|
156
|
+
if (Array.isArray(data) && data.includes(agentType)) return true;
|
|
157
|
+
}
|
|
158
|
+
} catch {}
|
|
159
|
+
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_markInstalled(agentType) {
|
|
164
|
+
// JSON file
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
167
|
+
let markers = [];
|
|
168
|
+
try {
|
|
169
|
+
markers = JSON.parse(fs.readFileSync(this.markersFile, 'utf-8'));
|
|
170
|
+
if (!Array.isArray(markers)) markers = [];
|
|
171
|
+
} catch {}
|
|
172
|
+
if (!markers.includes(agentType)) {
|
|
173
|
+
markers.push(agentType);
|
|
174
|
+
markers.sort();
|
|
175
|
+
}
|
|
176
|
+
fs.writeFileSync(this.markersFile, JSON.stringify(markers), 'utf-8');
|
|
177
|
+
} catch {}
|
|
178
|
+
|
|
179
|
+
// Per-agent marker file
|
|
180
|
+
try {
|
|
181
|
+
fs.mkdirSync(this.markersDir, { recursive: true });
|
|
182
|
+
fs.writeFileSync(path.join(this.markersDir, agentType), '', 'utf-8');
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_markUninstalled(agentType) {
|
|
187
|
+
// JSON file
|
|
188
|
+
try {
|
|
189
|
+
if (fs.existsSync(this.markersFile)) {
|
|
190
|
+
let markers = JSON.parse(fs.readFileSync(this.markersFile, 'utf-8'));
|
|
191
|
+
if (Array.isArray(markers)) {
|
|
192
|
+
markers = markers.filter((m) => m !== agentType);
|
|
193
|
+
fs.writeFileSync(this.markersFile, JSON.stringify(markers), 'utf-8');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {}
|
|
197
|
+
|
|
198
|
+
// Per-agent marker file
|
|
199
|
+
try {
|
|
200
|
+
const markerFile = path.join(this.markersDir, agentType);
|
|
201
|
+
if (fs.existsSync(markerFile)) fs.unlinkSync(markerFile);
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// -- Shell exec --
|
|
206
|
+
|
|
207
|
+
_execShell(cmd, timeoutMs = 300000) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
exec(cmd, {
|
|
210
|
+
encoding: 'utf-8',
|
|
211
|
+
timeout: timeoutMs,
|
|
212
|
+
shell: true,
|
|
213
|
+
env: { ...process.env },
|
|
214
|
+
}, (error, stdout, stderr) => {
|
|
215
|
+
const output = ((stdout || '') + '\n' + (stderr || '')).trim();
|
|
216
|
+
if (error) {
|
|
217
|
+
const err = new Error(output || error.message);
|
|
218
|
+
err.exitCode = error.code;
|
|
219
|
+
reject(err);
|
|
220
|
+
} else {
|
|
221
|
+
resolve(output);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { Installer };
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
return catalog.find((e) => e.name === agentType) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Force refresh from remote API.
|
|
91
|
+
*/
|
|
92
|
+
async refresh() {
|
|
93
|
+
const remote = await this._fetchRemote();
|
|
94
|
+
if (remote) this._catalog = remote;
|
|
95
|
+
return this._catalog || this.getCatalogSync();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -- Internal --
|
|
99
|
+
|
|
100
|
+
_loadCache() {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(this.cacheFile)) return null;
|
|
103
|
+
const stat = fs.statSync(this.cacheFile);
|
|
104
|
+
if (Date.now() - stat.mtimeMs > CACHE_TTL_MS) return null;
|
|
105
|
+
const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8'));
|
|
106
|
+
return Array.isArray(data) ? data : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_saveCache(data) {
|
|
113
|
+
try {
|
|
114
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
115
|
+
fs.writeFileSync(this.cacheFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_loadBundled() {
|
|
120
|
+
try {
|
|
121
|
+
const bundledPath = path.join(__dirname, '..', 'registry.json');
|
|
122
|
+
if (fs.existsSync(bundledPath)) {
|
|
123
|
+
return JSON.parse(fs.readFileSync(bundledPath, 'utf-8'));
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async _fetchRemote() {
|
|
130
|
+
try {
|
|
131
|
+
const data = await httpGetJson(this.registryUrl, 5000);
|
|
132
|
+
// API returns { data: [...] } or directly [...]
|
|
133
|
+
const catalog = Array.isArray(data) ? data : (data.data || []);
|
|
134
|
+
if (catalog.length > 0) {
|
|
135
|
+
this._saveCache(catalog);
|
|
136
|
+
}
|
|
137
|
+
return catalog;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_refreshInBackground() {
|
|
144
|
+
// Check if cache is older than TTL
|
|
145
|
+
try {
|
|
146
|
+
const stat = fs.statSync(this.cacheFile);
|
|
147
|
+
if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) return;
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Fire and forget
|
|
152
|
+
this._fetchRemote().catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Simple HTTP GET that returns parsed JSON. No external dependencies.
|
|
158
|
+
*/
|
|
159
|
+
function httpGetJson(url, timeoutMs = 5000) {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const parsedUrl = new URL(url);
|
|
162
|
+
const transport = parsedUrl.protocol === 'https:' ? require('https') : require('http');
|
|
163
|
+
|
|
164
|
+
const req = transport.get(url, { timeout: timeoutMs }, (res) => {
|
|
165
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
166
|
+
// Follow redirect
|
|
167
|
+
httpGetJson(res.headers.location, timeoutMs).then(resolve, reject);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (res.statusCode >= 400) {
|
|
171
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
172
|
+
res.resume();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
let data = '';
|
|
176
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
177
|
+
res.on('end', () => {
|
|
178
|
+
try { resolve(JSON.parse(data)); }
|
|
179
|
+
catch (e) { reject(e); }
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
req.on('error', reject);
|
|
184
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { Registry };
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test an LLM connection by sending a minimal inference request.
|
|
5
|
+
*
|
|
6
|
+
* Supports OpenAI-compatible and Anthropic APIs.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} env - Env vars (LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, etc.)
|
|
9
|
+
* @returns {Promise<{success: boolean, model?: string, response?: string, error?: string}>}
|
|
10
|
+
*/
|
|
11
|
+
function testLLMConnection(env) {
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
|
|
15
|
+
const apiKey = env.LLM_API_KEY || env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY || '';
|
|
16
|
+
if (!apiKey) return Promise.resolve({ success: false, error: 'No API key provided' });
|
|
17
|
+
|
|
18
|
+
let baseUrl = (env.LLM_BASE_URL || env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, '');
|
|
19
|
+
const model = env.LLM_MODEL || env.OPENCLAW_MODEL || '';
|
|
20
|
+
const isAnthropic = baseUrl.includes('anthropic');
|
|
21
|
+
|
|
22
|
+
if (!isAnthropic && !baseUrl.endsWith('/v1')) {
|
|
23
|
+
baseUrl += '/v1';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
let url, headers, body;
|
|
28
|
+
|
|
29
|
+
if (isAnthropic) {
|
|
30
|
+
url = 'https://api.anthropic.com/v1/messages';
|
|
31
|
+
headers = {
|
|
32
|
+
'x-api-key': apiKey,
|
|
33
|
+
'anthropic-version': '2023-06-01',
|
|
34
|
+
'content-type': 'application/json',
|
|
35
|
+
};
|
|
36
|
+
body = JSON.stringify({
|
|
37
|
+
model: model || 'claude-sonnet-4-20250514',
|
|
38
|
+
max_tokens: 32,
|
|
39
|
+
messages: [{ role: 'user', content: 'Say hi in 5 words.' }],
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
url = baseUrl + '/chat/completions';
|
|
43
|
+
headers = {
|
|
44
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
};
|
|
47
|
+
body = JSON.stringify({
|
|
48
|
+
model: model || 'gpt-4o-mini',
|
|
49
|
+
max_tokens: 32,
|
|
50
|
+
messages: [{ role: 'user', content: 'Say hi in 5 words.' }],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parsedUrl = new URL(url);
|
|
55
|
+
const transport = parsedUrl.protocol === 'https:' ? https : http;
|
|
56
|
+
|
|
57
|
+
const req = transport.request(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
|
|
60
|
+
timeout: 15000,
|
|
61
|
+
}, (res) => {
|
|
62
|
+
let data = '';
|
|
63
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
64
|
+
res.on('end', () => {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(data);
|
|
67
|
+
let text, usedModel;
|
|
68
|
+
if (isAnthropic) {
|
|
69
|
+
text = (parsed.content || [{}])[0].text || '';
|
|
70
|
+
usedModel = parsed.model || model || '?';
|
|
71
|
+
} else {
|
|
72
|
+
text = (parsed.choices || [{}])[0]?.message?.content || '';
|
|
73
|
+
usedModel = parsed.model || model || '?';
|
|
74
|
+
}
|
|
75
|
+
if (res.statusCode >= 400) {
|
|
76
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}: ${data.slice(0, 200)}` });
|
|
77
|
+
} else {
|
|
78
|
+
resolve({ success: true, model: usedModel, response: text.slice(0, 80) });
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
resolve({ success: false, error: `Invalid response: ${data.slice(0, 200)}` });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
req.on('error', (e) => resolve({ success: false, error: e.message }));
|
|
87
|
+
req.on('timeout', () => { req.destroy(); resolve({ success: false, error: 'Request timed out' }); });
|
|
88
|
+
req.write(body);
|
|
89
|
+
req.end();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { testLLMConnection };
|