@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.
Files changed (3) hide show
  1. package/README.md +44 -0
  2. package/index.js +517 -0
  3. 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
+ }