@khanglvm/outline-cli 0.1.1

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/cli.js ADDED
@@ -0,0 +1,956 @@
1
+ import { Command } from "commander";
2
+ import path from "node:path";
3
+ import { getAgentSkillHelp, listHelpSections } from "./agent-skills.js";
4
+ import {
5
+ buildProfile,
6
+ defaultConfigPath,
7
+ getProfile,
8
+ listProfiles,
9
+ loadConfig,
10
+ normalizeBaseUrlWithHints,
11
+ redactProfile,
12
+ saveConfig,
13
+ suggestProfileMetadata,
14
+ suggestProfiles,
15
+ } from "./config-store.js";
16
+ import { CliError, ApiError } from "./errors.js";
17
+ import { OutlineClient } from "./outline-client.js";
18
+ import { ResultStore } from "./result-store.js";
19
+ import {
20
+ hydrateProfileFromKeychain,
21
+ removeProfileFromKeychain,
22
+ secureProfileForStorage,
23
+ } from "./secure-keyring.js";
24
+ import { getToolContract, invokeTool, listTools } from "./tools.js";
25
+ import { mapLimit, parseJsonArg, parseCsv, toInteger } from "./utils.js";
26
+
27
+ function configureSharedOutputOptions(command) {
28
+ return command
29
+ .option("--config <path>", "Config file path", defaultConfigPath())
30
+ .option("--profile <id>", "Profile ID (required when multiple profiles exist and no default is set)")
31
+ .option("--output <format>", "Output format: json|ndjson", "json")
32
+ .option("--result-mode <mode>", "Result mode: auto|inline|file", "auto")
33
+ .option("--inline-max-bytes <n>", "Max inline JSON payload size", "12000")
34
+ .option("--tmp-dir <path>", "Directory for large result files")
35
+ .option("--pretty", "Pretty-print JSON output", false);
36
+ }
37
+
38
+ function buildStoreFromOptions(opts) {
39
+ return new ResultStore({
40
+ mode: opts.resultMode,
41
+ inlineMaxBytes: toInteger(opts.inlineMaxBytes, 12000),
42
+ tmpDir: opts.tmpDir,
43
+ pretty: !!opts.pretty,
44
+ });
45
+ }
46
+
47
+ async function getRuntime(opts, overrideProfileId) {
48
+ const configPath = path.resolve(opts.config || defaultConfigPath());
49
+ const config = await loadConfig(configPath);
50
+ const selectedProfile = getProfile(config, overrideProfileId || opts.profile);
51
+ const profile = hydrateProfileFromKeychain({
52
+ configPath,
53
+ profile: selectedProfile,
54
+ });
55
+ const client = new OutlineClient(profile);
56
+ return {
57
+ configPath,
58
+ config,
59
+ profile,
60
+ client,
61
+ };
62
+ }
63
+
64
+ function parseHeaders(input) {
65
+ if (!input) {
66
+ return {};
67
+ }
68
+ const pairs = parseCsv(input);
69
+ const headers = {};
70
+ for (const pair of pairs) {
71
+ const i = pair.indexOf(":");
72
+ if (i <= 0) {
73
+ throw new CliError(`Invalid header pair: ${pair}. Expected key:value`);
74
+ }
75
+ const key = pair.slice(0, i).trim();
76
+ const value = pair.slice(i + 1).trim();
77
+ headers[key] = value;
78
+ }
79
+ return headers;
80
+ }
81
+
82
+ const URL_HINT_PATH_MARKERS = new Set(["doc", "d", "share", "s"]);
83
+
84
+ function normalizeUrlHint(value) {
85
+ const raw = String(value || "").trim();
86
+ if (!raw) {
87
+ return null;
88
+ }
89
+ try {
90
+ const parsed = new URL(raw);
91
+ const chunks = parsed.pathname.split("/").filter(Boolean);
92
+ if (chunks.length === 0) {
93
+ return parsed.hostname;
94
+ }
95
+ const effective = chunks[0] && URL_HINT_PATH_MARKERS.has(chunks[0].toLowerCase())
96
+ ? chunks.slice(1)
97
+ : chunks;
98
+ if (effective.length === 0) {
99
+ return parsed.hostname;
100
+ }
101
+ const slug = effective.join(" ");
102
+ const withoutId = slug.replace(/-[A-Za-z0-9]{8,}$/g, "");
103
+ const compacted = withoutId
104
+ .replace(/[._-]+/g, " ")
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ return compacted || parsed.hostname;
108
+ } catch {
109
+ return raw;
110
+ }
111
+ }
112
+
113
+ function formatError(err) {
114
+ if (err instanceof ApiError) {
115
+ return {
116
+ ok: false,
117
+ error: {
118
+ type: "ApiError",
119
+ message: err.message,
120
+ ...err.details,
121
+ },
122
+ };
123
+ }
124
+
125
+ if (err instanceof CliError) {
126
+ return {
127
+ ok: false,
128
+ error: {
129
+ type: "CliError",
130
+ message: err.message,
131
+ ...err.details,
132
+ },
133
+ };
134
+ }
135
+
136
+ return {
137
+ ok: false,
138
+ error: {
139
+ type: err?.name || "Error",
140
+ message: err?.message || String(err),
141
+ stack: err?.stack,
142
+ },
143
+ };
144
+ }
145
+
146
+ function writeNdjsonLine(value) {
147
+ process.stdout.write(`${JSON.stringify(value)}\n`);
148
+ }
149
+
150
+ function emitNdjson(payload) {
151
+ if (Array.isArray(payload)) {
152
+ for (const item of payload) {
153
+ writeNdjsonLine({ type: "item", item });
154
+ }
155
+ return;
156
+ }
157
+
158
+ if (payload && typeof payload === "object" && Array.isArray(payload.items)) {
159
+ const { items, ...meta } = payload;
160
+ writeNdjsonLine({ type: "meta", ...meta });
161
+ for (const item of items) {
162
+ writeNdjsonLine({ type: "item", item });
163
+ }
164
+ return;
165
+ }
166
+
167
+ const listKeys = ["tools", "files", "contract", "profiles"];
168
+ for (const key of listKeys) {
169
+ if (payload && typeof payload === "object" && Array.isArray(payload[key])) {
170
+ const { [key]: rows, ...meta } = payload;
171
+ writeNdjsonLine({ type: "meta", list: key, ...meta });
172
+ for (const row of rows) {
173
+ writeNdjsonLine({ type: key.slice(0, -1), [key.slice(0, -1)]: row });
174
+ }
175
+ return;
176
+ }
177
+ }
178
+
179
+ if (payload?.result && Array.isArray(payload.result.data)) {
180
+ const meta = {
181
+ type: "meta",
182
+ tool: payload.tool,
183
+ profile: payload.profile,
184
+ count: payload.result.data.length,
185
+ pagination: payload.result.pagination,
186
+ };
187
+ writeNdjsonLine(meta);
188
+ for (const row of payload.result.data) {
189
+ writeNdjsonLine({ type: "data", row });
190
+ }
191
+ return;
192
+ }
193
+
194
+ writeNdjsonLine(payload);
195
+ }
196
+
197
+ async function emitOutput(store, payload, opts, emitOptions = {}) {
198
+ if ((opts.output || "json") === "ndjson") {
199
+ const mode = emitOptions.mode || opts.resultMode || store.mode || "auto";
200
+ const serialized = JSON.stringify(payload);
201
+ const bytes = Buffer.byteLength(serialized);
202
+ const shouldStore = mode === "file" || (mode === "auto" && bytes > store.inlineMaxBytes);
203
+
204
+ if (shouldStore) {
205
+ const file = await store.write(payload, {
206
+ label: emitOptions.label,
207
+ ext: emitOptions.ext,
208
+ pretty: false,
209
+ });
210
+ const preview = store.preview(payload);
211
+ writeNdjsonLine({
212
+ type: "meta",
213
+ ok: true,
214
+ stored: true,
215
+ bytes,
216
+ label: emitOptions.label || null,
217
+ preview,
218
+ });
219
+ writeNdjsonLine({
220
+ type: "file",
221
+ file,
222
+ bytes,
223
+ hint: `Use shell tools to inspect file, e.g. jq '.' ${JSON.stringify(file)} | head`,
224
+ });
225
+ return;
226
+ }
227
+
228
+ emitNdjson(payload);
229
+ return;
230
+ }
231
+
232
+ await store.emit(payload, emitOptions);
233
+ }
234
+
235
+ export async function run(argv = process.argv) {
236
+ const program = new Command();
237
+ program
238
+ .name("outline-cli")
239
+ .description("Agent-optimized CLI for Outline API")
240
+ .version("0.1.0")
241
+ .showHelpAfterError(true);
242
+
243
+ const profile = program.command("profile").description("Manage Outline profiles");
244
+
245
+ profile
246
+ .command("add <id>")
247
+ .description("Add or update a profile")
248
+ .option("--config <path>", "Config file path", defaultConfigPath())
249
+ .requiredOption("--base-url <url>", "Outline instance URL, e.g. https://app.getoutline.com")
250
+ .option("--name <name>", "Friendly profile name")
251
+ .option("--description <text>", "Profile description for AI/source routing")
252
+ .option("--keywords <csv>", "Comma-separated profile keywords for AI/source routing")
253
+ .option("--metadata-hints <csv>", "Comma-separated hints for automatic profile metadata generation")
254
+ .option("--no-auto-metadata", "Disable automatic metadata generation (description/keywords)")
255
+ .option("--auth-type <type>", "apiKey|basic|password")
256
+ .option("--api-key <key>", "Outline API key (ol_api_...)")
257
+ .option("--username <username>", "Username/email for basic or password mode")
258
+ .option("--password <password>", "Password for basic or password mode")
259
+ .option("--token-endpoint <url>", "Optional token exchange endpoint for password mode")
260
+ .option("--token-field <field>", "Token field in token response", "access_token")
261
+ .option("--token-body <json>", "Extra token request JSON body")
262
+ .option("--token-body-file <path>", "Extra token request JSON body file")
263
+ .option("--timeout-ms <n>", "Request timeout in milliseconds", "30000")
264
+ .option("--headers <csv>", "Extra headers as csv key:value pairs")
265
+ .option("--set-default", "Set as default profile", false)
266
+ .action(async (id, opts) => {
267
+ const configPath = path.resolve(opts.config || defaultConfigPath());
268
+ const config = await loadConfig(configPath);
269
+ const baseUrlHint = normalizeBaseUrlWithHints(opts.baseUrl);
270
+ const providedKeywords = parseCsv(opts.keywords);
271
+ const metadataHints = parseCsv(opts.metadataHints);
272
+ const metadata = opts.autoMetadata !== false
273
+ ? suggestProfileMetadata(
274
+ {
275
+ id,
276
+ name: opts.name || id,
277
+ baseUrl: baseUrlHint.baseUrl,
278
+ description: opts.description,
279
+ keywords: providedKeywords,
280
+ hints: metadataHints,
281
+ },
282
+ {
283
+ maxKeywords: 20,
284
+ preserveKeywords: providedKeywords.length > 0,
285
+ }
286
+ )
287
+ : {
288
+ description: opts.description,
289
+ keywords: providedKeywords,
290
+ generated: {
291
+ descriptionGenerated: false,
292
+ keywordsAdded: 0,
293
+ hintsUsed: metadataHints.length,
294
+ maxKeywords: 20,
295
+ },
296
+ };
297
+ const tokenBody = await parseJsonArg({
298
+ json: opts.tokenBody,
299
+ file: opts.tokenBodyFile,
300
+ name: "token-body",
301
+ });
302
+ const nextProfile = buildProfile({
303
+ id,
304
+ name: opts.name,
305
+ description: metadata.description,
306
+ keywords: metadata.keywords,
307
+ baseUrl: opts.baseUrl,
308
+ authType: opts.authType,
309
+ apiKey: opts.apiKey,
310
+ username: opts.username,
311
+ password: opts.password,
312
+ tokenEndpoint: opts.tokenEndpoint,
313
+ tokenField: opts.tokenField,
314
+ tokenRequestBody: tokenBody,
315
+ timeoutMs: toInteger(opts.timeoutMs, 30000),
316
+ headers: parseHeaders(opts.headers),
317
+ });
318
+
319
+ const secured = secureProfileForStorage({
320
+ configPath,
321
+ profileId: id,
322
+ profile: nextProfile,
323
+ });
324
+
325
+ config.profiles[id] = secured.profile;
326
+ if (opts.setDefault) {
327
+ config.defaultProfile = id;
328
+ }
329
+ await saveConfig(configPath, config);
330
+
331
+ const store = new ResultStore({ pretty: true });
332
+ await store.emit({
333
+ ok: true,
334
+ configPath,
335
+ defaultProfile: config.defaultProfile,
336
+ profile: redactProfile({ id, ...secured.profile }),
337
+ endpoint: {
338
+ input: baseUrlHint.input,
339
+ normalized: baseUrlHint.baseUrl,
340
+ autoCorrected: baseUrlHint.corrected,
341
+ corrections: baseUrlHint.corrections,
342
+ },
343
+ metadata: {
344
+ autoGenerated: opts.autoMetadata !== false,
345
+ hints: metadataHints,
346
+ ...metadata.generated,
347
+ },
348
+ security: secured.keychain,
349
+ }, { mode: "inline", pretty: true, label: "profile-add" });
350
+ });
351
+
352
+ profile
353
+ .command("list")
354
+ .description("List configured profiles")
355
+ .option("--config <path>", "Config file path", defaultConfigPath())
356
+ .action(async (opts) => {
357
+ const configPath = path.resolve(opts.config || defaultConfigPath());
358
+ const config = await loadConfig(configPath);
359
+ const profiles = listProfiles(config).map((item) => ({
360
+ ...redactProfile(item),
361
+ isDefault: config.defaultProfile === item.id,
362
+ }));
363
+ const store = new ResultStore({ pretty: true });
364
+ await store.emit(
365
+ {
366
+ ok: true,
367
+ configPath,
368
+ defaultProfile: config.defaultProfile,
369
+ profiles,
370
+ },
371
+ { mode: "inline", pretty: true, label: "profile-list" }
372
+ );
373
+ });
374
+
375
+ profile
376
+ .command("suggest <query>")
377
+ .description("Suggest best-matching profile(s) by id/name/base-url/description/keywords")
378
+ .option("--config <path>", "Config file path", defaultConfigPath())
379
+ .option("--limit <n>", "Max number of profile matches to return", "5")
380
+ .action(async (query, opts) => {
381
+ const configPath = path.resolve(opts.config || defaultConfigPath());
382
+ const config = await loadConfig(configPath);
383
+ const result = suggestProfiles(config, query, { limit: toInteger(opts.limit, 5) });
384
+ const store = new ResultStore({ pretty: true });
385
+ await store.emit(
386
+ {
387
+ ok: true,
388
+ configPath,
389
+ defaultProfile: config.defaultProfile,
390
+ ...result,
391
+ bestMatch: result.matches[0] || null,
392
+ },
393
+ { mode: "inline", pretty: true, label: "profile-suggest" }
394
+ );
395
+ });
396
+
397
+ profile
398
+ .command("show [id]")
399
+ .description("Show one profile (redacted)")
400
+ .option("--config <path>", "Config file path", defaultConfigPath())
401
+ .action(async (id, opts) => {
402
+ const configPath = path.resolve(opts.config || defaultConfigPath());
403
+ const config = await loadConfig(configPath);
404
+ const profileData = getProfile(config, id);
405
+ const store = new ResultStore({ pretty: true });
406
+ await store.emit(
407
+ {
408
+ ok: true,
409
+ configPath,
410
+ profile: redactProfile(profileData),
411
+ },
412
+ { mode: "inline", pretty: true, label: "profile-show" }
413
+ );
414
+ });
415
+
416
+ profile
417
+ .command("annotate <id>")
418
+ .description("Update profile routing metadata (description/keywords) for AI source selection")
419
+ .option("--config <path>", "Config file path", defaultConfigPath())
420
+ .option("--description <text>", "Set profile description")
421
+ .option("--clear-description", "Clear profile description", false)
422
+ .option("--keywords <csv>", "Replace keywords with comma-separated values")
423
+ .option("--append-keywords <csv>", "Append comma-separated keywords")
424
+ .option("--clear-keywords", "Clear profile keywords", false)
425
+ .action(async (id, opts) => {
426
+ const configPath = path.resolve(opts.config || defaultConfigPath());
427
+ const config = await loadConfig(configPath);
428
+ const record = config.profiles?.[id];
429
+ if (!record) {
430
+ throw new CliError(`Profile not found: ${id}`);
431
+ }
432
+
433
+ const next = structuredClone(record);
434
+ let changed = false;
435
+
436
+ if (opts.clearDescription) {
437
+ delete next.description;
438
+ changed = true;
439
+ } else if (typeof opts.description === "string") {
440
+ const value = opts.description.trim();
441
+ if (value) {
442
+ next.description = value;
443
+ } else {
444
+ delete next.description;
445
+ }
446
+ changed = true;
447
+ }
448
+
449
+ if (opts.clearKeywords) {
450
+ delete next.keywords;
451
+ changed = true;
452
+ } else {
453
+ const replaceKeywords = opts.keywords ? parseCsv(opts.keywords).map((item) => item.trim()).filter(Boolean) : null;
454
+ const appendKeywords = opts.appendKeywords
455
+ ? parseCsv(opts.appendKeywords).map((item) => item.trim()).filter(Boolean)
456
+ : [];
457
+
458
+ if (Array.isArray(replaceKeywords)) {
459
+ next.keywords = [...new Set(replaceKeywords)];
460
+ changed = true;
461
+ }
462
+
463
+ if (appendKeywords.length > 0) {
464
+ const current = Array.isArray(next.keywords) ? next.keywords : [];
465
+ next.keywords = [...new Set([...current, ...appendKeywords])];
466
+ changed = true;
467
+ }
468
+ }
469
+
470
+ if (!changed) {
471
+ throw new CliError(
472
+ "No metadata change requested. Use --description/--clear-description and/or --keywords/--append-keywords/--clear-keywords."
473
+ );
474
+ }
475
+
476
+ config.profiles[id] = next;
477
+ await saveConfig(configPath, config);
478
+ const store = new ResultStore({ pretty: true });
479
+ await store.emit(
480
+ {
481
+ ok: true,
482
+ configPath,
483
+ defaultProfile: config.defaultProfile,
484
+ profile: redactProfile({ id, ...next }),
485
+ },
486
+ { mode: "inline", pretty: true, label: "profile-annotate" }
487
+ );
488
+ });
489
+
490
+ profile
491
+ .command("enrich <id>")
492
+ .description("Auto-update profile description/keywords from usage hints for better AI routing")
493
+ .option("--config <path>", "Config file path", defaultConfigPath())
494
+ .option("--query <text>", "Single query/task hint to learn from")
495
+ .option("--queries <csv>", "Comma-separated additional query/task hints")
496
+ .option("--hints <csv>", "Comma-separated free-form metadata hints")
497
+ .option("--titles <csv>", "Comma-separated document titles seen in successful runs")
498
+ .option("--urls <csv>", "Comma-separated document URLs seen in successful runs")
499
+ .option("--max-keywords <n>", "Maximum keyword count after enrichment", "20")
500
+ .option("--refresh-description", "Regenerate description even when one exists", false)
501
+ .option("--discover", "Probe auth.info and add workspace/user hints before enrichment", false)
502
+ .option("--dry-run", "Show proposed changes without saving config", false)
503
+ .action(async (id, opts) => {
504
+ const configPath = path.resolve(opts.config || defaultConfigPath());
505
+ const config = await loadConfig(configPath);
506
+ const record = config.profiles?.[id];
507
+ if (!record) {
508
+ throw new CliError(`Profile not found: ${id}`);
509
+ }
510
+
511
+ const urlHints = parseCsv(opts.urls)
512
+ .map((item) => normalizeUrlHint(item))
513
+ .filter(Boolean);
514
+ const hints = [
515
+ opts.query,
516
+ ...parseCsv(opts.queries),
517
+ ...parseCsv(opts.hints),
518
+ ...parseCsv(opts.titles),
519
+ ...urlHints,
520
+ ]
521
+ .map((item) => String(item || "").trim())
522
+ .filter(Boolean);
523
+
524
+ const profileData = {
525
+ id,
526
+ ...record,
527
+ };
528
+
529
+ let discovery = null;
530
+ if (opts.discover) {
531
+ const hydrated = hydrateProfileFromKeychain({
532
+ configPath,
533
+ profile: profileData,
534
+ });
535
+ const discoverClient = new OutlineClient(hydrated);
536
+ try {
537
+ const auth = await discoverClient.call("auth.info", {}, { maxAttempts: 1 });
538
+ const team = auth?.body?.data?.team || {};
539
+ const user = auth?.body?.data?.user || {};
540
+ const discoveredHints = [team.name, team.domain, user.name].filter(Boolean);
541
+ hints.push(...discoveredHints);
542
+ discovery = {
543
+ ok: true,
544
+ team: team.name || null,
545
+ domain: team.domain || null,
546
+ user: user.name || null,
547
+ hintsAdded: discoveredHints,
548
+ };
549
+ } catch (err) {
550
+ discovery = {
551
+ ok: false,
552
+ error: err?.message || String(err),
553
+ };
554
+ }
555
+ }
556
+
557
+ const existingKeywords = Array.isArray(record.keywords) ? record.keywords : [];
558
+ const metadata = suggestProfileMetadata(
559
+ {
560
+ id,
561
+ name: record.name || id,
562
+ baseUrl: record.baseUrl,
563
+ description: record.description,
564
+ keywords: existingKeywords,
565
+ hints,
566
+ },
567
+ {
568
+ maxKeywords: toInteger(opts.maxKeywords, 20),
569
+ refreshDescription: !!opts.refreshDescription,
570
+ }
571
+ );
572
+
573
+ const next = structuredClone(record);
574
+ if (metadata.description) {
575
+ next.description = metadata.description;
576
+ } else {
577
+ delete next.description;
578
+ }
579
+ if (Array.isArray(metadata.keywords) && metadata.keywords.length > 0) {
580
+ next.keywords = metadata.keywords;
581
+ } else {
582
+ delete next.keywords;
583
+ }
584
+
585
+ const beforeDescription = record.description || null;
586
+ const afterDescription = next.description || null;
587
+ const beforeKeywords = Array.isArray(record.keywords) ? record.keywords : [];
588
+ const afterKeywords = Array.isArray(next.keywords) ? next.keywords : [];
589
+ const beforeSet = new Set(beforeKeywords.map((item) => String(item || "").toLowerCase()));
590
+ const afterSet = new Set(afterKeywords.map((item) => String(item || "").toLowerCase()));
591
+ const addedKeywords = afterKeywords.filter((item) => !beforeSet.has(String(item || "").toLowerCase()));
592
+ const removedKeywords = beforeKeywords.filter((item) => !afterSet.has(String(item || "").toLowerCase()));
593
+ const changed = beforeDescription !== afterDescription
594
+ || JSON.stringify(beforeKeywords) !== JSON.stringify(afterKeywords);
595
+
596
+ if (changed && !opts.dryRun) {
597
+ config.profiles[id] = next;
598
+ await saveConfig(configPath, config);
599
+ }
600
+
601
+ const store = new ResultStore({ pretty: true });
602
+ await store.emit(
603
+ {
604
+ ok: true,
605
+ changed,
606
+ dryRun: !!opts.dryRun,
607
+ persisted: changed && !opts.dryRun,
608
+ configPath,
609
+ defaultProfile: config.defaultProfile,
610
+ profile: redactProfile({ id, ...next }),
611
+ delta: {
612
+ beforeDescription,
613
+ afterDescription,
614
+ addedKeywords,
615
+ removedKeywords,
616
+ },
617
+ metadata: metadata.generated,
618
+ hintsUsed: hints,
619
+ discovery,
620
+ },
621
+ { mode: "inline", pretty: true, label: "profile-enrich" }
622
+ );
623
+ });
624
+
625
+ profile
626
+ .command("use <id>")
627
+ .description("Set default profile")
628
+ .option("--config <path>", "Config file path", defaultConfigPath())
629
+ .action(async (id, opts) => {
630
+ const configPath = path.resolve(opts.config || defaultConfigPath());
631
+ const config = await loadConfig(configPath);
632
+ if (!config.profiles?.[id]) {
633
+ throw new CliError(`Profile not found: ${id}`);
634
+ }
635
+ config.defaultProfile = id;
636
+ await saveConfig(configPath, config);
637
+ const store = new ResultStore({ pretty: true });
638
+ await store.emit(
639
+ {
640
+ ok: true,
641
+ defaultProfile: id,
642
+ configPath,
643
+ },
644
+ { mode: "inline", pretty: true, label: "profile-use" }
645
+ );
646
+ });
647
+
648
+ profile
649
+ .command("remove <id>")
650
+ .description("Remove a profile")
651
+ .option("--config <path>", "Config file path", defaultConfigPath())
652
+ .option("--force", "Allow removing default profile without replacement", false)
653
+ .action(async (id, opts) => {
654
+ const configPath = path.resolve(opts.config || defaultConfigPath());
655
+ const config = await loadConfig(configPath);
656
+ if (!config.profiles?.[id]) {
657
+ throw new CliError(`Profile not found: ${id}`);
658
+ }
659
+ if (config.defaultProfile === id && !opts.force) {
660
+ throw new CliError(
661
+ "Cannot remove default profile without --force. Set another default with `profile use <id>` first."
662
+ );
663
+ }
664
+
665
+ const profileRecord = {
666
+ id,
667
+ ...config.profiles[id],
668
+ };
669
+ const security = removeProfileFromKeychain({
670
+ configPath,
671
+ profileId: id,
672
+ profile: profileRecord,
673
+ });
674
+
675
+ delete config.profiles[id];
676
+ if (config.defaultProfile === id) {
677
+ config.defaultProfile = null;
678
+ }
679
+ await saveConfig(configPath, config);
680
+
681
+ const store = new ResultStore({ pretty: true });
682
+ await store.emit(
683
+ {
684
+ ok: true,
685
+ removed: id,
686
+ defaultProfile: config.defaultProfile,
687
+ security,
688
+ },
689
+ { mode: "inline", pretty: true, label: "profile-remove" }
690
+ );
691
+ });
692
+
693
+ configureSharedOutputOptions(
694
+ profile
695
+ .command("test [id]")
696
+ .description("Test profile authentication via auth.info")
697
+ ).action(async (id, opts) => {
698
+ const runtime = await getRuntime(opts, id);
699
+ const store = buildStoreFromOptions(opts);
700
+ const response = await runtime.client.call("auth.info", {}, { maxAttempts: 1 });
701
+ const result = {
702
+ ok: true,
703
+ profile: runtime.profile.id,
704
+ user: response.body?.data?.user,
705
+ team: response.body?.data?.team,
706
+ };
707
+ await emitOutput(store, result, opts, { label: "profile-test", mode: opts.resultMode });
708
+ });
709
+
710
+ const tools = configureSharedOutputOptions(
711
+ program.command("tools").description("Inspect tool contracts and metadata")
712
+ );
713
+
714
+ tools
715
+ .command("list")
716
+ .description("List available agent tools")
717
+ .action(async (opts, cmd) => {
718
+ const merged = { ...cmd.parent.opts(), ...opts };
719
+ const store = buildStoreFromOptions(merged);
720
+ await emitOutput(store, { ok: true, tools: listTools() }, merged, {
721
+ label: "tools-list",
722
+ mode: merged.resultMode,
723
+ });
724
+ });
725
+
726
+ tools
727
+ .command("contract [name]")
728
+ .description("Show tool contract (signature, usage, best practices)")
729
+ .action(async (name, opts, cmd) => {
730
+ const merged = { ...cmd.parent.opts(), ...opts };
731
+ const store = buildStoreFromOptions(merged);
732
+ const contract = getToolContract(name || "all");
733
+ await emitOutput(store, { ok: true, contract }, merged, {
734
+ label: "tool-contract",
735
+ mode: merged.resultMode,
736
+ });
737
+ });
738
+
739
+ tools
740
+ .command("help [section]")
741
+ .description("Show structured help sections for AI-oriented CLI usage")
742
+ .option("--view <mode>", "View mode: summary|full", "summary")
743
+ .option("--scenario <id>", "Filter ai-skills by scenario id, e.g. UC-12")
744
+ .option("--skill <id>", "Filter ai-skills by skill id")
745
+ .option("--query <text>", "Search ai-skills by tool/scenario/topic keyword")
746
+ .action(async (section, opts, cmd) => {
747
+ const merged = { ...cmd.parent.opts(), ...opts };
748
+ const store = buildStoreFromOptions(merged);
749
+ const sectionName = String(section || "index").trim().toLowerCase();
750
+
751
+ if (sectionName === "index" || sectionName === "all") {
752
+ await emitOutput(
753
+ store,
754
+ {
755
+ ok: true,
756
+ section: "index",
757
+ sections: listHelpSections(),
758
+ },
759
+ merged,
760
+ {
761
+ label: "tools-help-index",
762
+ mode: merged.resultMode,
763
+ }
764
+ );
765
+ return;
766
+ }
767
+
768
+ if (sectionName === "ai" || sectionName === "skill" || sectionName === "skills" || sectionName === "ai-skills") {
769
+ await emitOutput(
770
+ store,
771
+ {
772
+ ok: true,
773
+ ...getAgentSkillHelp({
774
+ view: merged.view,
775
+ scenario: merged.scenario,
776
+ skill: merged.skill,
777
+ query: merged.query,
778
+ }),
779
+ },
780
+ merged,
781
+ {
782
+ label: "tools-help-ai-skills",
783
+ mode: merged.resultMode,
784
+ }
785
+ );
786
+ return;
787
+ }
788
+
789
+ throw new CliError(
790
+ `Unknown tools help section: ${section}. Supported: ${listHelpSections().map((row) => row.id).join(", ")}`
791
+ );
792
+ });
793
+
794
+ const invoke = configureSharedOutputOptions(
795
+ program
796
+ .command("invoke <tool>")
797
+ .description("Invoke a high-level Outline tool")
798
+ .option("--args <json>", "Tool args JSON")
799
+ .option("--args-file <path>", "Tool args JSON file")
800
+ );
801
+
802
+ invoke.action(async (tool, opts) => {
803
+ const runtime = await getRuntime(opts);
804
+ const store = buildStoreFromOptions(opts);
805
+ const args = (await parseJsonArg({ json: opts.args, file: opts.argsFile, name: "args" })) || {};
806
+ const result = await invokeTool(runtime, tool, args);
807
+ await emitOutput(store, result, opts, {
808
+ label: `tool-${tool.replace(/\./g, "-")}`,
809
+ mode: opts.resultMode,
810
+ });
811
+ });
812
+
813
+ const batch = configureSharedOutputOptions(
814
+ program
815
+ .command("batch")
816
+ .description("Invoke multiple tools in one call")
817
+ .option("--ops <json>", "Array of operations: [{ tool, args, profile? }]")
818
+ .option("--ops-file <path>", "JSON file containing operations array")
819
+ .option("--parallel <n>", "Max parallel operations", "4")
820
+ .option("--item-envelope <mode>", "Batch item payload mode: compact|full", "compact")
821
+ .option("--strict-exit", "Exit non-zero if any operation fails", false)
822
+ );
823
+
824
+ batch.action(async (opts) => {
825
+ const configPath = path.resolve(opts.config || defaultConfigPath());
826
+ const config = await loadConfig(configPath);
827
+ const operations = await parseJsonArg({ json: opts.ops, file: opts.opsFile, name: "ops" });
828
+ if (!Array.isArray(operations)) {
829
+ throw new CliError("batch expects an array of operations in --ops or --ops-file");
830
+ }
831
+
832
+ const store = buildStoreFromOptions(opts);
833
+ const clientCache = new Map();
834
+
835
+ async function runtimeForProfile(profileId) {
836
+ const selected = getProfile(config, profileId || opts.profile);
837
+ if (!clientCache.has(selected.id)) {
838
+ const hydrated = hydrateProfileFromKeychain({
839
+ configPath,
840
+ profile: selected,
841
+ });
842
+ clientCache.set(selected.id, {
843
+ profile: hydrated,
844
+ client: new OutlineClient(hydrated),
845
+ });
846
+ }
847
+ return clientCache.get(selected.id);
848
+ }
849
+
850
+ const parallel = toInteger(opts.parallel, 4);
851
+ const items = await mapLimit(operations, parallel, async (operation, index) => {
852
+ try {
853
+ if (!operation || typeof operation !== "object") {
854
+ throw new CliError(`Operation at index ${index} must be an object`);
855
+ }
856
+ if (!operation.tool) {
857
+ throw new CliError(`Operation at index ${index} is missing tool`);
858
+ }
859
+ const runtime = await runtimeForProfile(operation.profile);
860
+ const payload = await invokeTool(runtime, operation.tool, operation.args || {});
861
+ const mode = (opts.itemEnvelope || "compact").toLowerCase();
862
+ const compactResult =
863
+ payload && typeof payload === "object" && Object.prototype.hasOwnProperty.call(payload, "result")
864
+ ? payload.result
865
+ : payload;
866
+ const compactMeta = {};
867
+ for (const key of ["query", "queryCount", "mode", "collectionId"]) {
868
+ if (payload && typeof payload === "object" && Object.prototype.hasOwnProperty.call(payload, key)) {
869
+ compactMeta[key] = payload[key];
870
+ }
871
+ }
872
+ return {
873
+ index,
874
+ tool: operation.tool,
875
+ profile: runtime.profile.id,
876
+ ok: true,
877
+ result: mode === "full" ? payload : compactResult,
878
+ ...(mode === "full" || Object.keys(compactMeta).length === 0 ? {} : { meta: compactMeta }),
879
+ };
880
+ } catch (err) {
881
+ return {
882
+ index,
883
+ tool: operation?.tool,
884
+ profile: operation?.profile || opts.profile || config.defaultProfile,
885
+ ok: false,
886
+ error: formatError(err).error,
887
+ };
888
+ }
889
+ });
890
+
891
+ const failed = items.filter((item) => !item.ok).length;
892
+ const output = {
893
+ ok: failed === 0,
894
+ total: items.length,
895
+ failed,
896
+ succeeded: items.length - failed,
897
+ items,
898
+ };
899
+ await emitOutput(store, output, opts, { label: "batch", mode: opts.resultMode });
900
+
901
+ if (failed > 0 && opts.strictExit) {
902
+ process.exitCode = 2;
903
+ }
904
+ });
905
+
906
+ const tmp = configureSharedOutputOptions(program.command("tmp").description("Manage temporary result files"));
907
+
908
+ tmp
909
+ .command("list")
910
+ .description("List stored result files")
911
+ .action(async (opts, cmd) => {
912
+ const merged = { ...cmd.parent.opts(), ...opts };
913
+ const store = buildStoreFromOptions(merged);
914
+ const files = await store.list();
915
+ await emitOutput(store, { ok: true, files }, merged, { label: "tmp-list", mode: "inline" });
916
+ });
917
+
918
+ tmp
919
+ .command("cat <file>")
920
+ .description("Print a temporary file")
921
+ .action(async (file, opts, cmd) => {
922
+ const merged = { ...cmd.parent.opts(), ...opts };
923
+ const store = buildStoreFromOptions(merged);
924
+ const content = await store.read(file);
925
+ process.stdout.write(content.content);
926
+ });
927
+
928
+ tmp
929
+ .command("rm <file>")
930
+ .description("Remove a temporary file")
931
+ .action(async (file, opts, cmd) => {
932
+ const merged = { ...cmd.parent.opts(), ...opts };
933
+ const store = buildStoreFromOptions(merged);
934
+ const result = await store.remove(file);
935
+ await emitOutput(store, { ok: true, ...result }, merged, { label: "tmp-rm", mode: "inline" });
936
+ });
937
+
938
+ tmp
939
+ .command("gc")
940
+ .description("Delete old temporary files")
941
+ .option("--max-age-hours <n>", "Delete files older than this age", "24")
942
+ .action(async (opts, cmd) => {
943
+ const merged = { ...cmd.parent.opts(), ...opts };
944
+ const store = buildStoreFromOptions(merged);
945
+ const result = await store.gc(toInteger(merged.maxAgeHours, 24));
946
+ await emitOutput(store, { ok: true, ...result }, merged, { label: "tmp-gc", mode: "inline" });
947
+ });
948
+
949
+ try {
950
+ await program.parseAsync(argv);
951
+ } catch (err) {
952
+ const output = formatError(err);
953
+ process.stderr.write(`${JSON.stringify(output, null, 2)}\n`);
954
+ process.exitCode = process.exitCode || 1;
955
+ }
956
+ }