@latchagent/latchctl 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 +44 -0
- package/index.js +517 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @latchagent/latchctl
|
|
2
|
+
|
|
3
|
+
Bootstrap and operate Latch integrations from the terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @latchagent/latchctl
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## OpenClaw Setup (One Command)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
latchctl openclaw setup \
|
|
15
|
+
--cloud-url https://your-latch-url.com \
|
|
16
|
+
--workspace-id ws_xxx \
|
|
17
|
+
--upstream-id upstream_xxx \
|
|
18
|
+
--agent-key sk-latch-xxx
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This command installs and enables `@latchagent/openclaw-latch-guard`, writes OpenClaw plugin config, and restarts gateway service.
|
|
22
|
+
|
|
23
|
+
## Print Manual Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
latchctl openclaw print-config \
|
|
27
|
+
--cloud-url https://your-latch-url.com \
|
|
28
|
+
--workspace-id ws_xxx \
|
|
29
|
+
--upstream-id upstream_xxx \
|
|
30
|
+
--agent-key sk-latch-xxx
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Authorize Test
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
latchctl openclaw test \
|
|
37
|
+
--cloud-url https://your-latch-url.com \
|
|
38
|
+
--workspace-id ws_xxx \
|
|
39
|
+
--upstream-id upstream_xxx \
|
|
40
|
+
--agent-key sk-latch-xxx \
|
|
41
|
+
--tool-name openclaw:read \
|
|
42
|
+
--action-class read \
|
|
43
|
+
--risk-level low
|
|
44
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import readline from 'node:readline/promises';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
|
|
8
|
+
const VERSION = '0.1.0';
|
|
9
|
+
const DEFAULT_PLUGIN_SPEC = '@latchagent/openclaw-latch-guard@latest';
|
|
10
|
+
const DEFAULT_PLUGIN_ID = 'openclaw-latch-guard';
|
|
11
|
+
|
|
12
|
+
function printUsage() {
|
|
13
|
+
console.log(`
|
|
14
|
+
latchctl v${VERSION}
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
latchctl openclaw setup [options]
|
|
18
|
+
latchctl openclaw print-config [options]
|
|
19
|
+
latchctl openclaw test [options]
|
|
20
|
+
latchctl help
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
openclaw setup Install + configure Latch Guard for OpenClaw
|
|
24
|
+
openclaw print-config Print exact OpenClaw commands for manual setup
|
|
25
|
+
openclaw test Send a direct authorize test request
|
|
26
|
+
|
|
27
|
+
Global:
|
|
28
|
+
--help Show command help
|
|
29
|
+
--non-interactive Do not prompt for missing values
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printOpenclawUsage() {
|
|
34
|
+
console.log(`
|
|
35
|
+
openclaw commands:
|
|
36
|
+
|
|
37
|
+
latchctl openclaw setup [options]
|
|
38
|
+
--cloud-url <url> Latch URL (env: LATCH_CLOUD_URL)
|
|
39
|
+
--workspace-id <id> Workspace ID (env: LATCH_WORKSPACE_ID)
|
|
40
|
+
--upstream-id <id> Connector ID (env: LATCH_UPSTREAM_ID)
|
|
41
|
+
--agent-key <key> Agent key (env: LATCH_AGENT_KEY)
|
|
42
|
+
--plugin-spec <spec> Plugin npm spec (default: ${DEFAULT_PLUGIN_SPEC})
|
|
43
|
+
--plugin-id <id> Plugin id (default: ${DEFAULT_PLUGIN_ID})
|
|
44
|
+
--mode <monitor|enforce> Plugin mode (default: enforce)
|
|
45
|
+
--wait-for-approval <true|false> Default: true
|
|
46
|
+
--approval-timeout-seconds <n> Default: 600
|
|
47
|
+
--fail-closed <true|false> Default: true
|
|
48
|
+
--log-file <path> Optional plugin log file
|
|
49
|
+
--openclaw-bin <bin> Default: openclaw
|
|
50
|
+
--skip-install <true|false> Default: false
|
|
51
|
+
--skip-enable <true|false> Default: false
|
|
52
|
+
--skip-restart <true|false> Default: false
|
|
53
|
+
--dry-run <true|false> Default: false
|
|
54
|
+
--non-interactive Do not prompt for missing values
|
|
55
|
+
|
|
56
|
+
latchctl openclaw print-config [options]
|
|
57
|
+
Same input options as setup. Prints manual commands only.
|
|
58
|
+
|
|
59
|
+
latchctl openclaw test [options]
|
|
60
|
+
--cloud-url <url> Latch URL
|
|
61
|
+
--workspace-id <id> Workspace ID
|
|
62
|
+
--upstream-id <id> Connector ID
|
|
63
|
+
--agent-key <key> Agent key
|
|
64
|
+
--tool-name <name> Default: openclaw:read
|
|
65
|
+
--action-class <class> Default: read
|
|
66
|
+
--risk-level <level> Default: low
|
|
67
|
+
--args-json <json> Default: {}
|
|
68
|
+
--approval-token <token> Optional approval token for retry
|
|
69
|
+
--require-allowed <true|false> Exit non-zero if not allowed
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseArgs(argv) {
|
|
74
|
+
const flags = {};
|
|
75
|
+
const positionals = [];
|
|
76
|
+
|
|
77
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
78
|
+
const token = argv[index];
|
|
79
|
+
if (token === '--') {
|
|
80
|
+
positionals.push(...argv.slice(index + 1));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (!token.startsWith('--')) {
|
|
84
|
+
positionals.push(token);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const withoutPrefix = token.slice(2);
|
|
89
|
+
const eqIndex = withoutPrefix.indexOf('=');
|
|
90
|
+
if (eqIndex >= 0) {
|
|
91
|
+
const key = withoutPrefix.slice(0, eqIndex);
|
|
92
|
+
const value = withoutPrefix.slice(eqIndex + 1);
|
|
93
|
+
flags[key] = value;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const key = withoutPrefix;
|
|
98
|
+
const next = argv[index + 1];
|
|
99
|
+
if (!next || next.startsWith('--')) {
|
|
100
|
+
flags[key] = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
flags[key] = next;
|
|
104
|
+
index += 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { flags, positionals };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseBoolean(raw, fallback) {
|
|
111
|
+
if (raw === undefined) return fallback;
|
|
112
|
+
if (typeof raw === 'boolean') return raw;
|
|
113
|
+
|
|
114
|
+
const value = String(raw).trim().toLowerCase();
|
|
115
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(value)) return true;
|
|
116
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(value)) return false;
|
|
117
|
+
throw new Error(`Invalid boolean value: ${raw}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseInteger(raw, fallback) {
|
|
121
|
+
if (raw === undefined) return fallback;
|
|
122
|
+
const parsed = Number(raw);
|
|
123
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
124
|
+
throw new Error(`Invalid numeric value: ${raw}`);
|
|
125
|
+
}
|
|
126
|
+
return Math.floor(parsed);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function canonicalize(input) {
|
|
130
|
+
if (Array.isArray(input)) return input.map(canonicalize);
|
|
131
|
+
if (input && typeof input === 'object') {
|
|
132
|
+
const entries = Object.entries(input).sort(([a], [b]) => a.localeCompare(b));
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const [key, value] of entries) out[key] = canonicalize(value);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
return input;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hashJson(value) {
|
|
141
|
+
return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function quote(arg) {
|
|
145
|
+
if (/^[a-zA-Z0-9_./:@=-]+$/.test(arg)) return arg;
|
|
146
|
+
return JSON.stringify(arg);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function commandToString(bin, args) {
|
|
150
|
+
return [bin, ...args].map((part) => quote(String(part))).join(' ');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function runCommand({ bin, args, dryRun, allowFailure = false, display }) {
|
|
154
|
+
const rendered = display ?? commandToString(bin, args);
|
|
155
|
+
console.log(`\n$ ${rendered}`);
|
|
156
|
+
if (dryRun) return;
|
|
157
|
+
|
|
158
|
+
const result = spawnSync(bin, args, {
|
|
159
|
+
stdio: 'inherit',
|
|
160
|
+
shell: false,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (result.error) {
|
|
164
|
+
if (allowFailure) {
|
|
165
|
+
console.warn(`Warning: command failed (${result.error.message})`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
throw result.error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if ((result.status ?? 0) !== 0) {
|
|
172
|
+
if (allowFailure) {
|
|
173
|
+
console.warn(`Warning: command exited with status ${result.status}`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`Command failed with status ${result.status}: ${rendered}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function promptIfMissing(values, nonInteractive) {
|
|
181
|
+
if (nonInteractive || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
182
|
+
return values;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const rl = readline.createInterface({
|
|
186
|
+
input: process.stdin,
|
|
187
|
+
output: process.stdout,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const next = { ...values };
|
|
192
|
+
if (!next.cloudUrl) {
|
|
193
|
+
const answer = await rl.question('Latch Cloud URL: ');
|
|
194
|
+
next.cloudUrl = answer.trim();
|
|
195
|
+
}
|
|
196
|
+
if (!next.workspaceId) {
|
|
197
|
+
const answer = await rl.question('Workspace ID (ws_...): ');
|
|
198
|
+
next.workspaceId = answer.trim();
|
|
199
|
+
}
|
|
200
|
+
if (!next.upstreamId) {
|
|
201
|
+
const answer = await rl.question('Connector ID / upstream_id: ');
|
|
202
|
+
next.upstreamId = answer.trim();
|
|
203
|
+
}
|
|
204
|
+
if (!next.agentKey) {
|
|
205
|
+
const answer = await rl.question('Agent key (sk-latch-...): ');
|
|
206
|
+
next.agentKey = answer.trim();
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
} finally {
|
|
210
|
+
rl.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertRequired(values) {
|
|
215
|
+
const missing = [];
|
|
216
|
+
if (!values.cloudUrl) missing.push('--cloud-url');
|
|
217
|
+
if (!values.workspaceId) missing.push('--workspace-id');
|
|
218
|
+
if (!values.upstreamId) missing.push('--upstream-id');
|
|
219
|
+
if (!values.agentKey) missing.push('--agent-key');
|
|
220
|
+
|
|
221
|
+
if (missing.length > 0) {
|
|
222
|
+
throw new Error(`Missing required options: ${missing.join(', ')}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildSetupInput(flags) {
|
|
227
|
+
return {
|
|
228
|
+
cloudUrl: String(flags['cloud-url'] ?? process.env.LATCH_CLOUD_URL ?? '').trim(),
|
|
229
|
+
workspaceId: String(flags['workspace-id'] ?? process.env.LATCH_WORKSPACE_ID ?? '').trim(),
|
|
230
|
+
upstreamId: String(flags['upstream-id'] ?? process.env.LATCH_UPSTREAM_ID ?? '').trim(),
|
|
231
|
+
agentKey: String(flags['agent-key'] ?? process.env.LATCH_AGENT_KEY ?? '').trim(),
|
|
232
|
+
pluginSpec: String(flags['plugin-spec'] ?? process.env.LATCH_OPENCLAW_PLUGIN_SPEC ?? DEFAULT_PLUGIN_SPEC).trim(),
|
|
233
|
+
pluginId: String(flags['plugin-id'] ?? process.env.LATCH_OPENCLAW_PLUGIN_ID ?? DEFAULT_PLUGIN_ID).trim(),
|
|
234
|
+
mode: String(flags.mode ?? 'enforce').trim() || 'enforce',
|
|
235
|
+
waitForApproval: parseBoolean(flags['wait-for-approval'], true),
|
|
236
|
+
approvalTimeoutSeconds: parseInteger(flags['approval-timeout-seconds'], 600),
|
|
237
|
+
failClosed: parseBoolean(flags['fail-closed'], true),
|
|
238
|
+
logFile: String(flags['log-file'] ?? '').trim(),
|
|
239
|
+
openclawBin: String(flags['openclaw-bin'] ?? process.env.OPENCLAW_BIN ?? 'openclaw').trim() || 'openclaw',
|
|
240
|
+
skipInstall: parseBoolean(flags['skip-install'], false),
|
|
241
|
+
skipEnable: parseBoolean(flags['skip-enable'], false),
|
|
242
|
+
skipRestart: parseBoolean(flags['skip-restart'], false),
|
|
243
|
+
dryRun: parseBoolean(flags['dry-run'], false),
|
|
244
|
+
nonInteractive: parseBoolean(flags['non-interactive'], false),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildManualSetupCommands(input) {
|
|
249
|
+
const base = `plugins.entries.${input.pluginId}.config`;
|
|
250
|
+
return [
|
|
251
|
+
`${input.openclawBin} plugins install ${input.pluginSpec}`,
|
|
252
|
+
`${input.openclawBin} plugins enable ${input.pluginId}`,
|
|
253
|
+
`${input.openclawBin} config set ${base}.baseUrl ${JSON.stringify(input.cloudUrl)}`,
|
|
254
|
+
`${input.openclawBin} config set ${base}.workspaceId ${JSON.stringify(input.workspaceId)}`,
|
|
255
|
+
`${input.openclawBin} config set ${base}.upstreamId ${JSON.stringify(input.upstreamId)}`,
|
|
256
|
+
`${input.openclawBin} config set ${base}.agentKey ${JSON.stringify(input.agentKey)}`,
|
|
257
|
+
`${input.openclawBin} config set ${base}.mode ${JSON.stringify(input.mode)}`,
|
|
258
|
+
`${input.openclawBin} config set ${base}.waitForApproval ${JSON.stringify(input.waitForApproval)} --json`,
|
|
259
|
+
`${input.openclawBin} config set ${base}.approvalTimeoutSeconds ${JSON.stringify(input.approvalTimeoutSeconds)} --json`,
|
|
260
|
+
`${input.openclawBin} config set ${base}.failClosed ${JSON.stringify(input.failClosed)} --json`,
|
|
261
|
+
...(input.logFile ? [`${input.openclawBin} config set ${base}.logFile ${JSON.stringify(input.logFile)}`] : []),
|
|
262
|
+
`${input.openclawBin} gateway restart`,
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function runOpenclawSetup(flags) {
|
|
267
|
+
let input = buildSetupInput(flags);
|
|
268
|
+
input = await promptIfMissing(input, input.nonInteractive);
|
|
269
|
+
assertRequired(input);
|
|
270
|
+
|
|
271
|
+
if (!['monitor', 'enforce'].includes(input.mode)) {
|
|
272
|
+
throw new Error('--mode must be monitor or enforce');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const base = `plugins.entries.${input.pluginId}.config`;
|
|
276
|
+
|
|
277
|
+
const commands = [];
|
|
278
|
+
if (!input.skipInstall) {
|
|
279
|
+
commands.push({
|
|
280
|
+
bin: input.openclawBin,
|
|
281
|
+
args: ['plugins', 'install', input.pluginSpec],
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (!input.skipEnable) {
|
|
285
|
+
commands.push({
|
|
286
|
+
bin: input.openclawBin,
|
|
287
|
+
args: ['plugins', 'enable', input.pluginId],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
commands.push(
|
|
292
|
+
{
|
|
293
|
+
bin: input.openclawBin,
|
|
294
|
+
args: ['config', 'set', `${base}.baseUrl`, input.cloudUrl],
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
bin: input.openclawBin,
|
|
298
|
+
args: ['config', 'set', `${base}.workspaceId`, input.workspaceId],
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
bin: input.openclawBin,
|
|
302
|
+
args: ['config', 'set', `${base}.upstreamId`, input.upstreamId],
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
bin: input.openclawBin,
|
|
306
|
+
args: ['config', 'set', `${base}.agentKey`, input.agentKey],
|
|
307
|
+
display: `${input.openclawBin} config set ${base}.agentKey "<redacted>"`,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
bin: input.openclawBin,
|
|
311
|
+
args: ['config', 'set', `${base}.mode`, input.mode],
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
bin: input.openclawBin,
|
|
315
|
+
args: ['config', 'set', `${base}.waitForApproval`, String(input.waitForApproval), '--json'],
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
bin: input.openclawBin,
|
|
319
|
+
args: ['config', 'set', `${base}.approvalTimeoutSeconds`, String(input.approvalTimeoutSeconds), '--json'],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
bin: input.openclawBin,
|
|
323
|
+
args: ['config', 'set', `${base}.failClosed`, String(input.failClosed), '--json'],
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
if (input.logFile) {
|
|
328
|
+
commands.push({
|
|
329
|
+
bin: input.openclawBin,
|
|
330
|
+
args: ['config', 'set', `${base}.logFile`, input.logFile],
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!input.skipRestart) {
|
|
335
|
+
commands.push({
|
|
336
|
+
bin: input.openclawBin,
|
|
337
|
+
args: ['gateway', 'restart'],
|
|
338
|
+
allowFailure: true,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
commands.push({
|
|
343
|
+
bin: input.openclawBin,
|
|
344
|
+
args: ['plugins', 'info', input.pluginId],
|
|
345
|
+
allowFailure: true,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
console.log('\nConfiguring OpenClaw with Latch Guard...');
|
|
349
|
+
for (const command of commands) {
|
|
350
|
+
runCommand({
|
|
351
|
+
...command,
|
|
352
|
+
dryRun: input.dryRun,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log('\nOpenClaw setup complete.');
|
|
357
|
+
if (input.dryRun) {
|
|
358
|
+
console.log('Dry-run mode: no commands were executed.');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function runOpenclawPrintConfig(flags) {
|
|
363
|
+
let input = buildSetupInput(flags);
|
|
364
|
+
input = await promptIfMissing(input, input.nonInteractive);
|
|
365
|
+
assertRequired(input);
|
|
366
|
+
|
|
367
|
+
console.log('\n# Manual OpenClaw setup commands');
|
|
368
|
+
for (const command of buildManualSetupCommands(input)) {
|
|
369
|
+
if (command.includes('.agentKey')) {
|
|
370
|
+
console.log(command.replace(input.agentKey, '<paste-agent-key>'));
|
|
371
|
+
} else {
|
|
372
|
+
console.log(command);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
console.log('\n# Replace <paste-agent-key> only if redacted in output.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function buildTestInput(flags) {
|
|
379
|
+
return {
|
|
380
|
+
cloudUrl: String(flags['cloud-url'] ?? process.env.LATCH_CLOUD_URL ?? '').trim(),
|
|
381
|
+
workspaceId: String(flags['workspace-id'] ?? process.env.LATCH_WORKSPACE_ID ?? '').trim(),
|
|
382
|
+
upstreamId: String(flags['upstream-id'] ?? process.env.LATCH_UPSTREAM_ID ?? '').trim(),
|
|
383
|
+
agentKey: String(flags['agent-key'] ?? process.env.LATCH_AGENT_KEY ?? '').trim(),
|
|
384
|
+
toolName: String(flags['tool-name'] ?? 'openclaw:read').trim(),
|
|
385
|
+
actionClass: String(flags['action-class'] ?? 'read').trim(),
|
|
386
|
+
riskLevel: String(flags['risk-level'] ?? 'low').trim(),
|
|
387
|
+
argsJson: String(flags['args-json'] ?? '{}'),
|
|
388
|
+
approvalToken: String(flags['approval-token'] ?? '').trim(),
|
|
389
|
+
requireAllowed: parseBoolean(flags['require-allowed'], false),
|
|
390
|
+
nonInteractive: parseBoolean(flags['non-interactive'], false),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function runOpenclawTest(flags) {
|
|
395
|
+
let input = buildTestInput(flags);
|
|
396
|
+
input = await promptIfMissing(input, input.nonInteractive);
|
|
397
|
+
assertRequired(input);
|
|
398
|
+
|
|
399
|
+
let parsedArgs;
|
|
400
|
+
try {
|
|
401
|
+
parsedArgs = JSON.parse(input.argsJson);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
throw new Error(`--args-json must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const argsRedacted = parsedArgs && typeof parsedArgs === 'object' ? parsedArgs : {};
|
|
407
|
+
const argsHash = hashJson(argsRedacted);
|
|
408
|
+
const requestHash = hashJson({
|
|
409
|
+
tool_name: input.toolName,
|
|
410
|
+
action_class: input.actionClass,
|
|
411
|
+
risk_level: input.riskLevel,
|
|
412
|
+
args: argsRedacted,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const body = {
|
|
416
|
+
workspace_id: input.workspaceId,
|
|
417
|
+
agent_key: input.agentKey,
|
|
418
|
+
upstream_id: input.upstreamId,
|
|
419
|
+
tool_name: input.toolName,
|
|
420
|
+
action_class: input.actionClass,
|
|
421
|
+
risk_level: input.riskLevel,
|
|
422
|
+
risk_flags: {
|
|
423
|
+
external_domain: false,
|
|
424
|
+
new_recipient: false,
|
|
425
|
+
attachment: false,
|
|
426
|
+
form_submit: false,
|
|
427
|
+
shell_exec: input.actionClass === 'execute',
|
|
428
|
+
destructive: input.riskLevel === 'high' && input.actionClass === 'execute',
|
|
429
|
+
},
|
|
430
|
+
resource: {},
|
|
431
|
+
args_hash: argsHash,
|
|
432
|
+
request_hash: requestHash,
|
|
433
|
+
args_redacted: argsRedacted,
|
|
434
|
+
...(input.approvalToken ? { approval_token: input.approvalToken } : {}),
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const url = `${input.cloudUrl.replace(/\/$/, '')}/api/v1/authorize`;
|
|
438
|
+
const response = await fetch(url, {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
headers: {
|
|
441
|
+
'Content-Type': 'application/json',
|
|
442
|
+
'X-Latch-Agent-Key': input.agentKey,
|
|
443
|
+
},
|
|
444
|
+
body: JSON.stringify(body),
|
|
445
|
+
});
|
|
446
|
+
const text = await response.text();
|
|
447
|
+
|
|
448
|
+
let data = null;
|
|
449
|
+
try {
|
|
450
|
+
data = text ? JSON.parse(text) : null;
|
|
451
|
+
} catch {
|
|
452
|
+
data = { raw: text };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
console.log(`\nPOST ${url}`);
|
|
456
|
+
console.log(`status: ${response.status}`);
|
|
457
|
+
console.log(JSON.stringify(data, null, 2));
|
|
458
|
+
|
|
459
|
+
if (!response.ok) {
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (input.requireAllowed && data?.decision !== 'allowed') {
|
|
464
|
+
process.exit(2);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function runOpenclawCommand(argv) {
|
|
469
|
+
const [subcommand, ...rest] = argv;
|
|
470
|
+
if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
471
|
+
printOpenclawUsage();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const { flags } = parseArgs(rest);
|
|
476
|
+
|
|
477
|
+
if (subcommand === 'setup') {
|
|
478
|
+
await runOpenclawSetup(flags);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (subcommand === 'print-config') {
|
|
482
|
+
await runOpenclawPrintConfig(flags);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (subcommand === 'test') {
|
|
486
|
+
await runOpenclawTest(flags);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
throw new Error(`Unknown openclaw subcommand: ${subcommand}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function main() {
|
|
494
|
+
const argv = process.argv.slice(2);
|
|
495
|
+
const [command] = argv;
|
|
496
|
+
|
|
497
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
498
|
+
printUsage();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (command === 'version' || command === '--version' || command === '-v') {
|
|
502
|
+
console.log(VERSION);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (command === 'openclaw') {
|
|
506
|
+
await runOpenclawCommand(argv.slice(1));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
throw new Error(`Unknown command: ${command}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
main().catch((error) => {
|
|
514
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
515
|
+
console.error(`Error: ${message}`);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@latchagent/latchctl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Latch CLI bootstrap tool for OpenClaw and runtime configuration.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/latchagent/latch-marketing-site.git",
|
|
9
|
+
"directory": "packages/latchctl"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/latchagent/latch-marketing-site/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/latchagent/latch-marketing-site/tree/main/packages/latchctl",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"latch",
|
|
17
|
+
"openclaw",
|
|
18
|
+
"security",
|
|
19
|
+
"governance",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"latchctl": "index.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|