@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/.env.test.example +2 -0
- package/AGENTS.md +107 -0
- package/CHANGELOG.md +102 -0
- package/README.md +244 -0
- package/bin/outline-agent.js +5 -0
- package/bin/outline-cli.js +13 -0
- package/package.json +25 -0
- package/scripts/generate-entry-integrity.mjs +123 -0
- package/scripts/release.mjs +353 -0
- package/src/action-gate.js +257 -0
- package/src/agent-skills.js +759 -0
- package/src/cli.js +956 -0
- package/src/config-store.js +720 -0
- package/src/entry-integrity-binding.generated.js +6 -0
- package/src/entry-integrity-manifest.generated.js +74 -0
- package/src/entry-integrity.js +112 -0
- package/src/errors.js +15 -0
- package/src/outline-client.js +237 -0
- package/src/result-store.js +183 -0
- package/src/secure-keyring.js +290 -0
- package/src/tool-arg-schemas.js +2346 -0
- package/src/tools.extended.js +3252 -0
- package/src/tools.js +1056 -0
- package/src/tools.mutation.js +1807 -0
- package/src/tools.navigation.js +2273 -0
- package/src/tools.platform.js +554 -0
- package/src/utils.js +176 -0
- package/test/action-gate.unit.test.js +157 -0
- package/test/agent-skills.unit.test.js +52 -0
- package/test/config-store.unit.test.js +89 -0
- package/test/hardening.unit.test.js +3778 -0
- package/test/live.integration.test.js +5140 -0
- package/test/profile-selection.unit.test.js +279 -0
- package/test/security.unit.test.js +113 -0
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
|
+
}
|