@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/src/tools.js CHANGED
@@ -12,6 +12,7 @@ import { MUTATION_TOOLS } from "./tools.mutation.js";
12
12
  import { PLATFORM_TOOLS } from "./tools.platform.js";
13
13
  import { EXTENDED_TOOLS } from "./tools.extended.js";
14
14
  import { validateToolArgs } from "./tool-arg-schemas.js";
15
+ import { summarizeSafeText } from "./summary-redaction.js";
15
16
  import {
16
17
  compactValue,
17
18
  ensureStringArray,
@@ -51,7 +52,7 @@ function normalizeSearchRow(row, view = "summary", contextChars = 320) {
51
52
  }
52
53
 
53
54
  const doc = row.document || row;
54
- const context = row.context || "";
55
+ const context = typeof row.context === "string" ? row.context : "";
55
56
  const summary = {
56
57
  id: doc.id,
57
58
  title: doc.title,
@@ -61,7 +62,7 @@ function normalizeSearchRow(row, view = "summary", contextChars = 320) {
61
62
  publishedAt: doc.publishedAt,
62
63
  urlId: doc.urlId,
63
64
  ranking: row.ranking,
64
- context: context.length > contextChars ? `${context.slice(0, contextChars)}...` : context,
65
+ context: summarizeSafeText(context, contextChars),
65
66
  };
66
67
 
67
68
  if (view === "ids") {
@@ -100,7 +101,7 @@ function normalizeDocumentRow(row, view = "summary", excerptChars = 280) {
100
101
  }
101
102
 
102
103
  if (row.text) {
103
- summary.excerpt = row.text.length > excerptChars ? `${row.text.slice(0, excerptChars)}...` : row.text;
104
+ summary.excerpt = summarizeSafeText(row.text, excerptChars);
104
105
  }
105
106
 
106
107
  return summary;
@@ -381,12 +382,15 @@ async function documentsListTool(ctx, args) {
381
382
  collectionId: args.collectionId,
382
383
  userId: args.userId,
383
384
  backlinkDocumentId: args.backlinkDocumentId,
384
- parentDocumentId: Object.prototype.hasOwnProperty.call(args, "parentDocumentId")
385
- ? args.parentDocumentId
386
- : undefined,
387
385
  statusFilter: normalizeStatusFilter(args.statusFilter),
388
386
  }) || {};
389
387
 
388
+ if (Object.prototype.hasOwnProperty.call(args, "parentDocumentId")) {
389
+ body.parentDocumentId = args.parentDocumentId;
390
+ } else if (args.rootOnly === true) {
391
+ body.parentDocumentId = null;
392
+ }
393
+
390
394
  const res = await ctx.client.call("documents.list", body, {
391
395
  maxAttempts: toInteger(args.maxAttempts, 2),
392
396
  });
@@ -846,7 +850,7 @@ export const TOOLS = {
846
850
  },
847
851
  "documents.list": {
848
852
  signature:
849
- "documents.list(args?: { limit?: number; offset?: number; sort?: string; direction?: 'ASC'|'DESC'; collectionId?: string; parentDocumentId?: string | null; userId?: string; statusFilter?: string[]; view?: 'ids'|'summary'|'full'; includePolicies?: boolean })",
853
+ "documents.list(args?: { limit?: number; offset?: number; sort?: string; direction?: 'ASC'|'DESC'; collectionId?: string; parentDocumentId?: string | null; rootOnly?: boolean; userId?: string; statusFilter?: string[]; view?: 'ids'|'summary'|'full'; includePolicies?: boolean })",
850
854
  description: "List documents with filtering and pagination.",
851
855
  usageExample: {
852
856
  tool: "documents.list",
@@ -859,7 +863,7 @@ export const TOOLS = {
859
863
  },
860
864
  bestPractices: [
861
865
  "Use small page sizes (10-25) and iterate with offset.",
862
- "Set parentDocumentId=null to list only collection root pages.",
866
+ "Use rootOnly=true (or parentDocumentId=null) to list only collection root pages.",
863
867
  "Use summary view unless the full document body is required.",
864
868
  ],
865
869
  handler: documentsListTool,
@@ -1007,6 +1011,216 @@ export const TOOLS = {
1007
1011
  ...PLATFORM_TOOLS,
1008
1012
  };
1009
1013
 
1014
+ const TOOL_ALIAS_DEFS = [
1015
+ {
1016
+ aliases: [
1017
+ "documents.search_titles",
1018
+ "documents.searchtitles",
1019
+ "documents.search-titles",
1020
+ "documents.search titles",
1021
+ ],
1022
+ name: "documents.search",
1023
+ argPatch: { mode: "titles" },
1024
+ reason: "mapped Outline title-search endpoint to wrapped documents.search with mode=titles",
1025
+ },
1026
+ { aliases: ["docs.search"], name: "documents.search", reason: "mapped shorthand docs.* alias to documents.*" },
1027
+ { aliases: ["docs.list"], name: "documents.list", reason: "mapped shorthand docs.* alias to documents.*" },
1028
+ { aliases: ["docs.info"], name: "documents.info", reason: "mapped shorthand docs.* alias to documents.*" },
1029
+ { aliases: ["docs.answer"], name: "documents.answer", reason: "mapped shorthand docs.* alias to documents.*" },
1030
+ { aliases: ["docs.answer_batch"], name: "documents.answer_batch", reason: "mapped shorthand docs.* alias to documents.*" },
1031
+ ];
1032
+
1033
+ function toSnakeCase(value) {
1034
+ return String(value || "")
1035
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
1036
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2");
1037
+ }
1038
+
1039
+ function canonicalizeToolName(value, options = {}) {
1040
+ const hyphenMode = options.hyphenMode || "underscore";
1041
+ let normalized = toSnakeCase(value).trim().toLowerCase();
1042
+ normalized = normalized.replace(/[/:\s]+/g, ".");
1043
+ normalized = hyphenMode === "dot"
1044
+ ? normalized.replace(/-+/g, ".")
1045
+ : normalized.replace(/-+/g, "_");
1046
+ normalized = normalized.replace(/\.+/g, ".").replace(/^\.+|\.+$/g, "");
1047
+ normalized = normalized.replace(/_+/g, "_");
1048
+ return normalized;
1049
+ }
1050
+
1051
+ function toolNameVariants(name) {
1052
+ const raw = String(name || "").trim();
1053
+ return [...new Set([
1054
+ raw,
1055
+ raw.toLowerCase(),
1056
+ canonicalizeToolName(raw, { hyphenMode: "underscore" }),
1057
+ canonicalizeToolName(raw, { hyphenMode: "dot" }),
1058
+ ].filter(Boolean))];
1059
+ }
1060
+
1061
+ function buildCanonicalToolMap() {
1062
+ const byCanonical = new Map();
1063
+ for (const toolName of Object.keys(TOOLS)) {
1064
+ for (const variant of toolNameVariants(toolName)) {
1065
+ const existing = byCanonical.get(variant) || [];
1066
+ existing.push(toolName);
1067
+ byCanonical.set(variant, existing);
1068
+ }
1069
+ }
1070
+ return byCanonical;
1071
+ }
1072
+
1073
+ function buildAliasMap() {
1074
+ const aliasMap = new Map();
1075
+ for (const def of TOOL_ALIAS_DEFS) {
1076
+ for (const alias of def.aliases) {
1077
+ for (const variant of toolNameVariants(alias)) {
1078
+ aliasMap.set(variant, def);
1079
+ }
1080
+ }
1081
+ }
1082
+ return aliasMap;
1083
+ }
1084
+
1085
+ const CANONICAL_TOOL_MAP = buildCanonicalToolMap();
1086
+ const TOOL_ALIAS_MAP = buildAliasMap();
1087
+
1088
+ function levenshteinDistance(a, b) {
1089
+ const left = String(a || "");
1090
+ const right = String(b || "");
1091
+ const rows = left.length + 1;
1092
+ const cols = right.length + 1;
1093
+ const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
1094
+
1095
+ for (let i = 0; i < rows; i += 1) {
1096
+ matrix[i][0] = i;
1097
+ }
1098
+ for (let j = 0; j < cols; j += 1) {
1099
+ matrix[0][j] = j;
1100
+ }
1101
+
1102
+ for (let i = 1; i < rows; i += 1) {
1103
+ for (let j = 1; j < cols; j += 1) {
1104
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1;
1105
+ matrix[i][j] = Math.min(
1106
+ matrix[i - 1][j] + 1,
1107
+ matrix[i][j - 1] + 1,
1108
+ matrix[i - 1][j - 1] + cost
1109
+ );
1110
+ }
1111
+ }
1112
+
1113
+ return matrix[rows - 1][cols - 1];
1114
+ }
1115
+
1116
+ function suggestionScore(input, candidate) {
1117
+ const requested = canonicalizeToolName(input, { hyphenMode: "underscore" });
1118
+ const option = canonicalizeToolName(candidate, { hyphenMode: "underscore" });
1119
+ const requestedParts = requested.split(".").filter(Boolean);
1120
+ const optionParts = option.split(".").filter(Boolean);
1121
+ const distance = levenshteinDistance(requested, option);
1122
+ let score = 1 / (1 + distance);
1123
+
1124
+ if (requested === option) {
1125
+ score += 10;
1126
+ }
1127
+ if (requested.startsWith(option) || option.startsWith(requested)) {
1128
+ score += 1.5;
1129
+ }
1130
+ if (requested.includes(option) || option.includes(requested)) {
1131
+ score += 0.8;
1132
+ }
1133
+ if (requestedParts[0] && requestedParts[0] === optionParts[0]) {
1134
+ score += 0.5;
1135
+ }
1136
+ if (requestedParts.at(-1) && requestedParts.at(-1) === optionParts.at(-1)) {
1137
+ score += 0.9;
1138
+ }
1139
+
1140
+ return Number(score.toFixed(4));
1141
+ }
1142
+
1143
+ export function listToolSuggestions(name, limit = 5) {
1144
+ return Object.keys(TOOLS)
1145
+ .map((toolName) => ({
1146
+ name: toolName,
1147
+ score: suggestionScore(name, toolName),
1148
+ }))
1149
+ .sort((a, b) => {
1150
+ if (b.score !== a.score) {
1151
+ return b.score - a.score;
1152
+ }
1153
+ return a.name.localeCompare(b.name);
1154
+ })
1155
+ .filter((row, index) => row.score >= 0.22 || index < Math.min(limit, 3))
1156
+ .slice(0, limit);
1157
+ }
1158
+
1159
+ export function resolveToolInvocation(name, args = {}) {
1160
+ const requestedName = String(name || "").trim();
1161
+ if (TOOLS[requestedName]) {
1162
+ return {
1163
+ requestedName,
1164
+ resolvedName: requestedName,
1165
+ args,
1166
+ autoCorrected: false,
1167
+ };
1168
+ }
1169
+
1170
+ for (const variant of toolNameVariants(requestedName)) {
1171
+ const alias = TOOL_ALIAS_MAP.get(variant);
1172
+ if (alias) {
1173
+ const nextArgs = { ...(args || {}) };
1174
+ const injectedArgs = [];
1175
+ for (const [key, value] of Object.entries(alias.argPatch || {})) {
1176
+ if (nextArgs[key] === undefined) {
1177
+ nextArgs[key] = value;
1178
+ injectedArgs.push(key);
1179
+ }
1180
+ }
1181
+
1182
+ return {
1183
+ requestedName,
1184
+ resolvedName: alias.name,
1185
+ args: nextArgs,
1186
+ autoCorrected: true,
1187
+ reason: alias.reason,
1188
+ injectedArgs,
1189
+ };
1190
+ }
1191
+ }
1192
+
1193
+ const candidates = new Set();
1194
+ for (const variant of toolNameVariants(requestedName)) {
1195
+ const matches = CANONICAL_TOOL_MAP.get(variant) || [];
1196
+ for (const match of matches) {
1197
+ candidates.add(match);
1198
+ }
1199
+ }
1200
+
1201
+ if (candidates.size === 1) {
1202
+ const [resolvedName] = [...candidates];
1203
+ return {
1204
+ requestedName,
1205
+ resolvedName,
1206
+ args,
1207
+ autoCorrected: true,
1208
+ reason: "normalized separators/casing to a known wrapped tool",
1209
+ injectedArgs: [],
1210
+ };
1211
+ }
1212
+
1213
+ const suggestions = listToolSuggestions(requestedName);
1214
+ throw new CliError(`Unknown tool: ${requestedName}`, {
1215
+ code: "UNKNOWN_TOOL",
1216
+ requestedTool: requestedName,
1217
+ suggestions,
1218
+ hint: suggestions.length > 0
1219
+ ? `Try ${suggestions.map((row) => row.name).join(", ")}`
1220
+ : "Run `outline-cli tools list` to inspect available tools.",
1221
+ });
1222
+ }
1223
+
1010
1224
  export function listTools() {
1011
1225
  return Object.entries(TOOLS).map(([name, def]) => ({
1012
1226
  name,
@@ -1026,31 +1240,73 @@ export function getToolContract(name) {
1026
1240
  }));
1027
1241
  }
1028
1242
 
1029
- const def = TOOLS[name];
1030
- if (!def) {
1031
- throw new CliError(`Unknown tool: ${name}`);
1032
- }
1243
+ const resolution = resolveToolInvocation(name, {});
1244
+ const def = TOOLS[resolution.resolvedName];
1033
1245
 
1034
1246
  return {
1035
- name,
1247
+ name: resolution.resolvedName,
1036
1248
  signature: def.signature,
1037
1249
  description: def.description,
1038
1250
  usageExample: def.usageExample,
1039
1251
  bestPractices: def.bestPractices,
1252
+ ...(resolution.autoCorrected
1253
+ ? {
1254
+ requestedName: resolution.requestedName,
1255
+ autoCorrected: true,
1256
+ reason: resolution.reason,
1257
+ injectedArgs: resolution.injectedArgs,
1258
+ }
1259
+ : {}),
1040
1260
  };
1041
1261
  }
1042
1262
 
1043
1263
  export async function invokeTool(ctx, name, args = {}) {
1044
- const tool = TOOLS[name];
1045
- if (!tool) {
1046
- throw new CliError(`Unknown tool: ${name}`);
1264
+ const resolution = resolveToolInvocation(name, args);
1265
+ const tool = TOOLS[resolution.resolvedName];
1266
+ let normalizedArgs;
1267
+
1268
+ try {
1269
+ normalizedArgs = validateToolArgs(resolution.resolvedName, resolution.args);
1270
+ } catch (err) {
1271
+ if (err instanceof CliError && err.details?.code === "ARG_VALIDATION_FAILED") {
1272
+ err.details = {
1273
+ ...err.details,
1274
+ toolSignature: tool.signature,
1275
+ usageExample: tool.usageExample,
1276
+ contractHint:
1277
+ "Run `outline-cli tools contract " + resolution.resolvedName + " --result-mode inline` for the full contract.",
1278
+ ...(resolution.autoCorrected
1279
+ ? {
1280
+ requestedTool: resolution.requestedName,
1281
+ toolResolution: {
1282
+ autoCorrected: true,
1283
+ resolvedTool: resolution.resolvedName,
1284
+ reason: resolution.reason,
1285
+ injectedArgs: resolution.injectedArgs,
1286
+ },
1287
+ }
1288
+ : {}),
1289
+ };
1290
+ }
1291
+ throw err;
1047
1292
  }
1048
1293
 
1049
- validateToolArgs(name, args);
1294
+ const result = await tool.handler(ctx, normalizedArgs);
1295
+ const enriched = resolution.autoCorrected
1296
+ ? {
1297
+ ...result,
1298
+ requestedTool: resolution.requestedName,
1299
+ toolResolution: {
1300
+ autoCorrected: true,
1301
+ resolvedTool: resolution.resolvedName,
1302
+ reason: resolution.reason,
1303
+ injectedArgs: resolution.injectedArgs,
1304
+ },
1305
+ }
1306
+ : result;
1050
1307
 
1051
- const result = await tool.handler(ctx, args);
1052
- if (args.compact ?? true) {
1053
- return compactValue(result) || {};
1308
+ if (normalizedArgs.compact ?? true) {
1309
+ return compactValue(enriched) || {};
1054
1310
  }
1055
- return result;
1311
+ return enriched;
1056
1312
  }
@@ -6,6 +6,7 @@ import {
6
6
  getDocumentDeleteReadReceipt,
7
7
  } from "./action-gate.js";
8
8
  import { compactValue, mapLimit, toInteger } from "./utils.js";
9
+ import { summarizeSafeText } from "./summary-redaction.js";
9
10
 
10
11
  function normalizeDocumentSummary(doc, view = "summary", excerptChars = 220) {
11
12
  if (!doc) {
@@ -28,7 +29,7 @@ function normalizeDocumentSummary(doc, view = "summary", excerptChars = 220) {
28
29
  };
29
30
 
30
31
  if (doc.text) {
31
- summary.excerpt = doc.text.length > excerptChars ? `${doc.text.slice(0, excerptChars)}...` : doc.text;
32
+ summary.excerpt = summarizeSafeText(doc.text, excerptChars);
32
33
  }
33
34
 
34
35
  return summary;
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "./errors.js";
2
2
  import { compactValue, ensureStringArray, mapLimit, toInteger } from "./utils.js";
3
+ import { summarizeSafeText } from "./summary-redaction.js";
3
4
 
4
5
  function normalizeDocumentRow(row, view = "summary", excerptChars = 220) {
5
6
  if (!row) {
@@ -29,7 +30,7 @@ function normalizeDocumentRow(row, view = "summary", excerptChars = 220) {
29
30
  }
30
31
 
31
32
  if (row.text) {
32
- summary.excerpt = row.text.length > excerptChars ? `${row.text.slice(0, excerptChars)}...` : row.text;
33
+ summary.excerpt = summarizeSafeText(row.text, excerptChars);
33
34
  }
34
35
 
35
36
  return summary;
@@ -407,7 +408,7 @@ function shapeResearchMergedRow(row, view, excerptChars, evidencePerDocument = 5
407
408
  }
408
409
 
409
410
  if (row.text) {
410
- summary.excerpt = row.text.length > excerptChars ? `${row.text.slice(0, excerptChars)}...` : row.text;
411
+ summary.excerpt = summarizeSafeText(row.text, excerptChars);
411
412
  }
412
413
 
413
414
  if (Array.isArray(row.evidence)) {
@@ -54,7 +54,7 @@ test("getQuickStartAgentHelp returns full payload and validates view", () => {
54
54
  assert.equal(payload.section, "quick-start-agent");
55
55
  assert.equal(payload.view, "full");
56
56
  assert.ok(Array.isArray(payload.steps));
57
- assert.ok(payload.steps.some((row) => row.command === "outline-cli profile list --pretty"));
57
+ assert.ok(payload.steps.some((row) => row.command === "outline-cli profile list"));
58
58
  assert.ok(
59
59
  payload.steps.some(
60
60
  (row) =>
@@ -77,7 +77,7 @@ test("getQuickStartAgentHelp returns full payload and validates view", () => {
77
77
  assert.ok(
78
78
  payload.steps.some(
79
79
  (row) =>
80
- row.apiKeySettingsUrlTemplate === "<base-url>/settings/api" &&
80
+ row.apiKeySettingsUrlTemplate === "<base-url>/settings/api-and-apps" &&
81
81
  Array.isArray(row.apiKeyConfigTemplate) &&
82
82
  row.apiKeyConfigTemplate.length >= 3
83
83
  )
@@ -2,6 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  buildProfile,
5
+ getProfile,
5
6
  normalizeBaseUrlWithHints,
6
7
  suggestProfileMetadata,
7
8
  suggestProfiles,
@@ -70,6 +71,37 @@ test("suggestProfiles ranks profiles by keywords/description/host signals", () =
70
71
  assert.ok(Array.isArray(result.matches[0].matchedOn));
71
72
  });
72
73
 
74
+ test("getProfile can auto-select a strong read-only match when query context is provided", () => {
75
+ const config = {
76
+ version: 1,
77
+ profiles: {
78
+ engineering: {
79
+ name: "Engineering",
80
+ baseUrl: "https://wiki.example.com",
81
+ description: "Runbooks and incident policy",
82
+ keywords: ["incident", "runbook", "sre"],
83
+ auth: { type: "apiKey", apiKey: "ol_api_eng" },
84
+ },
85
+ marketing: {
86
+ name: "Acme Handbook",
87
+ baseUrl: "https://handbook.acme.example",
88
+ description: "Marketing campaign and event tracking handbook",
89
+ keywords: ["tracking", "campaign", "analytics"],
90
+ auth: { type: "apiKey", apiKey: "ol_api_marketing" },
91
+ },
92
+ },
93
+ };
94
+
95
+ const selected = getProfile(config, undefined, {
96
+ query: "documents search incident runbook sre",
97
+ allowAutoSelect: true,
98
+ });
99
+
100
+ assert.equal(selected.id, "engineering");
101
+ assert.equal(selected.selection?.autoSelected, true);
102
+ assert.equal(selected.selection?.query, "documents search incident runbook sre");
103
+ });
104
+
73
105
  test("suggestProfileMetadata can generate and enrich metadata from hints", () => {
74
106
  const next = suggestProfileMetadata({
75
107
  id: "acme-handbook",
@@ -24,6 +24,26 @@ test("validateToolArgs rejects unknown args by default", () => {
24
24
  );
25
25
  });
26
26
 
27
+ test("validateToolArgs exposes accepted args and closest suggestions", () => {
28
+ assert.throws(
29
+ () => validateToolArgs("auth.info", { vew: "summary", unexpected: true }),
30
+ (err) => {
31
+ assert.ok(err instanceof CliError);
32
+ assert.equal(err.details?.code, "ARG_VALIDATION_FAILED");
33
+ assert.deepEqual(err.details?.requiredArgs, []);
34
+ assert.ok(Array.isArray(err.details?.acceptedArgs));
35
+ assert.ok(err.details.acceptedArgs.includes("view"));
36
+ assert.ok(err.details.acceptedArgs.includes("compact"));
37
+ assert.deepEqual(err.details?.unknownArgs, ["vew", "unexpected"]);
38
+ const typoIssue = err.details?.issues?.find((issue) => issue.path === "args.vew");
39
+ assert.deepEqual(typoIssue?.suggestions, ["view"]);
40
+ assert.equal(err.details?.suggestedArgs, undefined);
41
+ assert.match(err.details?.validationHint || "", /Accepted args:/);
42
+ return true;
43
+ }
44
+ );
45
+ });
46
+
27
47
  test("validateToolArgs supports allowUnknown opt-out", () => {
28
48
  const toolName = "__test.allow_unknown";
29
49
  TOOL_ARG_SCHEMAS[toolName] = {
@@ -1651,8 +1671,13 @@ test("shares lifecycle schemas enforce deterministic selectors and update requir
1651
1671
  }
1652
1672
  );
1653
1673
 
1674
+ assert.equal(
1675
+ validateToolArgs("shares.update", { id: "share-1", published: "true" }).published,
1676
+ true
1677
+ );
1678
+
1654
1679
  assert.throws(
1655
- () => validateToolArgs("shares.update", { id: "share-1", published: "true" }),
1680
+ () => validateToolArgs("shares.update", { id: "share-1", published: "yes" }),
1656
1681
  (err) => {
1657
1682
  assert.ok(err instanceof CliError);
1658
1683
  assert.ok(err.details?.issues?.some((issue) => issue.path === "args.published"));
@@ -1789,11 +1789,6 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
1789
1789
  });
1790
1790
 
1791
1791
  if (run.status !== 0) {
1792
- if (isApiNotFoundErrorEnvelope(run.stderrJson)) {
1793
- t.diagnostic(`documents.answer unsupported payload: ${run.stderr || "<empty stderr>"}`);
1794
- t.skip("documents.answerQuestion endpoint unsupported by this Outline deployment");
1795
- return;
1796
- }
1797
1792
  assert.fail(`documents.answer expected success, got status=${run.status}, stderr=${run.stderr || "<empty>"}`);
1798
1793
  }
1799
1794
 
@@ -1805,11 +1800,17 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
1805
1800
  assert.equal(payload.result?.question, happyQuestion);
1806
1801
 
1807
1802
  const signals = extractAnswerSignals(payload.result);
1808
- assert.ok(
1809
- signals.answerText.length > 0,
1810
- `documents.answer happy path should include answer text: ${JSON.stringify(payload.result)}`
1811
- );
1812
- assert.ok(Array.isArray(signals.citations), "documents.answer envelope should expose citations array");
1803
+ if (payload.result?.fallbackUsed) {
1804
+ assert.equal(payload.result?.unsupported, true);
1805
+ assert.ok(Array.isArray(signals.citations), "documents.answer fallback should expose citations/documents array");
1806
+ assert.ok(signals.citations.length > 0, `documents.answer fallback should include retrieved docs: ${JSON.stringify(payload.result)}`);
1807
+ } else {
1808
+ assert.ok(
1809
+ signals.answerText.length > 0,
1810
+ `documents.answer happy path should include answer text: ${JSON.stringify(payload.result)}`
1811
+ );
1812
+ assert.ok(Array.isArray(signals.citations), "documents.answer envelope should expose citations array");
1813
+ }
1813
1814
  });
1814
1815
 
1815
1816
  await t.test("documents.answer no-hit path assertions", async (t) => {
@@ -1826,11 +1827,6 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
1826
1827
  });
1827
1828
 
1828
1829
  if (run.status !== 0) {
1829
- if (isApiNotFoundErrorEnvelope(run.stderrJson)) {
1830
- t.diagnostic(`documents.answer unsupported payload: ${run.stderr || "<empty stderr>"}`);
1831
- t.skip("documents.answerQuestion endpoint unsupported by this Outline deployment");
1832
- return;
1833
- }
1834
1830
  assert.fail(`documents.answer no-hit expected success, got status=${run.status}, stderr=${run.stderr || "<empty>"}`);
1835
1831
  }
1836
1832
 
@@ -1840,10 +1836,15 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
1840
1836
  assert.equal(payload.result?.question, noHitQuestion);
1841
1837
 
1842
1838
  const signals = extractAnswerSignals(payload.result);
1843
- assert.ok(
1844
- signals.noAnswerReason.length > 0 || signals.citations.length === 0 || noHitPattern.test(signals.answerText),
1845
- `documents.answer no-hit should include explicit no-hit signal: ${JSON.stringify(payload.result)}`
1846
- );
1839
+ if (payload.result?.fallbackUsed) {
1840
+ assert.equal(payload.result?.unsupported, true);
1841
+ assert.ok(signals.noAnswerReason.length > 0, `documents.answer fallback no-hit should include reason: ${JSON.stringify(payload.result)}`);
1842
+ } else {
1843
+ assert.ok(
1844
+ signals.noAnswerReason.length > 0 || signals.citations.length === 0 || noHitPattern.test(signals.answerText),
1845
+ `documents.answer no-hit should include explicit no-hit signal: ${JSON.stringify(payload.result)}`
1846
+ );
1847
+ }
1847
1848
  });
1848
1849
 
1849
1850
  await t.test("documents.answer_batch mixed questions keep per-item isolation", async (t) => {
@@ -1865,11 +1866,6 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
1865
1866
  });
1866
1867
 
1867
1868
  if (run.status !== 0) {
1868
- if (isApiNotFoundErrorEnvelope(run.stderrJson)) {
1869
- t.diagnostic(`documents.answer_batch unsupported payload: ${run.stderr || "<empty stderr>"}`);
1870
- t.skip("documents.answerQuestion endpoint unsupported by this Outline deployment");
1871
- return;
1872
- }
1873
1869
  assert.fail(`documents.answer_batch expected success, got status=${run.status}, stderr=${run.stderr || "<empty>"}`);
1874
1870
  }
1875
1871
 
@@ -74,6 +74,17 @@ test("profile selection supports explicit, default, and single-profile fallback
74
74
  ]);
75
75
  assert.equal(addAlpha.stdoutJson?.defaultProfile, null, "first add should not auto-set default");
76
76
 
77
+ const profileListJson = runCli([
78
+ "profile",
79
+ "list",
80
+ "--config",
81
+ configPath,
82
+ "--output",
83
+ "json",
84
+ ]);
85
+ assert.equal(profileListJson.stdoutJson?.ok, true);
86
+ assert.equal(profileListJson.stdoutJson?.profiles?.length, 1);
87
+
77
88
  const singleFallback = runCli([
78
89
  "invoke",
79
90
  "no.such.tool",
@@ -105,10 +116,9 @@ test("profile selection supports explicit, default, and single-profile fallback
105
116
  "--args",
106
117
  "{}",
107
118
  ], { expectCode: 1 });
108
- assert.match(
109
- ambiguous.stderrJson?.error?.message || "",
110
- /Profile selection required: multiple profiles are saved and no default profile is set/
111
- );
119
+ assert.match(ambiguous.stderrJson?.error?.message || "", /Unknown tool: no\.such\.tool/);
120
+ assert.equal(ambiguous.stderrJson?.error?.code, "UNKNOWN_TOOL");
121
+ assert.ok(Array.isArray(ambiguous.stderrJson?.error?.suggestions));
112
122
 
113
123
  const explicitProfile = runCli([
114
124
  "invoke",