@khanglvm/outline-cli 0.1.3 → 0.1.4
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/CHANGELOG.md +5 -0
- package/README.md +34 -13
- package/bin/outline-cli.js +14 -0
- package/docs/TOOL_CONTRACTS.md +8 -0
- package/package.json +1 -1
- package/src/agent-skills.js +15 -15
- package/src/cli.js +203 -62
- package/src/config-store.js +86 -6
- package/src/entry-integrity-manifest.generated.js +15 -11
- package/src/entry-integrity.js +3 -0
- package/src/summary-redaction.js +37 -0
- package/src/tool-arg-schemas.js +266 -10
- package/src/tools.extended.js +123 -16
- package/src/tools.js +277 -21
- package/src/tools.mutation.js +2 -1
- package/src/tools.navigation.js +3 -2
- package/test/agent-skills.unit.test.js +2 -2
- package/test/config-store.unit.test.js +32 -0
- package/test/hardening.unit.test.js +26 -1
- package/test/live.integration.test.js +20 -24
- package/test/profile-selection.unit.test.js +14 -4
- package/test/tool-resolution.unit.test.js +333 -0
- package/test/version.unit.test.js +21 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { ApiError, CliError } from "../src/errors.js";
|
|
5
|
+
import { getToolContract, invokeTool, resolveToolInvocation } from "../src/tools.js";
|
|
6
|
+
|
|
7
|
+
test("getToolContract auto-corrects common raw endpoint aliases", () => {
|
|
8
|
+
const contract = getToolContract("documents.search_titles");
|
|
9
|
+
assert.equal(contract.name, "documents.search");
|
|
10
|
+
assert.equal(contract.autoCorrected, true);
|
|
11
|
+
assert.equal(contract.requestedName, "documents.search_titles");
|
|
12
|
+
assert.match(contract.reason || "", /mode=titles/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("resolveToolInvocation surfaces structured suggestions for unknown tools", () => {
|
|
16
|
+
assert.throws(
|
|
17
|
+
() => resolveToolInvocation("document.search"),
|
|
18
|
+
(err) => {
|
|
19
|
+
assert.equal(err.name, "CliError");
|
|
20
|
+
assert.equal(err.details?.code, "UNKNOWN_TOOL");
|
|
21
|
+
assert.ok(Array.isArray(err.details?.suggestions));
|
|
22
|
+
assert.ok(err.details.suggestions.some((row) => row.name === "documents.search"));
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("invokeTool auto-corrects title-search alias and coerces common JSON string mistakes", async () => {
|
|
29
|
+
const calls = [];
|
|
30
|
+
const ctx = {
|
|
31
|
+
profile: { id: "prod" },
|
|
32
|
+
client: {
|
|
33
|
+
async call(method, body) {
|
|
34
|
+
calls.push({ method, body });
|
|
35
|
+
return {
|
|
36
|
+
body: {
|
|
37
|
+
ok: true,
|
|
38
|
+
status: 200,
|
|
39
|
+
data: [
|
|
40
|
+
{
|
|
41
|
+
id: "doc-1",
|
|
42
|
+
title: "Engineering Handbook",
|
|
43
|
+
collectionId: "col-1",
|
|
44
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
45
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
46
|
+
urlId: "abc123",
|
|
47
|
+
ranking: 0.99,
|
|
48
|
+
context: "sample context",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
pagination: { limit: body.limit, offset: body.offset },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const result = await invokeTool(ctx, "documents.searchTitles", {
|
|
59
|
+
query: ["engineering handbook"],
|
|
60
|
+
limit: "5",
|
|
61
|
+
offset: "0",
|
|
62
|
+
compact: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(calls.length, 1);
|
|
66
|
+
assert.equal(calls[0].method, "documents.search_titles");
|
|
67
|
+
assert.equal(calls[0].body.query, "engineering handbook");
|
|
68
|
+
assert.equal(calls[0].body.limit, 5);
|
|
69
|
+
assert.equal(calls[0].body.offset, 0);
|
|
70
|
+
assert.equal(result.tool, "documents.search");
|
|
71
|
+
assert.equal(result.requestedTool, "documents.searchTitles");
|
|
72
|
+
assert.equal(result.toolResolution?.autoCorrected, true);
|
|
73
|
+
assert.equal(result.result?.ok, true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("documents.list preserves parentDocumentId null for collection root filtering", async () => {
|
|
77
|
+
const calls = [];
|
|
78
|
+
const ctx = {
|
|
79
|
+
profile: { id: "prod" },
|
|
80
|
+
client: {
|
|
81
|
+
async call(method, body) {
|
|
82
|
+
calls.push({ method, body });
|
|
83
|
+
return {
|
|
84
|
+
body: {
|
|
85
|
+
ok: true,
|
|
86
|
+
status: 200,
|
|
87
|
+
data: [],
|
|
88
|
+
pagination: { limit: body.limit, offset: body.offset },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
await invokeTool(ctx, "documents.list", {
|
|
96
|
+
collectionId: "col-1",
|
|
97
|
+
parentDocumentId: null,
|
|
98
|
+
limit: 10,
|
|
99
|
+
compact: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(calls.length, 1);
|
|
103
|
+
assert.equal(calls[0].method, "documents.list");
|
|
104
|
+
assert.ok(Object.prototype.hasOwnProperty.call(calls[0].body, "parentDocumentId"));
|
|
105
|
+
assert.equal(calls[0].body.parentDocumentId, null);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("documents.list rootOnly maps to parentDocumentId null", async () => {
|
|
109
|
+
const calls = [];
|
|
110
|
+
const ctx = {
|
|
111
|
+
profile: { id: "prod" },
|
|
112
|
+
client: {
|
|
113
|
+
async call(method, body) {
|
|
114
|
+
calls.push({ method, body });
|
|
115
|
+
return {
|
|
116
|
+
body: {
|
|
117
|
+
ok: true,
|
|
118
|
+
status: 200,
|
|
119
|
+
data: [],
|
|
120
|
+
pagination: { limit: body.limit, offset: body.offset },
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await invokeTool(ctx, "documents.list", {
|
|
128
|
+
collectionId: "col-1",
|
|
129
|
+
rootOnly: "true",
|
|
130
|
+
compact: false,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
assert.equal(calls.length, 1);
|
|
134
|
+
assert.equal(calls[0].method, "documents.list");
|
|
135
|
+
assert.ok(Object.prototype.hasOwnProperty.call(calls[0].body, "parentDocumentId"));
|
|
136
|
+
assert.equal(calls[0].body.parentDocumentId, null);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("invokeTool enriches arg validation failures with contract hints", async () => {
|
|
140
|
+
const ctx = {
|
|
141
|
+
profile: { id: "prod" },
|
|
142
|
+
client: {
|
|
143
|
+
async call() {
|
|
144
|
+
throw new Error("client.call should not run for validation failures");
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
() => invokeTool(ctx, "docs.answer", { question: "Where?", limt: "3", compact: false }),
|
|
151
|
+
(err) => {
|
|
152
|
+
assert.ok(err instanceof CliError);
|
|
153
|
+
assert.equal(err.details?.code, "ARG_VALIDATION_FAILED");
|
|
154
|
+
assert.equal(err.details?.tool, "documents.answer");
|
|
155
|
+
assert.equal(err.details?.requestedTool, "docs.answer");
|
|
156
|
+
assert.equal(err.details?.toolResolution?.autoCorrected, true);
|
|
157
|
+
assert.match(err.details?.toolSignature || "", /^documents\.answer\(/);
|
|
158
|
+
assert.equal(err.details?.usageExample?.tool, "documents.answer");
|
|
159
|
+
assert.match(err.details?.contractHint || "", /tools contract documents\.answer/);
|
|
160
|
+
const typoIssue = err.details?.issues?.find((issue) => issue.path === "args.limt");
|
|
161
|
+
assert.ok(Array.isArray(typoIssue?.suggestions));
|
|
162
|
+
assert.ok(typoIssue.suggestions.includes("limit"));
|
|
163
|
+
assert.deepEqual(err.details?.suggestedArgs, {
|
|
164
|
+
question: "Where?",
|
|
165
|
+
limit: 3,
|
|
166
|
+
compact: false,
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("docs.answer alias accepts limit and falls back to deterministic retrieval", async () => {
|
|
174
|
+
const calls = [];
|
|
175
|
+
const ctx = {
|
|
176
|
+
profile: { id: "prod" },
|
|
177
|
+
client: {
|
|
178
|
+
async call(method, body) {
|
|
179
|
+
calls.push({ method, body });
|
|
180
|
+
if (method === "documents.answerQuestion") {
|
|
181
|
+
throw new ApiError("Not Found", {
|
|
182
|
+
status: 404,
|
|
183
|
+
url: "https://example.com/api/documents.answerQuestion",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (method === "documents.search") {
|
|
187
|
+
return {
|
|
188
|
+
body: {
|
|
189
|
+
ok: true,
|
|
190
|
+
status: 200,
|
|
191
|
+
data: [
|
|
192
|
+
{
|
|
193
|
+
id: "doc-1",
|
|
194
|
+
title: "Engineering / Welcome",
|
|
195
|
+
collectionId: "col-1",
|
|
196
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
197
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
198
|
+
urlId: "welcome",
|
|
199
|
+
ranking: 0.88,
|
|
200
|
+
context: "This page helps onboard new engineers.",
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`Unexpected method: ${method}`);
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const payload = await invokeTool(ctx, "docs.answer", {
|
|
212
|
+
question: "How do I onboard engineers?",
|
|
213
|
+
collectionId: "col-1",
|
|
214
|
+
limit: "3",
|
|
215
|
+
compact: false,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.equal(calls[0].method, "documents.answerQuestion");
|
|
219
|
+
assert.equal(calls[0].body.limit, undefined);
|
|
220
|
+
assert.equal(calls[1].method, "documents.search");
|
|
221
|
+
assert.equal(calls[1].body.limit, 3);
|
|
222
|
+
assert.equal(payload.tool, "documents.answer");
|
|
223
|
+
assert.equal(payload.requestedTool, "docs.answer");
|
|
224
|
+
assert.equal(payload.toolResolution?.autoCorrected, true);
|
|
225
|
+
assert.equal(payload.result?.fallbackUsed, true);
|
|
226
|
+
assert.equal(payload.result?.unsupported, true);
|
|
227
|
+
assert.equal(payload.result?.fallbackTool, "documents.search");
|
|
228
|
+
assert.equal(payload.result?.question, "How do I onboard engineers?");
|
|
229
|
+
assert.deepEqual(payload.result?.fallbackSuggestion, {
|
|
230
|
+
tool: "documents.search",
|
|
231
|
+
args: {
|
|
232
|
+
query: "How do I onboard engineers?",
|
|
233
|
+
collectionId: "col-1",
|
|
234
|
+
limit: 3,
|
|
235
|
+
view: "summary",
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
assert.ok(Array.isArray(payload.result?.documents));
|
|
239
|
+
assert.equal(payload.result.documents[0]?.title, "Engineering / Welcome");
|
|
240
|
+
assert.match(payload.result?.noAnswerReason || "", /unsupported/i);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("documents.info summary redacts obvious credentials in excerpts", async () => {
|
|
244
|
+
const ctx = {
|
|
245
|
+
profile: { id: "prod" },
|
|
246
|
+
client: {
|
|
247
|
+
async call() {
|
|
248
|
+
return {
|
|
249
|
+
body: {
|
|
250
|
+
ok: true,
|
|
251
|
+
status: 200,
|
|
252
|
+
data: {
|
|
253
|
+
id: "doc-1",
|
|
254
|
+
title: "Keycloak",
|
|
255
|
+
collectionId: "col-1",
|
|
256
|
+
revision: 3,
|
|
257
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
258
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
259
|
+
urlId: "keycloak",
|
|
260
|
+
text: [
|
|
261
|
+
"Site: https://idp.example.com",
|
|
262
|
+
"User / Pass: dev / qUHxy1auV5E7",
|
|
263
|
+
"* **Credentials**: `test@yopmail.com` / `xxxxyyyy`",
|
|
264
|
+
"API key: ol_api_abcdef123456",
|
|
265
|
+
"Git Repo: https://dev:supersecret@example.com/repo",
|
|
266
|
+
].join("\n"),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const payload = await invokeTool(ctx, "documents.info", {
|
|
275
|
+
id: "doc-1",
|
|
276
|
+
view: "summary",
|
|
277
|
+
compact: false,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const excerpt = payload.result?.data?.excerpt || "";
|
|
281
|
+
assert.match(excerpt, /User \/ Pass: \[REDACTED\]/);
|
|
282
|
+
assert.match(excerpt, /\* \*\*Credentials\*\*: \[REDACTED\]/);
|
|
283
|
+
assert.match(excerpt, /API key: \[REDACTED\]/i);
|
|
284
|
+
assert.match(excerpt, /https:\/\/\[REDACTED\]@example\.com\/repo/);
|
|
285
|
+
assert.ok(!excerpt.includes("qUHxy1auV5E7"));
|
|
286
|
+
assert.ok(!excerpt.includes("xxxxyyyy"));
|
|
287
|
+
assert.ok(!excerpt.includes("ol_api_abcdef123456"));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("documents.search summary redacts obvious credentials in contexts", async () => {
|
|
291
|
+
const ctx = {
|
|
292
|
+
profile: { id: "prod" },
|
|
293
|
+
client: {
|
|
294
|
+
async call(method) {
|
|
295
|
+
assert.equal(method, "documents.search");
|
|
296
|
+
return {
|
|
297
|
+
body: {
|
|
298
|
+
ok: true,
|
|
299
|
+
status: 200,
|
|
300
|
+
data: [
|
|
301
|
+
{
|
|
302
|
+
document: {
|
|
303
|
+
id: "doc-1",
|
|
304
|
+
title: "Keycloak",
|
|
305
|
+
collectionId: "col-1",
|
|
306
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
307
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
308
|
+
urlId: "keycloak",
|
|
309
|
+
},
|
|
310
|
+
ranking: 0.9,
|
|
311
|
+
context:
|
|
312
|
+
"* **Credentials**: dev / secretpass\nAPI key: ol_api_searchsecret\nURL: https://dev:pass@example.com/repo",
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const payload = await invokeTool(ctx, "documents.search", {
|
|
322
|
+
query: "keycloak",
|
|
323
|
+
view: "summary",
|
|
324
|
+
compact: false,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const context = payload.result?.data?.[0]?.context || "";
|
|
328
|
+
assert.match(context, /\* \*\*Credentials\*\*: \[REDACTED\]/);
|
|
329
|
+
assert.match(context, /API key: \[REDACTED\]/i);
|
|
330
|
+
assert.match(context, /https:\/\/\[REDACTED\]@example\.com\/repo/);
|
|
331
|
+
assert.ok(!context.includes("secretpass"));
|
|
332
|
+
assert.ok(!context.includes("ol_api_searchsecret"));
|
|
333
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
11
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
12
|
+
|
|
13
|
+
test("outline-cli --version matches package.json", () => {
|
|
14
|
+
const result = spawnSync("node", [path.join(repoRoot, "bin/outline-cli.js"), "--version"], {
|
|
15
|
+
cwd: repoRoot,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(result.status, 0, result.stderr);
|
|
20
|
+
assert.equal(result.stdout.trim(), packageJson.version);
|
|
21
|
+
});
|