@odla-ai/cli 0.18.0 → 0.20.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/README.md CHANGED
@@ -66,6 +66,23 @@ Platform runbook edits need admin or the `platform:runbook:write` capability,
66
66
  which the CLI requests for you; a plain handshake token is never admin by
67
67
  design, and `odla-ai whoami` reports what this terminal actually holds.
68
68
 
69
+ **Is the corpus itself still true?**
70
+
71
+ ```bash
72
+ npx odla-ai runbook lint
73
+ ```
74
+
75
+ Because a runbook is edited without a release, its text can name a command that
76
+ only a newer CLI has — and no gate in any repository can see that, since the
77
+ text is a row in a database rather than a file. `lint` closes that: it holds
78
+ every `odla-ai …` command the runbooks name against this CLI's actual command
79
+ surface, and against the minimum versions each runbook declares in `requires`.
80
+
81
+ A runbook may record the versions its steps assume, as `name@version` specs read
82
+ as minimums (`@odla-ai/cli@0.18.0`). When yours is older, `runbook get` says so
83
+ on **stderr** — never stdout, so piping the procedure into an agent still yields
84
+ the procedure and nothing else.
85
+
69
86
  ## Install
70
87
 
71
88
  No install needed — the package ships a single `odla-ai` binary, so npx can run
@@ -109,6 +126,7 @@ npx odla-ai runbook search "restore a tenant" --limit 5
109
126
  npx odla-ai runbook get release
110
127
  npx odla-ai runbook list
111
128
  npx odla-ai runbook impact --base origin/main
129
+ npx odla-ai runbook lint
112
130
  npx odla-ai runbook edit db --note "what changed"
113
131
  npx odla-ai runbook comment release --body "step 4 no longer applies"
114
132
  npx odla-ai whoami
@@ -118,7 +136,6 @@ npx odla-ai doctor
118
136
  npx odla-ai calendar status --env dev
119
137
  npx odla-ai calendar calendars --env dev
120
138
  npx odla-ai calendar connect --env dev
121
- npx odla-ai calendar resync --env dev
122
139
  npx odla-ai calendar disconnect --env dev --yes
123
140
  npx odla-ai capabilities --json
124
141
  npx odla-ai code connect --platform https://odla.ai --app-id my-app --env dev --email owner@example.com
@@ -318,19 +335,18 @@ agent automation. Once connected, `calendar calendars` lists ids visible to the
318
335
  Google identity so the checked-in selection can be refined. `calendar connect`
319
336
  applies the checked-in booking-page setting and starts consent when the
320
337
  connection is absent, failed, disconnected, or degraded (so an agent can
321
- repair revoked credentials); a healthy connection is reused. A `needs_sync`
322
- connection already has provider credentials, so connect/provision resyncs it
323
- without requesting Google consent again; an existing authorization or initial
324
- sync is awaited instead of creating a duplicate attempt.
325
- `calendar resync` asks the hosted connector to sync immediately; `calendar
326
- disconnect --yes` stops watches and deletes this app/environment connection's
327
- encrypted platform token and cursors without purging retained mirror rows,
328
- which may therefore remain readable but stale. It does not revoke the shared
329
- user-to-Google-OAuth-project grant, because that could invalidate other odla
330
- connections for the same user. A future explicitly global revoke command would
331
- need cross-connection accounting and warning. Production resync requires
332
- `--yes`, as does a direct production connect. None of these commands accepts a
333
- Google authorization code, client secret, access token, or refresh token.
338
+ repair revoked credentials); a healthy connection is reused. A connection that
339
+ already holds provider credentials is reused without requesting Google consent
340
+ again, and an in-flight authorization is awaited rather than duplicated.
341
+
342
+ Nothing syncs: Google Calendar is the source of truth and odla stores no events
343
+ and no attendees, so there is no resync to ask for. `calendar disconnect --yes`
344
+ deletes this app/environment connection's encrypted platform token. It does not
345
+ revoke the shared user-to-Google-OAuth-project grant, because that could
346
+ invalidate other odla connections for the same user. A future explicitly global
347
+ revoke command would need cross-connection accounting and warning. A direct
348
+ production connect requires `--yes`. None of these commands accepts a Google
349
+ authorization code, client secret, access token, or refresh token.
334
350
 
335
351
  The SDK uses the existing server-side db values (`ODLA_ENDPOINT`,
336
352
  `ODLA_TENANT`, `ODLA_API_KEY`), so calendar adds no Worker secret. Never expose
package/dist/bin.cjs CHANGED
@@ -6435,11 +6435,12 @@ Usage:
6435
6435
  odla-ai runbook ask "<question>" [--app <id>] [--all] [--json]
6436
6436
  odla-ai runbook search "<question>" [--app <id>] [--all] [--limit <n>] [--json]
6437
6437
  odla-ai runbook impact [--base origin/main] [--app <id>] [--all] [--limit <n>] [--json]
6438
+ odla-ai runbook lint [--app <id>] [--all] [--json]
6438
6439
  odla-ai runbook list [--app <id>] [--all] [--q <text>] [--json]
6439
6440
  odla-ai runbook comment <slug> --body "..." [--app <id>]
6440
6441
  odla-ai runbook get <slug> [--app <id>]
6441
- odla-ai runbook new <slug> --title <t> --file <path|-> [--summary <s>] [--app <id>]
6442
- odla-ai runbook edit <slug> [--file <path|->|--body "..."] [--note <why>] [--app <id>]
6442
+ odla-ai runbook new <slug> --title <t> --file <path|-> [--summary <s>] [--requires <specs>] [--app <id>]
6443
+ odla-ai runbook edit <slug> [--file <path|->|--body "..."] [--note <why>] [--requires <specs>] [--app <id>]
6443
6444
  odla-ai runbook import <dir> [--visibility operator|admin] [--dry-run] [--app <id>]
6444
6445
  odla-ai runbook publish <slug> [--app <id>]
6445
6446
  odla-ai runbook visibility <slug> <operator|admin> [--app <id>]
@@ -6481,7 +6482,10 @@ Commands:
6481
6482
  "search" the passages behind it; "get" the whole document.
6482
6483
  "impact" diffs your working tree against a base ref and names the
6483
6484
  runbooks that describe what you changed \u2014 run it after touching a
6484
- package's exported API or JSDoc, or a Studio surface. A wrong step
6485
+ package's exported API or JSDoc, or a Studio surface. "lint"
6486
+ checks the corpus the other way: every odla-ai command the
6487
+ runbooks name is held against this CLI's real command surface,
6488
+ and against the minimum versions each runbook declares. A wrong step
6485
6489
  is fixed with "edit", which takes effect immediately: a runbook is
6486
6490
  a row, not a release. Runbooks describe PROCEDURE; what an export
6487
6491
  does and what it guarantees is JSDoc, rendered per package at
@@ -7802,6 +7806,55 @@ var import_node_process9 = __toESM(require("process"), 1);
7802
7806
 
7803
7807
  // src/runbook-actions.ts
7804
7808
  var import_node_fs13 = require("fs");
7809
+
7810
+ // src/runbook-requires.ts
7811
+ var SPEC = /^(@?[\w./-]+?)@(\d+\.\d+\.\d+(?:[\w.-]*)?)$/;
7812
+ function parseRequires(value) {
7813
+ if (!value) return [];
7814
+ const out = [];
7815
+ for (const token of value.split(/[\s,]+/).filter(Boolean)) {
7816
+ const match = SPEC.exec(token);
7817
+ if (match?.[1] && match[2]) out.push({ name: match[1], min: match[2] });
7818
+ }
7819
+ return out;
7820
+ }
7821
+ function compareVersions(a, b) {
7822
+ const parts = (v) => {
7823
+ const [core = "", pre = ""] = v.split("-", 2);
7824
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
7825
+ };
7826
+ const left = parts(a);
7827
+ const right = parts(b);
7828
+ for (let i = 0; i < Math.max(left.nums.length, right.nums.length); i++) {
7829
+ const diff = (left.nums[i] ?? 0) - (right.nums[i] ?? 0);
7830
+ if (diff) return diff < 0 ? -1 : 1;
7831
+ }
7832
+ if (left.pre === right.pre) return 0;
7833
+ if (!left.pre) return 1;
7834
+ if (!right.pre) return -1;
7835
+ return left.pre < right.pre ? -1 : 1;
7836
+ }
7837
+ function satisfies(installed, min) {
7838
+ return compareVersions(installed, min) >= 0;
7839
+ }
7840
+ function unmetRequirements(requires, installedVersions) {
7841
+ const unmet = [];
7842
+ for (const requirement of parseRequires(requires)) {
7843
+ const installed = installedVersions[requirement.name];
7844
+ if (installed === void 0) {
7845
+ unmet.push({ ...requirement, installed: null });
7846
+ } else if (!satisfies(installed, requirement.min)) {
7847
+ unmet.push({ ...requirement, installed });
7848
+ }
7849
+ }
7850
+ return unmet;
7851
+ }
7852
+ function describeUnmet(slug, unmet) {
7853
+ const detail = unmet.map((u) => `${u.name}@${u.min}${u.installed ? ` (you have ${u.installed})` : " (not installed here)"}`).join(", ");
7854
+ return `note: runbook "${slug}" expects ${detail} \u2014 some steps may name commands your version does not have.`;
7855
+ }
7856
+
7857
+ // src/runbook-actions.ts
7805
7858
  var PLATFORM_SCOPE = "$platform";
7806
7859
  async function call(ctx, method, path, body) {
7807
7860
  const res = await ctx.doFetch(`${ctx.platformUrl.replace(/\/$/, "")}/registry/pm${path}`, {
@@ -7856,19 +7909,23 @@ async function runbookList(ctx, all, query) {
7856
7909
  async function runbookGet(ctx, slug) {
7857
7910
  const runbook = await bySlug(ctx, slug);
7858
7911
  if (ctx.json) return ctx.out.log(JSON.stringify(runbook, null, 2));
7912
+ const unmet = unmetRequirements(runbook.requires, { "@odla-ai/cli": cliVersion() });
7913
+ if (unmet.length) ctx.out.error(describeUnmet(slug, unmet));
7859
7914
  ctx.out.log(runbook.body);
7860
7915
  }
7861
- async function runbookNew(ctx, slug, title, body, summary) {
7916
+ async function runbookNew(ctx, slug, title, body, summary, requires) {
7862
7917
  const created = await call(ctx, "POST", "/runbook", {
7863
7918
  appId: ctx.appId,
7864
- input: { slug, title, body, ...summary ? { summary } : {} }
7919
+ input: { slug, title, body, ...summary ? { summary } : {}, ...requires ? { requires } : {} }
7865
7920
  });
7866
7921
  ctx.out.log(ctx.json ? JSON.stringify(created, null, 2) : `created ${slug} (${created.id})`);
7867
7922
  }
7868
- async function runbookEdit(ctx, slug, body, note) {
7923
+ async function runbookEdit(ctx, slug, body, note, requires) {
7869
7924
  const runbook = await bySlug(ctx, slug);
7870
7925
  const result = await call(ctx, "PATCH", `/runbook/${encodeURIComponent(runbook.id)}`, {
7871
- patch: { body, ...note ? { note } : {} }
7926
+ // An empty --requires clears the declaration; omitting the flag leaves
7927
+ // whatever is there, so an ordinary body edit never drops it.
7928
+ patch: { body, ...note ? { note } : {}, ...requires === void 0 ? {} : { requires: requires || null } }
7872
7929
  });
7873
7930
  if (ctx.json) return ctx.out.log(JSON.stringify(result, null, 2));
7874
7931
  ctx.out.log(`${slug} \u2192 v${result.record?.version ?? runbook.version + 1}`);
@@ -8275,6 +8332,170 @@ async function runbookImpact(ctx, options, deps = {}) {
8275
8332
  report2(ctx, impacts);
8276
8333
  }
8277
8334
 
8335
+ // src/surface.ts
8336
+ var PM_ACTIONS = {
8337
+ list: {},
8338
+ add: {},
8339
+ create: {},
8340
+ get: {},
8341
+ set: {},
8342
+ update: {},
8343
+ status: {},
8344
+ move: {},
8345
+ done: {},
8346
+ comment: {},
8347
+ comments: {},
8348
+ rm: {},
8349
+ delete: {}
8350
+ };
8351
+ var PM_ENTITIES = Object.fromEntries(
8352
+ ["goal", "conformance", "task", "kanban", "decision", "bug"].map((entity) => [entity, PM_ACTIONS])
8353
+ );
8354
+ var COMMAND_SURFACE = {
8355
+ admin: {
8356
+ ai: {
8357
+ show: {},
8358
+ set: {},
8359
+ credentials: {},
8360
+ models: {},
8361
+ usage: {},
8362
+ audit: {},
8363
+ credential: { set: {} }
8364
+ }
8365
+ },
8366
+ app: {
8367
+ archive: {},
8368
+ restore: {},
8369
+ export: {},
8370
+ import: {},
8371
+ rename: {},
8372
+ "refresh-sandbox": {},
8373
+ "go-live": {},
8374
+ promote: {},
8375
+ owners: { list: {}, add: {}, remove: {} }
8376
+ },
8377
+ calendar: { status: {}, calendars: {}, connect: {}, disconnect: {} },
8378
+ capabilities: {},
8379
+ code: { connect: {} },
8380
+ doctor: {},
8381
+ help: {},
8382
+ init: {},
8383
+ pm: PM_ENTITIES,
8384
+ provision: {},
8385
+ runbook: {
8386
+ ask: {},
8387
+ search: {},
8388
+ impact: {},
8389
+ list: {},
8390
+ get: {},
8391
+ cat: {},
8392
+ new: {},
8393
+ edit: {},
8394
+ comment: {},
8395
+ import: {},
8396
+ visibility: {},
8397
+ publish: {},
8398
+ archive: {},
8399
+ history: {},
8400
+ revert: {},
8401
+ rm: {},
8402
+ lint: {}
8403
+ },
8404
+ secrets: { push: {}, set: {}, "set-clerk-key": {} },
8405
+ security: {
8406
+ plan: {},
8407
+ sources: {},
8408
+ run: {},
8409
+ status: {},
8410
+ report: {},
8411
+ github: { connect: {}, disconnect: {} }
8412
+ },
8413
+ setup: {},
8414
+ skill: { install: {} },
8415
+ smoke: {},
8416
+ version: {},
8417
+ whoami: {}
8418
+ };
8419
+ function acceptedAfter(path) {
8420
+ let node = COMMAND_SURFACE;
8421
+ for (const word of path) {
8422
+ node = node?.[word];
8423
+ if (!node) return [];
8424
+ }
8425
+ return Object.keys(node).sort();
8426
+ }
8427
+ function validateInvocation(words2) {
8428
+ let node = COMMAND_SURFACE;
8429
+ const walked = [];
8430
+ for (const word of words2) {
8431
+ if (Object.keys(node).length === 0) return null;
8432
+ const next = node[word];
8433
+ if (!next) return { validPrefix: walked.join(" "), word, accepted: Object.keys(node).sort() };
8434
+ walked.push(word);
8435
+ node = next;
8436
+ }
8437
+ return null;
8438
+ }
8439
+ function describeProblem(problem) {
8440
+ const where = problem.validPrefix ? `after "${problem.validPrefix}"` : "as a command";
8441
+ return `"${problem.word}" is not accepted ${where} \u2014 try: ${problem.accepted.join(", ")}`;
8442
+ }
8443
+
8444
+ // src/runbook-lint.ts
8445
+ function invocationsIn(body) {
8446
+ const re = /(`|npx[^\S\n]+)?(?:@odla-ai\/cli(?:@[\w.-]+)?|odla-ai)((?:[^\S\n]+[a-z][\w-]*)+)/g;
8447
+ const found = [];
8448
+ for (const match of body.matchAll(re)) {
8449
+ if (!match[1]) continue;
8450
+ const words2 = match[2].trim().split(/[^\S\n]+/);
8451
+ if (words2.length) found.push(words2);
8452
+ }
8453
+ return found;
8454
+ }
8455
+ function lintRunbook(runbook, installed) {
8456
+ const findings = [];
8457
+ const at = { slug: runbook.slug, appId: runbook.appId };
8458
+ const declared = parseRequires(runbook.requires);
8459
+ const seen = /* @__PURE__ */ new Set();
8460
+ for (const words2 of invocationsIn(runbook.body)) {
8461
+ const problem = validateInvocation(words2);
8462
+ if (!problem) continue;
8463
+ const key = `${problem.validPrefix}|${problem.word}`;
8464
+ if (seen.has(key)) continue;
8465
+ seen.add(key);
8466
+ findings.push({
8467
+ ...at,
8468
+ kind: declared.length ? "command" : "undeclared",
8469
+ detail: declared.length ? `\`odla-ai ${words2.join(" ")}\`: ${describeProblem(problem)}` : `\`odla-ai ${words2.join(" ")}\`: ${describeProblem(problem)}. If a newer package added it, declare that with requires.`
8470
+ });
8471
+ }
8472
+ const unmet = unmetRequirements(runbook.requires, installed);
8473
+ if (unmet.length) findings.push({ ...at, kind: "requires", detail: describeUnmet(runbook.slug, unmet) });
8474
+ return findings;
8475
+ }
8476
+ async function runbookLint(ctx, all) {
8477
+ const params = new URLSearchParams();
8478
+ if (!all) params.set("app", ctx.appId);
8479
+ const page = await call(ctx, "GET", `/runbook${params.size ? `?${params}` : ""}`);
8480
+ const installed = { "@odla-ai/cli": cliVersion() };
8481
+ const findings = page.records.flatMap((runbook) => lintRunbook(runbook, installed));
8482
+ if (ctx.json) return ctx.out.log(JSON.stringify({ checked: page.records.length, findings }, null, 2));
8483
+ if (!page.records.length) return ctx.out.log("(no runbooks in scope)");
8484
+ if (!findings.length) {
8485
+ return ctx.out.log(
8486
+ `${page.records.length} runbook${page.records.length === 1 ? "" : "s"} checked; every command they name is real for @odla-ai/cli ${cliVersion()}.`
8487
+ );
8488
+ }
8489
+ ctx.out.log(`${findings.length} finding${findings.length === 1 ? "" : "s"} across ${page.records.length} runbooks:`);
8490
+ for (const finding of findings) {
8491
+ const scope = finding.appId === PLATFORM_SCOPE ? "" : ` --app ${finding.appId}`;
8492
+ ctx.out.log("");
8493
+ ctx.out.log(`${finding.slug} [${finding.kind}]`);
8494
+ ctx.out.log(` ${finding.detail}`);
8495
+ ctx.out.log(` odla-ai runbook get ${finding.slug}${scope}`);
8496
+ }
8497
+ }
8498
+
8278
8499
  // src/runbook-search-command.ts
8279
8500
  async function runbookSearch(ctx, query, all, limit) {
8280
8501
  const params = new URLSearchParams({ q: query });
@@ -8467,14 +8688,15 @@ var ALLOWED2 = [
8467
8688
  "visibility",
8468
8689
  "dry-run",
8469
8690
  "limit",
8470
- "base"
8691
+ "base",
8692
+ "requires"
8471
8693
  ];
8472
8694
  function requireSlug(slug, action) {
8473
8695
  if (!slug) throw new Error(`"runbook ${action}" needs a slug, e.g. "odla-ai runbook ${action} release"`);
8474
8696
  return slug;
8475
8697
  }
8476
8698
  var WRITES = /* @__PURE__ */ new Set(["new", "edit", "publish", "archive", "visibility", "revert", "rm", "import"]);
8477
- var CONFIG_FREE = /* @__PURE__ */ new Set(["list", "search", "ask", "get", "cat", "history", "impact"]);
8699
+ var CONFIG_FREE = /* @__PURE__ */ new Set(["list", "search", "ask", "get", "cat", "history", "impact", "lint"]);
8478
8700
  async function loadOrDefaultConfig(configPath, action, appId) {
8479
8701
  const projectScoped = appId !== void 0;
8480
8702
  if (!projectScoped && CONFIG_FREE.has(action) && !(0, import_node_fs17.existsSync)((0, import_node_path14.resolve)(configPath))) {
@@ -8556,6 +8778,8 @@ async function runbookCommand(parsed, deps = {}) {
8556
8778
  if (!question) throw new Error('"runbook ask" needs a question, e.g. odla-ai runbook ask "how do I roll back a publish?"');
8557
8779
  return runbookAsk(ctx, question, parsed.options.all === true);
8558
8780
  }
8781
+ case "lint":
8782
+ return runbookLint(ctx, parsed.options.all === true);
8559
8783
  case "impact":
8560
8784
  return runbookImpact(
8561
8785
  ctx,
@@ -8586,7 +8810,8 @@ async function runbookCommand(parsed, deps = {}) {
8586
8810
  name,
8587
8811
  stringOpt(parsed.options.title) ?? name,
8588
8812
  readBody(stringOpt(parsed.options.file), stringOpt(parsed.options.body)),
8589
- stringOpt(parsed.options.summary)
8813
+ stringOpt(parsed.options.summary),
8814
+ stringOpt(parsed.options.requires)
8590
8815
  );
8591
8816
  }
8592
8817
  case "edit": {
@@ -8595,7 +8820,13 @@ async function runbookCommand(parsed, deps = {}) {
8595
8820
  const inline = stringOpt(parsed.options.body);
8596
8821
  const body = file === void 0 && inline === void 0 ? await editRunbook(ctx, name) : readBody(file, inline);
8597
8822
  if (body === null) return ctx.out.log(`${name} unchanged; nothing written`);
8598
- return runbookEdit(ctx, name, body, stringOpt(parsed.options.note));
8823
+ return runbookEdit(
8824
+ ctx,
8825
+ name,
8826
+ body,
8827
+ stringOpt(parsed.options.note),
8828
+ parsed.options.requires === void 0 ? void 0 : stringOpt(parsed.options.requires) ?? ""
8829
+ );
8599
8830
  }
8600
8831
  case "import": {
8601
8832
  const dir = parsed.positionals[2];
@@ -8624,9 +8855,7 @@ async function runbookCommand(parsed, deps = {}) {
8624
8855
  case "rm":
8625
8856
  return runbookRemove(ctx, requireSlug(slug, "rm"));
8626
8857
  default:
8627
- throw new Error(
8628
- `unknown runbook action "${action}". Try ask, search, impact, list, get, new, edit, comment, import, visibility, publish, archive, history, revert, rm.`
8629
- );
8858
+ throw new Error(`unknown runbook action "${action}". Try ${acceptedAfter(["runbook"]).join(", ")}.`);
8630
8859
  }
8631
8860
  }
8632
8861