@theplato/tiro-cli 0.4.0 → 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.
package/AGENTS.md CHANGED
@@ -89,26 +89,15 @@ Two ways to authenticate:
89
89
  The CLI never prints tokens to stdout. `tiro auth status` shows only the
90
90
  first 4 characters of the access token.
91
91
 
92
- ## Security regression suite (pre-deploy contract)
93
-
94
- Every release MUST keep `npm run test:security` green. The suite pins the
95
- following defenses; if any test breaks, the corresponding attack surface
96
- has reopened do not publish.
97
-
98
- | ID | Surface | What the test guards |
99
- |---|---|---|
100
- | **C1** | `lib/config.ts` `validateHostname` | Rejects non-https/non-loopback hostnames so `TIRO_HOSTNAME` / `--hostname` cannot redirect OAuth + API traffic to an attacker. |
101
- | **H1** | `lib/auth/pkce.ts` `verifyState` | Constant-time OAuth state CSRF compare. |
102
- | **H2** | `lib/auth/loopback.ts` | OAuth redirect listener is GET-only, single-shot, `Connection: close` — local processes can't race or replay a callback. |
103
- | **H3** | `lib/api/client.ts` `buildApiUrl` | API path must be relative to the configured hostname refuses absolute / protocol-relative input that would leak the bearer token. |
104
- | **H4** | `lib/config.ts` `getOauthClientId` | DCR `client_id` cache is bound to the hostname it was registered against; a swapped hostname misses the cache. |
105
- | **M1** | `lib/output/file.ts` `assertSafeOutputPath` | `--output` rejects `..` segments unless `--force`; an agent forwarding untrusted input cannot escape its scope. |
106
- | **M2** | `lib/auth/token.ts` `decodeJwtPayload` | Documented as display-only — claims must never gate auth, scope, identity, or expiry decisions. |
107
-
108
- Pre-deploy checklist:
109
-
110
- 1. `npm run typecheck`
111
- 2. `npm run test:security` (regression gate)
112
- 3. `npm test` (full suite)
113
- 4. `npm run build`
114
- 5. Smoke: `node dist/bin/tiro.js auth status`
92
+ ## Security posture (summary)
93
+
94
+ - OAuth uses Authorization Code + PKCE with a CSRF-checked loopback
95
+ redirect. Tokens are held in the OS keychain or `TIRO_TOKEN` only —
96
+ never written to stdout, stderr, or disk in any other form.
97
+ - `TIRO_HOSTNAME` / `--hostname` are validated against an `https://` (or
98
+ loopback) allowlist before any OAuth or API request runs.
99
+ - `--output` paths that escape the working tree require `--force`.
100
+ - A dedicated regression suite (`npm run test:security`) pins these
101
+ surfaces and runs in `prepublishOnly` a broken defense blocks publish.
102
+
103
+ Always run on the latest released version; older versions are deprecated.
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).
@@ -108,7 +109,7 @@ tiro notes list
108
109
  ```bash
109
110
  tiro notes list # pretty table in TTY
110
111
  tiro notes list --json # NDJSON for pipes
111
- tiro notes list --keyword "OKR" --since 30d # OpenSearch reorder by keyword
112
+ tiro notes list --keyword "OKR" --since 30d # Reorder by keyword relevance
112
113
  tiro notes list --folder <id> --limit 100 --cursor <token>
113
114
  ```
114
115
 
@@ -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
@@ -287,7 +310,7 @@ The CLI sits on the public Tiro API alongside the MCP server, sharing the same O
287
310
 
288
311
  ```
289
312
  ┌──────────────────────────────┐
290
- │ Tiro Backend (Kotlin)
313
+ │ Tiro Backend
291
314
  │ /v1/external/* + /v1/mcp/* │
292
315
  └──────────────┬────────────────┘
293
316
 
@@ -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(
@@ -1198,17 +1357,18 @@ Examples:
1198
1357
  tiro notes list --folder <id> --limit 50
1199
1358
 
1200
1359
  Keyword matching:
1201
- --keyword reorders results by OpenSearch relevance (case-insensitive,
1202
- full-text against note title and paragraph content). When --keyword is
1203
- set, nextCursor is always null. Without --keyword, results are ordered
1204
- by createdAt desc.
1360
+ --keyword reorders results by full-text relevance over title and
1361
+ paragraph content (case-insensitive), with createdAt desc as tiebreaker
1362
+ when scores are close. When --keyword is set, nextCursor is always null.
1363
+ Without --keyword, results are ordered by recording time desc (falls back
1364
+ to createdAt for memo-only notes).
1205
1365
 
1206
1366
  Note: placeholder notes (title='Untitled' or sourceType='onboarding') are
1207
1367
  filtered out by default. A page of N may return fewer than N visible notes \u2014
1208
1368
  keep paginating to fetch more, or pass --include-untitled to surface them.
1209
1369
  `;
1210
1370
  function registerNotesList(parent) {
1211
- parent.command("list").description("List notes (lightweight metadata).").option("--keyword <text>", 'Reorder by OpenSearch relevance for this keyword (e.g. "OKR")').option("--folder <id>", "Restrict to a folder and its descendants").option(
1371
+ parent.command("list").description("List notes (lightweight metadata).").option("--keyword <text>", 'Reorder by full-text relevance for this keyword (e.g. "OKR")').option("--folder <id>", "Restrict to a folder and its descendants").option(
1212
1372
  "--since <date>",
1213
1373
  "Inclusive lower bound on createdAt (ISO-8601 or relative: 7d, 24h, 30m)"
1214
1374
  ).option("--until <date>", "Exclusive upper bound on createdAt").option(
@@ -1248,7 +1408,7 @@ function clampLimit(raw) {
1248
1408
  if (!Number.isFinite(n) || n <= 0) return DEFAULT_PAGE_SIZE;
1249
1409
  return Math.min(n, MAX_PAGE_SIZE);
1250
1410
  }
1251
- function printPretty(notes, nextCursor, opts) {
1411
+ function printPretty(notes, nextCursor2, opts) {
1252
1412
  if (notes.length === 0) {
1253
1413
  process.stdout.write(`${color("(no notes)", "gray", opts)}
1254
1414
  `);
@@ -1264,10 +1424,10 @@ function printPretty(notes, nextCursor, opts) {
1264
1424
  `
1265
1425
  );
1266
1426
  }
1267
- if (nextCursor) {
1427
+ if (nextCursor2) {
1268
1428
  process.stdout.write(
1269
1429
  `${color(`
1270
- next: --cursor ${nextCursor}`, "gray", opts)}
1430
+ next: --cursor ${nextCursor2}`, "gray", opts)}
1271
1431
  `
1272
1432
  );
1273
1433
  }
@@ -1307,12 +1467,16 @@ Examples:
1307
1467
  tiro notes search "release" --since 2026-04-01 --until 2026-05-01
1308
1468
 
1309
1469
  Keyword matching:
1310
- Full-text against note title + paragraph content via OpenSearch.
1311
- Case-insensitive. Multi-word keywords are tokenized \u2014 "OKR planning"
1312
- matches notes containing both terms. The deep search variant (this
1313
- command) hydrates each result with its primary documents (one-pager,
1314
- custom) so an MCP/LLM client can read content alongside metadata in
1315
- one call.
1470
+ Full-text search against note title + paragraph content. Case-insensitive.
1471
+ Multi-word keywords are tokenized \u2014 "OKR planning" matches notes containing
1472
+ both terms. The deep search variant (this command) hydrates each result
1473
+ with its primary documents (one-pager, custom) so an MCP/LLM client can
1474
+ read content alongside metadata in one call.
1475
+
1476
+ Ordering:
1477
+ Results are sorted by full-text relevance, then createdAt desc as
1478
+ tiebreaker. Without --limit, the server returns 50 results (capped
1479
+ at 200). Pass --limit to override.
1316
1480
 
1317
1481
  Note: placeholder notes (title='Untitled' or sourceType='onboarding') are
1318
1482
  filtered out by default. Pass --include-untitled to surface them.
@@ -1392,7 +1556,7 @@ function clampLimit2(raw) {
1392
1556
  if (!Number.isFinite(n) || n <= 0) return DEFAULT_PAGE_SIZE2;
1393
1557
  return Math.min(n, MAX_PAGE_SIZE2);
1394
1558
  }
1395
- function printPretty2(notes, nextCursor, opts) {
1559
+ function printPretty2(notes, nextCursor2, opts) {
1396
1560
  if (notes.length === 0) {
1397
1561
  process.stdout.write(`${color("(no matches)", "gray", opts)}
1398
1562
  `);
@@ -1405,10 +1569,10 @@ function printPretty2(notes, nextCursor, opts) {
1405
1569
  `
1406
1570
  );
1407
1571
  }
1408
- if (nextCursor) {
1572
+ if (nextCursor2) {
1409
1573
  process.stdout.write(
1410
1574
  `${color(`
1411
- next: --cursor ${nextCursor}`, "gray", opts)}
1575
+ next: --cursor ${nextCursor2}`, "gray", opts)}
1412
1576
  `
1413
1577
  );
1414
1578
  }
@@ -1902,6 +2066,413 @@ function registerNotes(program) {
1902
2066
  registerNotesTranscript(notes);
1903
2067
  }
1904
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
+
1905
2476
  // src/commands/mcp/index.ts
1906
2477
  import "commander";
1907
2478
 
@@ -2024,6 +2595,8 @@ EXAMPLES
2024
2595
  $ tiro notes get <guid> --output ./meeting.md --include transcript
2025
2596
  $ tiro notes transcript <guid> --format md --output ./transcript.md
2026
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
2027
2600
  $ tiro mcp install # one-line setup for Claude Code
2028
2601
 
2029
2602
  ENVIRONMENT
@@ -2036,11 +2609,12 @@ DOCS
2036
2609
  https://api-docs.tiro.ooo/cli
2037
2610
  `;
2038
2611
  function buildProgram() {
2039
- const program = new Command13();
2612
+ const program = new Command19();
2040
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);
2041
2614
  program.showHelpAfterError("(run `tiro --help` for available commands)");
2042
2615
  registerAuth(program);
2043
2616
  registerNotes(program);
2617
+ registerWiki(program);
2044
2618
  registerMcp(program);
2045
2619
  return program;
2046
2620
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theplato/tiro-cli",
3
- "version": "0.4.0",
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": [