@theplato/tiro-cli 0.4.1 β†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +24 -1
  2. package/dist/bin/tiro.js +582 -13
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -46,6 +46,7 @@ The CLI is **not a replacement for MCP**. It's the same data through a different
46
46
  - πŸ” **OAuth Authorization Code + PKCE** β€” no copy-paste tokens, no secrets in shell history. Tokens live in OS Keychain.
47
47
  - πŸ“ **`--output` writes to disk** β€” stdout becomes a single line of metadata. Agents stay context-light.
48
48
  - πŸͺž **Same JSON shape as MCP** β€” `tiro notes transcript --format json` mirrors `get_note_transcript` exactly. Switch surfaces without changing parsers.
49
+ - πŸ•ΈοΈ **Workspace wiki** β€” `tiro wiki search / page / mentions / graph` read the auto-extracted knowledge graph; `--workspace` targets a specific workspace.
49
50
  - 🧡 **NDJSON streams by default** β€” pipe to `jq`, `head`, `xargs`. No buffer-in-memory surprises.
50
51
  - πŸ€– **Agent-aware** β€” TTY detection auto-selects pretty vs JSON. `error.suggestion` field for auto-recovery.
51
52
  - 🚫 **No voice in v1** β€” intentionally matches MCP feature parity (audio uploads belong elsewhere).
@@ -150,6 +151,28 @@ header instead of being attached to every speaker line. Use
150
151
 
151
152
  `--format json` returns the exact shape MCP's `get_note_transcript` emits (`{noteGuid, title, participants, createdAt, recordingDurationSeconds, paragraphs[]}` with each paragraph carrying `segments[]` of `{content, speaker:{label,name}|null}`).
152
153
 
154
+ ### Explore the workspace wiki
155
+
156
+ The wiki is Tiro's auto-extracted knowledge graph (entities, concepts, and their
157
+ links) over your notes. These commands are **read-only** and mirror the MCP wiki
158
+ tools. Wiki is a paid, opt-in feature β€” calls against an ineligible or
159
+ not-activated workspace return the backend's upgrade message and a non-zero exit.
160
+
161
+ ```bash
162
+ tiro wiki workspaces # which workspaces? (guid + wiki on/off)
163
+ tiro wiki search "Ontology" --workspace <guid> # find pages by keyword
164
+ tiro wiki page <pageGuid> --workspace <guid> # body + mentions + links
165
+ tiro wiki mentions <pageGuid> --workspace <guid> # where the page is grounded in notes
166
+ tiro wiki graph <pageGuid> --mode around --workspace <guid> # neighborhood graph
167
+ tiro wiki graph --mode seed --type CONCEPT --workspace <guid> # overview graph (no pageGuid)
168
+ ```
169
+
170
+ `--workspace` is optional β€” omit it to use the default workspace for your API
171
+ key. If your account belongs to multiple workspaces, run `tiro wiki workspaces`
172
+ first and pass the `guid` of the one whose wiki is enabled. `graph` results are
173
+ capped (default 50 nodes); a `truncated: true` flag signals you to narrow the
174
+ query.
175
+
153
176
  ### Connect to Claude Code (MCP)
154
177
  ```bash
155
178
  tiro mcp install
@@ -325,7 +348,7 @@ See [`SPEC.md`](./SPEC.md) for the full v1 design and [`CHANGELOG.md`](./CHANGEL
325
348
 
326
349
  ## Contributing
327
350
 
328
- This repo is currently private alpha. Feedback and bug reports go to the internal Slack `#tiro-backend` channel; once we open up, this section will list public contribution guidelines.
351
+ This repo is currently private alpha. Feedback and bug reports can be sent to [yeoul@theplato.io](mailto:yeoul@theplato.io); once we open up, this section will list public contribution guidelines.
329
352
 
330
353
  ---
331
354
 
package/dist/bin/tiro.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin/tiro.ts
4
- import { Command as Command13 } from "commander";
4
+ import { Command as Command19 } from "commander";
5
5
 
6
6
  // src/lib/version.ts
7
7
  import { readFileSync } from "fs";
@@ -43,6 +43,7 @@ var TiroError = class extends Error {
43
43
  errorType;
44
44
  httpStatus;
45
45
  requestId;
46
+ details;
46
47
  exitCode;
47
48
  constructor(payload, exitCode = ExitCode.Generic) {
48
49
  super(payload.message);
@@ -52,6 +53,7 @@ var TiroError = class extends Error {
52
53
  this.errorType = payload.errorType;
53
54
  this.httpStatus = payload.httpStatus;
54
55
  this.requestId = payload.requestId;
56
+ this.details = payload.details;
55
57
  this.exitCode = exitCode;
56
58
  }
57
59
  toJSON() {
@@ -63,7 +65,8 @@ var TiroError = class extends Error {
63
65
  ...this.suggestion !== void 0 && { suggestion: this.suggestion },
64
66
  ...this.errorType !== void 0 && { errorType: this.errorType },
65
67
  ...this.httpStatus !== void 0 && { httpStatus: this.httpStatus },
66
- ...this.requestId !== void 0 && { requestId: this.requestId }
68
+ ...this.requestId !== void 0 && { requestId: this.requestId },
69
+ ...this.details !== void 0 && { details: this.details }
67
70
  }
68
71
  };
69
72
  }
@@ -461,6 +464,7 @@ var config = new Conf({
461
464
  oauthClientId: null,
462
465
  oauthClientIdRegisteredAt: null,
463
466
  oauthClientHostname: null,
467
+ oauthClientRedirectUri: null,
464
468
  defaultOutputDir: null
465
469
  }
466
470
  });
@@ -518,25 +522,40 @@ function getHostname(override) {
518
522
  if (env) return validateHostname(env);
519
523
  return validateHostname(config.get("hostname"));
520
524
  }
521
- function getOauthClientId(hostname) {
525
+ function getOauthClientId(hostname, currentRedirectUri) {
522
526
  const id = config.get("oauthClientId");
523
527
  const registeredAt = config.get("oauthClientIdRegisteredAt");
524
528
  const cachedHostname = config.get("oauthClientHostname");
529
+ const cachedRedirectUri = config.get("oauthClientRedirectUri");
525
530
  if (!id || !registeredAt) return null;
526
531
  if (cachedHostname !== hostname) return null;
532
+ if (!cachedRedirectUri) return null;
533
+ if (!sameRedirectTarget(cachedRedirectUri, currentRedirectUri)) return null;
527
534
  const ttlMs = 29 * 24 * 60 * 60 * 1e3;
528
535
  if (Date.now() - registeredAt > ttlMs) return null;
529
536
  return id;
530
537
  }
531
- function setOauthClientId(clientId, hostname) {
538
+ function setOauthClientId(clientId, hostname, redirectUri) {
532
539
  config.set("oauthClientId", clientId);
533
540
  config.set("oauthClientIdRegisteredAt", Date.now());
534
541
  config.set("oauthClientHostname", hostname);
542
+ config.set("oauthClientRedirectUri", redirectUri);
535
543
  }
536
544
  function clearOauthClientId() {
537
545
  config.set("oauthClientId", null);
538
546
  config.set("oauthClientIdRegisteredAt", null);
539
547
  config.set("oauthClientHostname", null);
548
+ config.set("oauthClientRedirectUri", null);
549
+ }
550
+ function sameRedirectTarget(a, b) {
551
+ let pa, pb;
552
+ try {
553
+ pa = new URL(a);
554
+ pb = new URL(b);
555
+ } catch {
556
+ return false;
557
+ }
558
+ return pa.protocol === pb.protocol && pa.hostname === pb.hostname && pa.pathname === pb.pathname;
540
559
  }
541
560
  function stripTrailingSlash(s) {
542
561
  return s.endsWith("/") ? s.slice(0, -1) : s;
@@ -617,7 +636,7 @@ ${authorizeUrl}`);
617
636
  }
618
637
  }
619
638
  async function ensureOauthClient(hostname, redirectUri) {
620
- const cached = getOauthClientId(hostname);
639
+ const cached = getOauthClientId(hostname, redirectUri);
621
640
  if (cached) return cached;
622
641
  const url = `${hostname}/v1/mcp/oauth/register`;
623
642
  let res;
@@ -670,7 +689,7 @@ async function ensureOauthClient(hostname, redirectUri) {
670
689
  ExitCode.Generic
671
690
  );
672
691
  }
673
- setOauthClientId(parsed.data.client_id, hostname);
692
+ setOauthClientId(parsed.data.client_id, hostname, redirectUri);
674
693
  return parsed.data.client_id;
675
694
  }
676
695
  function buildAuthorizeUrl(input) {
@@ -971,6 +990,116 @@ var ApiErrorSchema = z2.object({
971
990
  detail: z2.string().nullable().optional()
972
991
  })
973
992
  });
993
+ var WorkspaceMeSchema = z2.object({
994
+ guid: z2.string()
995
+ }).passthrough();
996
+ var WorkspaceItemSchema = z2.object({
997
+ guid: z2.string(),
998
+ name: z2.string(),
999
+ isWikiEnabled: z2.boolean()
1000
+ }).passthrough();
1001
+ var WorkspacesListSchema = z2.object({
1002
+ workspaces: z2.array(WorkspaceItemSchema)
1003
+ }).passthrough();
1004
+ var WikiSearchPageSchema = z2.object({
1005
+ guid: z2.string(),
1006
+ wikiId: z2.number(),
1007
+ canonicalName: z2.string(),
1008
+ pageType: z2.string(),
1009
+ entitySubtype: z2.string().nullable().optional(),
1010
+ score: z2.number()
1011
+ }).passthrough();
1012
+ var WikiSearchPagesResponseSchema = z2.object({
1013
+ items: z2.array(WikiSearchPageSchema)
1014
+ }).passthrough();
1015
+ var WikiMentionSchema = z2.object({
1016
+ guid: z2.string(),
1017
+ noteId: z2.number(),
1018
+ paragraphId: z2.number().nullable().optional(),
1019
+ paragraphUuid: z2.string().nullable().optional(),
1020
+ kind: z2.string(),
1021
+ sourceUserId: z2.number(),
1022
+ extractedText: z2.string(),
1023
+ confidence: z2.number().nullable().optional(),
1024
+ createdAt: z2.string()
1025
+ }).passthrough();
1026
+ var WikiAliasSchema = z2.object({
1027
+ guid: z2.string(),
1028
+ alias: z2.string(),
1029
+ source: z2.string(),
1030
+ sourceMentionGuid: z2.string().nullable().optional(),
1031
+ sourceUserId: z2.number().nullable().optional(),
1032
+ createdAt: z2.string()
1033
+ }).passthrough();
1034
+ var WikiLinkSchema = z2.object({
1035
+ guid: z2.string(),
1036
+ sourcePageGuid: z2.string(),
1037
+ sourcePageName: z2.string().nullable().optional(),
1038
+ targetPageGuid: z2.string(),
1039
+ targetPageName: z2.string().nullable().optional(),
1040
+ linkType: z2.string(),
1041
+ linkTypeDisplayKo: z2.string().optional(),
1042
+ linkTypeDisplayEn: z2.string().optional(),
1043
+ isDirectional: z2.boolean(),
1044
+ source: z2.string().optional(),
1045
+ creatorUserId: z2.number().nullable().optional(),
1046
+ createdAt: z2.string().optional(),
1047
+ updatedAt: z2.string().optional()
1048
+ }).passthrough();
1049
+ var WikiPageDetailSchema = z2.object({
1050
+ guid: z2.string(),
1051
+ wikiId: z2.number(),
1052
+ canonicalName: z2.string(),
1053
+ description: z2.string().nullable().optional(),
1054
+ descriptionStatus: z2.string().nullable().optional(),
1055
+ regenerationAvailable: z2.boolean().nullable().optional(),
1056
+ pageType: z2.string(),
1057
+ entitySubtype: z2.string().nullable().optional(),
1058
+ extractionStatus: z2.string(),
1059
+ mentionCountVisible: z2.number(),
1060
+ mentions: z2.array(WikiMentionSchema),
1061
+ aliases: z2.array(WikiAliasSchema),
1062
+ links: z2.array(WikiLinkSchema),
1063
+ lastUpdatedVisible: z2.string().nullable().optional(),
1064
+ createdAt: z2.string(),
1065
+ updatedAt: z2.string()
1066
+ }).passthrough();
1067
+ var WikiMentionListResponseSchema = z2.object({
1068
+ items: z2.array(WikiMentionSchema),
1069
+ nextCursorCreatedAt: z2.string().nullable(),
1070
+ nextCursorId: z2.number().nullable()
1071
+ }).passthrough();
1072
+ var WikiGraphNodeSchema = z2.object({
1073
+ guid: z2.string(),
1074
+ canonicalName: z2.string(),
1075
+ pageType: z2.string(),
1076
+ entitySubtype: z2.string().nullable().optional(),
1077
+ extractionStatus: z2.string(),
1078
+ mentionCountVisible: z2.number()
1079
+ }).passthrough();
1080
+ var WikiGraphEdgeSchema = z2.object({
1081
+ guid: z2.string(),
1082
+ sourcePageGuid: z2.string(),
1083
+ targetPageGuid: z2.string(),
1084
+ linkType: z2.string(),
1085
+ linkTypeDisplayKo: z2.string().optional(),
1086
+ linkTypeDisplayEn: z2.string().optional(),
1087
+ isDirectional: z2.boolean()
1088
+ }).passthrough();
1089
+ var WikiGraphResponseSchema = z2.object({
1090
+ nodes: z2.array(WikiGraphNodeSchema),
1091
+ edges: z2.array(WikiGraphEdgeSchema)
1092
+ }).passthrough();
1093
+ var WikiPlanRequiredSchema = z2.object({
1094
+ // Two gate kinds, relayed verbatim:
1095
+ // WIKI_PLAN_REQUIRED β†’ plan ineligible (upsell; required_plans set)
1096
+ // WIKI_NOT_ACTIVATED β†’ eligible plan, wiki not activated by an admin (required_plans null)
1097
+ error_code: z2.enum(["WIKI_PLAN_REQUIRED", "WIKI_NOT_ACTIVATED"]),
1098
+ message: z2.string(),
1099
+ current_plan: z2.string().nullable().optional(),
1100
+ required_plans: z2.array(z2.string()).nullable().optional(),
1101
+ action_url: z2.string().nullable().optional()
1102
+ }).passthrough();
974
1103
 
975
1104
  // src/lib/api/client.ts
976
1105
  var TiroApiClient = class {
@@ -1062,6 +1191,10 @@ var TiroApiClient = class {
1062
1191
  async function mapHttpError(res, method, path) {
1063
1192
  const requestId = res.headers.get("x-request-id") ?? void 0;
1064
1193
  const exitCode = res.status === 401 ? ExitCode.AuthRequired : ExitCode.Generic;
1194
+ if (res.status === 402) {
1195
+ const gate = await tryParsePlanGate(res);
1196
+ if (gate) return planGateError(gate, requestId);
1197
+ }
1065
1198
  const apiError = await tryParseApiError(res);
1066
1199
  if (apiError) {
1067
1200
  return new TiroError(
@@ -1092,6 +1225,7 @@ async function mapHttpError(res, method, path) {
1092
1225
  function httpStatusToErrorType(status) {
1093
1226
  if (status === 400) return "bad_request";
1094
1227
  if (status === 401) return "unauthorized";
1228
+ if (status === 402) return "payment_required";
1095
1229
  if (status === 403) return "forbidden";
1096
1230
  if (status === 404) return "not_found";
1097
1231
  if (status === 409) return "conflict";
@@ -1109,6 +1243,31 @@ async function tryParseApiError(res) {
1109
1243
  return null;
1110
1244
  }
1111
1245
  }
1246
+ async function tryParsePlanGate(res) {
1247
+ try {
1248
+ const json = await res.clone().json();
1249
+ const parsed = WikiPlanRequiredSchema.safeParse(json);
1250
+ return parsed.success ? parsed.data : null;
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ }
1255
+ function planGateError(gate, requestId) {
1256
+ const actionUrl = gate.action_url ?? null;
1257
+ return new TiroError(
1258
+ {
1259
+ // Relay the backend's discriminator (WIKI_PLAN_REQUIRED / WIKI_NOT_ACTIVATED) verbatim.
1260
+ code: gate.error_code,
1261
+ message: gate.message,
1262
+ errorType: "payment_required",
1263
+ httpStatus: 402,
1264
+ ...actionUrl ? { suggestion: `Upgrade: ${actionUrl}` } : {},
1265
+ ...requestId !== void 0 && { requestId },
1266
+ details: { ...gate }
1267
+ },
1268
+ ExitCode.Generic
1269
+ );
1270
+ }
1112
1271
  function buildApiUrl(hostname, path, params) {
1113
1272
  if (!path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) {
1114
1273
  throw new TiroError(
@@ -1249,7 +1408,7 @@ function clampLimit(raw) {
1249
1408
  if (!Number.isFinite(n) || n <= 0) return DEFAULT_PAGE_SIZE;
1250
1409
  return Math.min(n, MAX_PAGE_SIZE);
1251
1410
  }
1252
- function printPretty(notes, nextCursor, opts) {
1411
+ function printPretty(notes, nextCursor2, opts) {
1253
1412
  if (notes.length === 0) {
1254
1413
  process.stdout.write(`${color("(no notes)", "gray", opts)}
1255
1414
  `);
@@ -1265,10 +1424,10 @@ function printPretty(notes, nextCursor, opts) {
1265
1424
  `
1266
1425
  );
1267
1426
  }
1268
- if (nextCursor) {
1427
+ if (nextCursor2) {
1269
1428
  process.stdout.write(
1270
1429
  `${color(`
1271
- next: --cursor ${nextCursor}`, "gray", opts)}
1430
+ next: --cursor ${nextCursor2}`, "gray", opts)}
1272
1431
  `
1273
1432
  );
1274
1433
  }
@@ -1397,7 +1556,7 @@ function clampLimit2(raw) {
1397
1556
  if (!Number.isFinite(n) || n <= 0) return DEFAULT_PAGE_SIZE2;
1398
1557
  return Math.min(n, MAX_PAGE_SIZE2);
1399
1558
  }
1400
- function printPretty2(notes, nextCursor, opts) {
1559
+ function printPretty2(notes, nextCursor2, opts) {
1401
1560
  if (notes.length === 0) {
1402
1561
  process.stdout.write(`${color("(no matches)", "gray", opts)}
1403
1562
  `);
@@ -1410,10 +1569,10 @@ function printPretty2(notes, nextCursor, opts) {
1410
1569
  `
1411
1570
  );
1412
1571
  }
1413
- if (nextCursor) {
1572
+ if (nextCursor2) {
1414
1573
  process.stdout.write(
1415
1574
  `${color(`
1416
- next: --cursor ${nextCursor}`, "gray", opts)}
1575
+ next: --cursor ${nextCursor2}`, "gray", opts)}
1417
1576
  `
1418
1577
  );
1419
1578
  }
@@ -1907,6 +2066,413 @@ function registerNotes(program) {
1907
2066
  registerNotesTranscript(notes);
1908
2067
  }
1909
2068
 
2069
+ // src/commands/wiki/index.ts
2070
+ import "commander";
2071
+
2072
+ // src/commands/wiki/search.ts
2073
+ import "commander";
2074
+
2075
+ // src/lib/api/workspace.ts
2076
+ var cache = /* @__PURE__ */ new WeakMap();
2077
+ async function resolveWorkspaceGuid(client) {
2078
+ const cached = cache.get(client);
2079
+ if (cached) return cached;
2080
+ const promise = client.getJson("/v1/external/workspaces/me", WorkspaceMeSchema).then((res) => res.guid);
2081
+ cache.set(client, promise);
2082
+ return promise;
2083
+ }
2084
+ async function listWorkspaces(client) {
2085
+ const res = await client.getJson("/v1/external/workspaces", WorkspacesListSchema);
2086
+ return res.workspaces;
2087
+ }
2088
+
2089
+ // src/commands/wiki/search.ts
2090
+ var MAX_SIZE = 100;
2091
+ var MAX_QUERY_LENGTH = 500;
2092
+ var HELP_AFTER3 = `
2093
+ Examples:
2094
+ tiro wiki search "onboarding"
2095
+ tiro wiki search "payment gateway" --size 50 --json
2096
+ tiro wiki search "billing" --workspace <guid>
2097
+
2098
+ The workspace defaults to the one implicit in your API key. Pass --workspace
2099
+ to target a specific workspace (get guids from 'tiro wiki workspaces').
2100
+ Results are ranked wiki pages (pageGuid, name, type, relevance). Pass a
2101
+ pageGuid to 'tiro wiki page', 'tiro wiki mentions', or 'tiro wiki graph'.
2102
+ `;
2103
+ function registerWikiSearch(parent) {
2104
+ parent.command("search <query>").description("Search the workspace wiki for pages matching a keyword.").option("--size <n>", `Max results (default: backend default, max ${MAX_SIZE})`).option("--workspace <guid>", "Target a specific workspace (from `tiro wiki workspaces`); defaults to your default workspace").addHelpText("after", HELP_AFTER3).action(async (query, opts, cmd) => {
2105
+ const globalOpts = cmd.optsWithGlobals();
2106
+ const keyword = query.trim();
2107
+ if (!keyword) {
2108
+ throw new TiroError(
2109
+ {
2110
+ code: "missing_query",
2111
+ message: "wiki search requires a query.",
2112
+ errorType: "bad_request",
2113
+ suggestion: 'tiro wiki search "onboarding"'
2114
+ },
2115
+ ExitCode.Usage
2116
+ );
2117
+ }
2118
+ const client = createApiClient({
2119
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
2120
+ });
2121
+ const workspaceGuid = opts.workspace ?? await resolveWorkspaceGuid(client);
2122
+ const clampedKeyword = keyword.length > MAX_QUERY_LENGTH ? keyword.slice(0, MAX_QUERY_LENGTH) : keyword;
2123
+ const params = { q: clampedKeyword };
2124
+ const size = clampSize(opts.size);
2125
+ if (size !== void 0) params["size"] = size;
2126
+ const res = await client.getJson(
2127
+ // Fix 1: encode workspaceGuid so path-traversal sequences (../, ?, #) are neutralised.
2128
+ `/v1/external/workspaces/${encodeURIComponent(workspaceGuid)}/wiki/search/pages`,
2129
+ WikiSearchPagesResponseSchema,
2130
+ params
2131
+ );
2132
+ const mode = resolveOutputMode(globalOpts);
2133
+ if (mode === "json") {
2134
+ for (const item of res.items) printNdjson(item);
2135
+ } else {
2136
+ printPretty3(res.items, globalOpts);
2137
+ }
2138
+ });
2139
+ }
2140
+ function clampSize(raw) {
2141
+ if (!raw) return void 0;
2142
+ const n = parseInt(raw, 10);
2143
+ if (!Number.isFinite(n) || n <= 0) return void 0;
2144
+ return Math.min(n, MAX_SIZE);
2145
+ }
2146
+ function printPretty3(items, opts) {
2147
+ if (items.length === 0) {
2148
+ process.stdout.write(`${color("(no matches)", "gray", opts)}
2149
+ `);
2150
+ return;
2151
+ }
2152
+ for (const it of items) {
2153
+ const score = it.score.toFixed(2);
2154
+ process.stdout.write(
2155
+ `${color(score, "cyan", opts)} ${color(it.guid, "dim", opts)} ${color(it.pageType, "gray", opts)} ${it.canonicalName}
2156
+ `
2157
+ );
2158
+ }
2159
+ }
2160
+
2161
+ // src/commands/wiki/page.ts
2162
+ import "commander";
2163
+ var HELP_AFTER4 = `
2164
+ Examples:
2165
+ tiro wiki page <pageGuid>
2166
+ tiro wiki page <pageGuid> --json
2167
+ tiro wiki page <pageGuid> --workspace <guid>
2168
+
2169
+ Returns the page body (per-user description) plus metadata, mentions,
2170
+ aliases, and links. The workspace defaults to the one implicit in your API
2171
+ key; pass --workspace to target a specific workspace (from 'tiro wiki workspaces').
2172
+ `;
2173
+ function registerWikiPage(parent) {
2174
+ parent.command("page <pageGuid>").description("Get a single wiki page: body, metadata, mentions, aliases, and links.").option("--workspace <guid>", "Target a specific workspace (from `tiro wiki workspaces`); defaults to your default workspace").addHelpText("after", HELP_AFTER4).action(async (pageGuid, opts, cmd) => {
2175
+ const globalOpts = cmd.optsWithGlobals();
2176
+ const client = createApiClient({
2177
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
2178
+ });
2179
+ const workspaceGuid = opts.workspace ?? await resolveWorkspaceGuid(client);
2180
+ const page = await client.getJson(
2181
+ `/v1/external/workspaces/${encodeURIComponent(workspaceGuid)}/wiki/pages/${encodeURIComponent(pageGuid)}`,
2182
+ WikiPageDetailSchema
2183
+ );
2184
+ const mode = resolveOutputMode(globalOpts);
2185
+ if (mode === "json") {
2186
+ printOutput({ ok: true, data: page }, globalOpts);
2187
+ } else {
2188
+ printPretty4(page, globalOpts);
2189
+ }
2190
+ });
2191
+ }
2192
+ function printPretty4(page, opts) {
2193
+ const w = process.stdout.write.bind(process.stdout);
2194
+ w(`${color(page.canonicalName, "bold", opts)} ${color(`(${page.pageType})`, "gray", opts)}
2195
+ `);
2196
+ w(`${color(page.guid, "dim", opts)}
2197
+
2198
+ `);
2199
+ if (page.description) {
2200
+ w(`${page.description}
2201
+
2202
+ `);
2203
+ } else {
2204
+ const status = page.descriptionStatus ?? "none";
2205
+ w(`${color(`(no description \u2014 status: ${status})`, "gray", opts)}
2206
+
2207
+ `);
2208
+ }
2209
+ w(
2210
+ `${color("mentions", "gray", opts)} ${page.mentionCountVisible} ${color("aliases", "gray", opts)} ${page.aliases.length} ${color("links", "gray", opts)} ${page.links.length}
2211
+ `
2212
+ );
2213
+ }
2214
+
2215
+ // src/commands/wiki/mentions.ts
2216
+ import "commander";
2217
+ var MAX_LIMIT = 200;
2218
+ var HELP_AFTER5 = `
2219
+ Examples:
2220
+ tiro wiki mentions <pageGuid>
2221
+ tiro wiki mentions <pageGuid> --limit 100 --json
2222
+ tiro wiki mentions <pageGuid> --workspace <guid>
2223
+
2224
+ Lists the note paragraphs that reference a wiki page. In JSON mode a trailing
2225
+ {_cursor: ...} object is emitted when more pages are available; pass the
2226
+ cursor token back via --cursor to fetch the next page. The workspace defaults
2227
+ to the one implicit in your API key; pass --workspace to target a specific
2228
+ workspace (from 'tiro wiki workspaces').
2229
+ `;
2230
+ function registerWikiMentions(parent) {
2231
+ parent.command("mentions <pageGuid>").description("List the note mentions that reference a single wiki page.").option("--limit <n>", `Max mentions per page (max ${MAX_LIMIT})`).option("--cursor <token>", "Continue from a previous page's cursor token.").option("--workspace <guid>", "Target a specific workspace (from `tiro wiki workspaces`); defaults to your default workspace").addHelpText("after", HELP_AFTER5).action(async (pageGuid, opts, cmd) => {
2232
+ const globalOpts = cmd.optsWithGlobals();
2233
+ const client = createApiClient({
2234
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
2235
+ });
2236
+ const workspaceGuid = opts.workspace ?? await resolveWorkspaceGuid(client);
2237
+ const params = {};
2238
+ const limit = clampLimit3(opts.limit);
2239
+ if (limit !== void 0) params["limit"] = limit;
2240
+ if (opts.cursor) params["cursor"] = opts.cursor;
2241
+ const res = await client.getJson(
2242
+ `/v1/external/workspaces/${encodeURIComponent(workspaceGuid)}/wiki/pages/${encodeURIComponent(pageGuid)}/mentions`,
2243
+ WikiMentionListResponseSchema,
2244
+ params
2245
+ );
2246
+ const mode = resolveOutputMode(globalOpts);
2247
+ if (mode === "json") {
2248
+ for (const item of res.items) printNdjson(item);
2249
+ const next = nextCursor(res.nextCursorCreatedAt, res.nextCursorId);
2250
+ if (next) printNdjson({ _cursor: next });
2251
+ } else {
2252
+ printPretty5(res.items, globalOpts);
2253
+ }
2254
+ });
2255
+ }
2256
+ function clampLimit3(raw) {
2257
+ if (!raw) return void 0;
2258
+ const n = parseInt(raw, 10);
2259
+ if (!Number.isFinite(n) || n <= 0) return void 0;
2260
+ return Math.min(n, MAX_LIMIT);
2261
+ }
2262
+ function nextCursor(createdAt, id) {
2263
+ if (createdAt === null || id === null) return null;
2264
+ const millis = Date.parse(createdAt);
2265
+ if (Number.isNaN(millis)) return null;
2266
+ return `${millis}_${id}`;
2267
+ }
2268
+ function printPretty5(items, opts) {
2269
+ if (items.length === 0) {
2270
+ process.stdout.write(`${color("(no mentions)", "gray", opts)}
2271
+ `);
2272
+ return;
2273
+ }
2274
+ for (const m of items) {
2275
+ const date = m.createdAt.slice(0, 10);
2276
+ process.stdout.write(
2277
+ `${color(date, "gray", opts)} ${color(m.kind, "cyan", opts)} ${m.extractedText}
2278
+ `
2279
+ );
2280
+ }
2281
+ }
2282
+
2283
+ // src/commands/wiki/graph.ts
2284
+ import "commander";
2285
+ var MODES = ["seed", "expand", "around", "links"];
2286
+ var MAX_QUERY_LENGTH2 = 500;
2287
+ var GRAPH_CAP_DEFAULT = 50;
2288
+ var HELP_AFTER6 = `
2289
+ Modes (--mode, default: around):
2290
+ around Neighborhood around <pageGuid> (--radius hops, default 2)
2291
+ expand Outward expansion from <pageGuid> (--depth hops, default 1)
2292
+ links Links among <pageGuid> + each --page <guid> (repeatable)
2293
+ seed Overview seed graph (no <pageGuid> required; --query, --type, --since)
2294
+
2295
+ Examples:
2296
+ tiro wiki graph <pageGuid> # around, radius 2
2297
+ tiro wiki graph <pageGuid> --mode expand --depth 2
2298
+ tiro wiki graph <pageGuid> --mode links --page <g2> --page <g3>
2299
+ tiro wiki graph --mode seed --query "billing"
2300
+ tiro wiki graph <pageGuid> --workspace <guid>
2301
+
2302
+ Returns a node+edge slice of the workspace wiki graph. The workspace defaults
2303
+ to the one implicit in your API key; pass --workspace to target a specific
2304
+ workspace (from 'tiro wiki workspaces').
2305
+ `;
2306
+ function registerWikiGraph(parent) {
2307
+ parent.command("graph [pageGuid]").description("Get a node+edge slice of the wiki link graph around a page.").option("--mode <mode>", `Graph mode: ${MODES.join(" | ")} (default: around)`).option("--depth <n>", "expand mode: link-hops to traverse outward (default 1)").option("--radius <n>", "around mode: neighborhood radius in hops (default 2)").option("--limit <n>", "Max nodes to return (max 200)").option("--page <guid>", "links mode: additional page guid (repeatable)", collect, []).option("--type <type>", "seed mode: restrict to a page type (CONCEPT|DECISION|ENTITY)").option("--since <date>", "seed mode: only pages updated at/after this date").option("--query <keyword>", "seed mode: keyword to seed the overview graph").option("--workspace <guid>", "Target a specific workspace (from `tiro wiki workspaces`); defaults to your default workspace").addHelpText("after", HELP_AFTER6).action(async (pageGuid, opts, cmd) => {
2308
+ const globalOpts = cmd.optsWithGlobals();
2309
+ const mode = parseMode(opts.mode);
2310
+ if ((mode === "expand" || mode === "around") && !pageGuid) {
2311
+ throw new TiroError(
2312
+ {
2313
+ code: "missing_page_guid",
2314
+ message: `--mode ${mode} requires a <pageGuid> positional argument.`,
2315
+ errorType: "bad_request",
2316
+ suggestion: `tiro wiki graph <pageGuid> --mode ${mode}`
2317
+ },
2318
+ ExitCode.Usage
2319
+ );
2320
+ }
2321
+ const client = createApiClient({
2322
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
2323
+ });
2324
+ const workspaceGuid = opts.workspace ?? await resolveWorkspaceGuid(client);
2325
+ const effectiveLimit = mode === "links" ? void 0 : clampInt(opts.limit, 200) ?? GRAPH_CAP_DEFAULT;
2326
+ const raw = await fetchGraph(client, workspaceGuid, mode, pageGuid ?? "", opts, effectiveLimit);
2327
+ const res = applyGraphCap(raw, effectiveLimit ?? raw.nodes.length);
2328
+ const mode2 = resolveOutputMode(globalOpts);
2329
+ if (mode2 === "json") {
2330
+ printOutput({ ok: true, data: res }, globalOpts);
2331
+ } else {
2332
+ printPretty6(res, globalOpts);
2333
+ }
2334
+ });
2335
+ }
2336
+ async function fetchGraph(client, workspaceGuid, mode, pageGuid, opts, limit) {
2337
+ const base = `/v1/external/workspaces/${encodeURIComponent(workspaceGuid)}/wiki/graph`;
2338
+ if (mode === "expand") {
2339
+ return client.getJson(`${base}/expand`, WikiGraphResponseSchema, {
2340
+ pageGuid,
2341
+ depth: clampInt(opts.depth, 200),
2342
+ limit
2343
+ });
2344
+ }
2345
+ if (mode === "around") {
2346
+ return client.getJson(`${base}/around`, WikiGraphResponseSchema, {
2347
+ pageGuid,
2348
+ radius: clampInt(opts.radius, 200),
2349
+ limit
2350
+ });
2351
+ }
2352
+ if (mode === "links") {
2353
+ const guids = [pageGuid, ...opts.page ?? []].filter((g) => g && g.length > 0);
2354
+ return client.getJson(`${base}/links`, WikiGraphResponseSchema, {
2355
+ pageGuids: guids.join(",")
2356
+ });
2357
+ }
2358
+ const rawQuery = opts.query;
2359
+ const clampedQuery = rawQuery && rawQuery.length > MAX_QUERY_LENGTH2 ? rawQuery.slice(0, MAX_QUERY_LENGTH2) : rawQuery;
2360
+ return client.getJson(`${base}/seed`, WikiGraphResponseSchema, {
2361
+ type: opts.type,
2362
+ since: opts.since ? parseDate(opts.since) : void 0,
2363
+ q: clampedQuery,
2364
+ limit
2365
+ });
2366
+ }
2367
+ function parseMode(raw) {
2368
+ if (!raw) return "around";
2369
+ const m = raw.toLowerCase();
2370
+ if (MODES.includes(m)) return m;
2371
+ throw new TiroError(
2372
+ {
2373
+ code: "invalid_mode",
2374
+ message: `Invalid --mode "${raw}". Allowed: ${MODES.join(", ")}.`,
2375
+ errorType: "bad_request",
2376
+ suggestion: "tiro wiki graph <pageGuid> --mode around"
2377
+ },
2378
+ ExitCode.Usage
2379
+ );
2380
+ }
2381
+ function clampInt(raw, max) {
2382
+ if (!raw) return void 0;
2383
+ const n = parseInt(raw, 10);
2384
+ if (!Number.isFinite(n) || n <= 0) return void 0;
2385
+ return Math.min(n, max);
2386
+ }
2387
+ function collect(value, prev) {
2388
+ return [...prev, value];
2389
+ }
2390
+ function applyGraphCap(graph, limit) {
2391
+ const keptNodes = graph.nodes.slice(0, limit);
2392
+ const keptGuids = new Set(keptNodes.map((n) => n.guid));
2393
+ const keptEdges = graph.edges.filter(
2394
+ (e) => keptGuids.has(e.sourcePageGuid) && keptGuids.has(e.targetPageGuid)
2395
+ );
2396
+ const truncated = graph.nodes.length > limit || keptEdges.length < graph.edges.length;
2397
+ return { ...graph, nodes: keptNodes, edges: keptEdges, truncated };
2398
+ }
2399
+ function printPretty6(res, opts) {
2400
+ const w = process.stdout.write.bind(process.stdout);
2401
+ w(
2402
+ `${color("nodes", "gray", opts)} ${res.nodes.length} ${color("edges", "gray", opts)} ${res.edges.length}
2403
+ `
2404
+ );
2405
+ if (res.truncated) {
2406
+ w(
2407
+ `${color(`(truncated \u2014 showing first ${res.nodes.length} nodes; narrow with --query / --type / smaller --radius)`, "gray", opts)}
2408
+ `
2409
+ );
2410
+ }
2411
+ w("\n");
2412
+ for (const n of res.nodes) {
2413
+ w(`${color("\u25CF", "cyan", opts)} ${color(n.guid, "dim", opts)} ${n.canonicalName}
2414
+ `);
2415
+ }
2416
+ if (res.edges.length > 0) w("\n");
2417
+ for (const e of res.edges) {
2418
+ const arrow = e.isDirectional ? "\u2192" : "\u2014";
2419
+ w(
2420
+ `${color(e.sourcePageGuid, "dim", opts)} ${arrow} ${color(e.targetPageGuid, "dim", opts)} ${color(e.linkType, "gray", opts)}
2421
+ `
2422
+ );
2423
+ }
2424
+ }
2425
+
2426
+ // src/commands/wiki/workspaces.ts
2427
+ import "commander";
2428
+ var HELP_AFTER7 = `
2429
+ Examples:
2430
+ tiro wiki workspaces
2431
+ tiro wiki workspaces --json
2432
+
2433
+ Lists all workspaces you are a member of. Use the guid with --workspace on
2434
+ any other wiki command to target a non-default workspace.
2435
+ `;
2436
+ function registerWikiWorkspaces(parent) {
2437
+ parent.command("workspaces").description("List all workspaces you are a member of (use guid with --workspace).").addHelpText("after", HELP_AFTER7).action(async (_opts, cmd) => {
2438
+ const globalOpts = cmd.optsWithGlobals();
2439
+ const client = createApiClient({
2440
+ ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
2441
+ });
2442
+ const workspaces = await listWorkspaces(client);
2443
+ const mode = resolveOutputMode(globalOpts);
2444
+ if (mode === "json") {
2445
+ for (const ws of workspaces) printNdjson(ws);
2446
+ } else {
2447
+ printPretty7(workspaces, globalOpts);
2448
+ }
2449
+ });
2450
+ }
2451
+ function printPretty7(workspaces, opts) {
2452
+ if (workspaces.length === 0) {
2453
+ process.stdout.write(`${color("(no workspaces)", "gray", opts)}
2454
+ `);
2455
+ return;
2456
+ }
2457
+ for (const ws of workspaces) {
2458
+ const wikiStatus = ws.isWikiEnabled ? color("wiki:on", "green", opts) : color("wiki:off", "gray", opts);
2459
+ process.stdout.write(
2460
+ `${color(ws.guid, "dim", opts)} ${wikiStatus} ${ws.name}
2461
+ `
2462
+ );
2463
+ }
2464
+ }
2465
+
2466
+ // src/commands/wiki/index.ts
2467
+ function registerWiki(program) {
2468
+ const wiki = program.command("wiki").description("Search and explore the workspace wiki (pages, mentions, link graph)");
2469
+ registerWikiSearch(wiki);
2470
+ registerWikiPage(wiki);
2471
+ registerWikiMentions(wiki);
2472
+ registerWikiGraph(wiki);
2473
+ registerWikiWorkspaces(wiki);
2474
+ }
2475
+
1910
2476
  // src/commands/mcp/index.ts
1911
2477
  import "commander";
1912
2478
 
@@ -2029,6 +2595,8 @@ EXAMPLES
2029
2595
  $ tiro notes get <guid> --output ./meeting.md --include transcript
2030
2596
  $ tiro notes transcript <guid> --format md --output ./transcript.md
2031
2597
  $ tiro notes transcript <guid> --format md --no-timestamps --output ./clean.md
2598
+ $ tiro wiki search "onboarding" --json
2599
+ $ tiro wiki graph <pageGuid> --mode around --radius 2
2032
2600
  $ tiro mcp install # one-line setup for Claude Code
2033
2601
 
2034
2602
  ENVIRONMENT
@@ -2041,11 +2609,12 @@ DOCS
2041
2609
  https://api-docs.tiro.ooo/cli
2042
2610
  `;
2043
2611
  function buildProgram() {
2044
- const program = new Command13();
2612
+ const program = new Command19();
2045
2613
  program.name("tiro").description("Tiro AI notes & transcripts \u2014 agent-first command line").version(VERSION, "-v, --version", "Print version").option("--hostname <url>", "API base URL (default: https://api.tiro.ooo)").option("--json", "Force JSON output").option("--pretty", "Force pretty (human) output").option("--quiet", "Suppress non-error output").option("--verbose", "Verbose logging to stderr").option("--no-color", "Disable ANSI colors").addHelpText("after", EXAMPLES);
2046
2614
  program.showHelpAfterError("(run `tiro --help` for available commands)");
2047
2615
  registerAuth(program);
2048
2616
  registerNotes(program);
2617
+ registerWiki(program);
2049
2618
  registerMcp(program);
2050
2619
  return program;
2051
2620
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theplato/tiro-cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Tiro AI notes & transcripts β€” agent-first command line",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit",
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
25
- "test:security": "vitest run --testNamePattern \"\\\\((C|H|M)[0-9]+\\\\)\"",
25
+ "test:security": "vitest run --testNamePattern \"\\\\((C|H|M|X)[0-9]+\\\\)\"",
26
26
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
27
27
  },
28
28
  "keywords": [