@revturbine/cli 0.17.2 → 0.18.1

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 +32 -2
  2. package/dist/cli.js +327 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,10 +63,10 @@ Commands that read a config name the version explicitly — there is no default:
63
63
 
64
64
  | Command | What it does |
65
65
  |---|---|
66
- | `init` (alias `create`) | Scaffold RevTurbine into this app: detect the package manager and stack, install the SDK, pin the CLI exactly, drop a starter Playbook, and install the Agent Skills. In a directory with no `package.json` it offers to start a new project (`--yes` to skip the prompt); `--dir`, `--dry-run`, `--no-skills`, `--json`. Runs the invoked CLI even inside a repo that pins a different one — setup establishes the pin, so it never delegates. |
66
+ | `init` (alias `create`) | Set up RevTurbine while preserving existing integrations: install missing packages, pin the CLI exactly, create a starter for a fresh integration, and install skills for one agent. Use `--scaffold` to create a missing root starter explicitly; existing files are never overwritten. `--agent claude-code\|cursor\|codex` overrides environment detection; an unknown agent skips skills and prints the selection command. Also supports `--dir`, `--dry-run`, `--no-skills`, `--json`. With no `package.json`, offers a new project (`--yes` for noninteractive creation). Runs the invoked CLI even inside a repo that pins a different one. |
67
67
  | `signup` | Create an account headlessly: email + password, then an emailed one-time code to verify, then a token is stored. |
68
68
  | `login` / `logout` | Device-flow auth; tokens stored at `~/.revturbine/credentials.json` (mode 0600). |
69
- | `whoami` | The resolved instance, tenant, credentials source, and whether the stored token works. |
69
+ | `whoami` | Authentication state first: not logged in, credentials accepted, credentials rejected for the selected instance/tenant, or verification unavailable. Then shows instance, tenant, and credentials source; configured tenant information alone does not prove login. Verification and subsequent telemetry each time out after 3 seconds. Status reports exit 0. `--json` retains its seven fields: `instance`, `tenant`, `tenant_source`, `credentials_dir`, `credentials_source`, `token_present` (boolean), and `token_valid` (`true` for 2xx, `false` for 401/403, `null` when absent or unverified, including redirects, other HTTP errors, timeouts and network failures). |
70
70
  | `schema` | Emit the bundled `RevTurbineConfig` JSON schema (for agents to author against). |
71
71
  | `docs` | Print the canonical documentation URL. |
72
72
  | `download` | Fetch a config version (`--live` / `--draft` / `--release <id>`); `--save`. |
@@ -84,10 +84,40 @@ Commands that read a config name the version explicitly — there is no default:
84
84
  | `generate types` | Generate a TypeScript module of typed Playbook handles from any config version — `Entitlements` (namespaced by type, with the `EntitlementHandle` union for type-safe `can()`/`gate()`/`checkEntitlement()` call sites), plus `Plans`, `Segments`, `SurfaceTemplates`, and `UiPathActionTypes`. Const objects + literal-union types (erasable — no enums). `--out <path>` writes the file; the generated header records the exact command to regenerate it. `--json` for the raw handle map. |
85
85
  | `analytics catalog\|templates\|views\|view\|create\|preview\|query` | Work with the hosted Semantic Catalog and canonical analytics-view documents. Create and preview pass the document through unchanged to the same server contract used by the web editor and MCP tools; preview remains subject to the server's query limits. |
86
86
  | `ingest-keys create` (alias `mint`) | Mint a tenant-bound public ingest token for browser SDK use. Requires one or more `--origin` values; optional `--ip` restrictions. The full token is returned once. `ingest-keys list` shows ids/previews and `ingest-keys revoke <id>` invalidates one. |
87
+ | `events track` | Send analytics events to the ingest pipeline with your own login — no ingest key and no browser origin required. Takes `--file <path>` (JSON array, `{ "events": [...] }` envelope, or NDJSON) or `--event <json>`. Batches over 500 events are chunked automatically. Reports accepted, quarantined, rejected and dropped counts separately. |
88
+
89
+ `init` preserves declared SDK/CLI versions and application code. It skips a new root
90
+ Playbook when the SDK is already declared in `dependencies` or `devDependencies`,
91
+ or project source imports the SDK, or a non-root canonical Playbook is found.
92
+ Detection ignores hidden directories, symlinks, dependencies and build outputs.
93
+ If the integration is intentionally incomplete, `--scaffold` creates only the missing
94
+ `revturbine.playbook.json`; it never replaces an existing one. `--dry-run --json`
95
+ reports `playbook: "present"`, `"skipped"`, or the filename to create, and
96
+ `skills_agent` identifies the single installer target (or `null` when skipped).
97
+ `--no-skills` takes precedence over a detected or explicitly selected valid agent.
87
98
 
88
99
  `--json` on read commands emits machine-readable output. Results go to stdout,
89
100
  diagnostics to stderr.
90
101
 
102
+ ### Send analytics events
103
+
104
+ ```bash
105
+ revturbine login
106
+ revturbine events track --file ./events.ndjson
107
+
108
+ # or a single event inline
109
+ revturbine events track --event '{"environment_id":"production","user_id":"u-1","account_id":"a-1","event_name":"product_used","event_ts":"2026-09-10T00:00:00.000Z"}'
110
+ ```
111
+
112
+ Every event needs `environment_id`, `user_id`, `account_id`, `event_name` and
113
+ `event_ts`; anything else you include is carried through. Local validation
114
+ reports every malformed event at once and sends nothing until they are fixed.
115
+
116
+ This authenticates with your device-auth token rather than an ingest key, so
117
+ it works from a terminal or CI where there is no browser origin to allowlist.
118
+ Quarantined and rejected counts are reported separately from accepted — a
119
+ load is not clean merely because the server returned `202`.
120
+
91
121
  ### Mint a publishable ingest token
92
122
 
93
123
  ```bash
package/dist/cli.js CHANGED
@@ -10705,7 +10705,7 @@ var CLI_UNTRACKED_COMMANDS = /* @__PURE__ */ new Set(["login", "signup", "logout
10705
10705
  function shouldTrackCommandExecution(name, hasUrl) {
10706
10706
  return hasUrl && !CLI_UNTRACKED_COMMANDS.has(name);
10707
10707
  }
10708
- async function trackEvent(rawUrl, explicitTenantId, eventType, payload) {
10708
+ async function trackEvent(rawUrl, explicitTenantId, eventType, payload, signal) {
10709
10709
  try {
10710
10710
  const url = normalizeBaseUrl(rawUrl);
10711
10711
  const cred = getCredential(url);
@@ -10717,7 +10717,8 @@ async function trackEvent(rawUrl, explicitTenantId, eventType, payload) {
10717
10717
  "x-tenant-id": explicitTenantId ?? cred.tenant_id ?? "dev-tenant-001",
10718
10718
  Authorization: `Bearer ${cred.token}`
10719
10719
  },
10720
- body: JSON.stringify({ event_type: eventType, payload })
10720
+ body: JSON.stringify({ event_type: eventType, payload }),
10721
+ ...signal ? { signal } : {}
10721
10722
  });
10722
10723
  } catch {
10723
10724
  }
@@ -10886,6 +10887,20 @@ async function resolveActiveDraft(baseUrl, headers, fetchImpl = fetch) {
10886
10887
  return { ok: res.ok, status: res.status, draft: res.ok ? json.active ?? null : null };
10887
10888
  }
10888
10889
 
10890
+ // src/lib/whoami.ts
10891
+ function classifyToken(hasToken, status) {
10892
+ if (!hasToken || status === null) return null;
10893
+ if (status >= 200 && status < 300) return true;
10894
+ if (status === 401 || status === 403) return false;
10895
+ return null;
10896
+ }
10897
+ function formatAuthentication(hasToken, tokenValid) {
10898
+ if (!hasToken) return "authentication: not logged in \u2014 run `revturbine login`";
10899
+ if (tokenValid === true) return "authentication: credentials accepted for this instance and tenant";
10900
+ if (tokenValid === false) return "authentication: credentials rejected for this instance or tenant \u2014 check access or run `revturbine login`";
10901
+ return "authentication: verification unavailable \u2014 credentials present; retry when the instance is reachable";
10902
+ }
10903
+
10889
10904
  // src/lib/evaluate-local.ts
10890
10905
  import { initRevTurbine } from "@revturbine/sdk/headless";
10891
10906
  function resolvePlacementComponentType(input) {
@@ -10981,6 +10996,130 @@ function formatIngestKeyLine(key2) {
10981
10996
  return `${key2.id} ${key2.tokenPreview} origins: ${origins}${ips}${last}`;
10982
10997
  }
10983
10998
 
10999
+ // src/lib/events-ingest.ts
11000
+ var MAX_EVENTS_PER_BATCH = 500;
11001
+ var REQUIRED_FIELDS = [
11002
+ "environment_id",
11003
+ "user_id",
11004
+ "account_id",
11005
+ "event_name",
11006
+ "event_ts"
11007
+ ];
11008
+ function parseEventBatch(text) {
11009
+ const trimmed = text.trim();
11010
+ if (trimmed === "") return { events: [], errors: ["input is empty"] };
11011
+ const errors = [];
11012
+ let raw;
11013
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
11014
+ let parsed;
11015
+ try {
11016
+ parsed = JSON.parse(trimmed);
11017
+ } catch {
11018
+ return parseNdjson(trimmed);
11019
+ }
11020
+ if (Array.isArray(parsed)) {
11021
+ raw = parsed;
11022
+ } else if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.events)) {
11023
+ raw = parsed.events;
11024
+ } else if (parsed !== null && typeof parsed === "object") {
11025
+ raw = [parsed];
11026
+ } else {
11027
+ return { events: [], errors: ["input must be an object, an array, or an { events: [...] } envelope"] };
11028
+ }
11029
+ } else {
11030
+ return parseNdjson(trimmed);
11031
+ }
11032
+ const events2 = validateAll(raw, errors);
11033
+ return { events: events2, errors };
11034
+ }
11035
+ function parseNdjson(text) {
11036
+ const errors = [];
11037
+ const raw = [];
11038
+ text.split("\n").forEach((line, i) => {
11039
+ const t = line.trim();
11040
+ if (t === "") return;
11041
+ try {
11042
+ raw.push(JSON.parse(t));
11043
+ } catch {
11044
+ errors.push(`line ${i + 1}: not valid JSON`);
11045
+ }
11046
+ });
11047
+ const events2 = validateAll(raw, errors);
11048
+ return { events: events2, errors };
11049
+ }
11050
+ function validateAll(raw, errors) {
11051
+ const events2 = [];
11052
+ raw.forEach((item, i) => {
11053
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
11054
+ errors.push(`event ${i}: not a JSON object`);
11055
+ return;
11056
+ }
11057
+ const bag = item;
11058
+ const missing = REQUIRED_FIELDS.filter(
11059
+ (f) => typeof bag[f] !== "string" || bag[f].trim() === ""
11060
+ );
11061
+ if (missing.length > 0) {
11062
+ errors.push(`event ${i}: missing or empty ${missing.join(", ")}`);
11063
+ return;
11064
+ }
11065
+ events2.push(bag);
11066
+ });
11067
+ return events2;
11068
+ }
11069
+ function chunkEvents(events2, size = MAX_EVENTS_PER_BATCH) {
11070
+ const limit = Math.max(1, Math.min(size, MAX_EVENTS_PER_BATCH));
11071
+ const out = [];
11072
+ for (let i = 0; i < events2.length; i += limit) out.push(events2.slice(i, i + limit));
11073
+ return out;
11074
+ }
11075
+ function readResult(json) {
11076
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
11077
+ return {
11078
+ accepted: num(json.accepted),
11079
+ quarantined: num(json.quarantined),
11080
+ dropped_unscoped: num(json.dropped_unscoped),
11081
+ rejected: Array.isArray(json.rejected) ? json.rejected : [],
11082
+ request_id: typeof json.request_id === "string" ? json.request_id : null
11083
+ };
11084
+ }
11085
+ async function postBatch(baseUrl, headers, events2, fetchImpl = fetch) {
11086
+ const res = await fetchImpl(`${baseUrl}/api/track`, {
11087
+ method: "POST",
11088
+ headers,
11089
+ body: JSON.stringify({ events: events2 })
11090
+ });
11091
+ const json = await res.json().catch(() => ({}));
11092
+ if (!res.ok) return { ok: false, status: res.status, result: null, error: json };
11093
+ return { ok: true, status: res.status, result: readResult(json), error: {} };
11094
+ }
11095
+ function summarize(results, eventCount) {
11096
+ return {
11097
+ batches: results.length,
11098
+ // @revturbine-graph gref:3f4d51af0a0af98edea0
11099
+ events: eventCount,
11100
+ accepted: results.reduce((n, r) => n + r.accepted, 0),
11101
+ quarantined: results.reduce((n, r) => n + r.quarantined, 0),
11102
+ dropped_unscoped: results.reduce((n, r) => n + r.dropped_unscoped, 0),
11103
+ rejected: results.reduce((n, r) => n + r.rejected.length, 0),
11104
+ results
11105
+ };
11106
+ }
11107
+ function formatSummary(summary) {
11108
+ const lines = [
11109
+ `ingested ${summary.accepted}/${summary.events} event(s) in ${summary.batches} batch(es)`
11110
+ ];
11111
+ if (summary.quarantined > 0) {
11112
+ lines.push(` quarantined: ${summary.quarantined} (payload failed its platform contract)`);
11113
+ }
11114
+ if (summary.rejected > 0) {
11115
+ lines.push(` rejected: ${summary.rejected} (malformed on the wire)`);
11116
+ }
11117
+ if (summary.dropped_unscoped > 0) {
11118
+ lines.push(` dropped (unscoped): ${summary.dropped_unscoped} (simulation rows without a dataset id)`);
11119
+ }
11120
+ return lines.join("\n");
11121
+ }
11122
+
10984
11123
  // src/lib/delegate.ts
10985
11124
  var DELEGATION_ENV = "REVTURBINE_DELEGATED";
10986
11125
  var NO_LOCAL_FLAG = "--no-local";
@@ -11116,6 +11255,29 @@ function detectStack(signals) {
11116
11255
  }
11117
11256
  var SDK_PACKAGE = "@revturbine/sdk";
11118
11257
  var CLI_PACKAGE = "@revturbine/cli";
11258
+ function declaredSdk(manifest) {
11259
+ return Boolean(manifest.dependencies?.[SDK_PACKAGE] || manifest.devDependencies?.[SDK_PACKAGE]);
11260
+ }
11261
+ function integrationFileReason(file, content) {
11262
+ if (file.endsWith("revturbine.playbook.json")) return `existing Playbook (${file})`;
11263
+ if (/\.[cm]?[jt]sx?$/.test(file) && /['"]@revturbine\/sdk(?:\/[^'"]*)?['"]/.test(content)) {
11264
+ return `SDK integration (${file})`;
11265
+ }
11266
+ if (file.endsWith(".json")) {
11267
+ try {
11268
+ const value = JSON.parse(content);
11269
+ if (value && typeof value === "object" && "artifact_type" in value && value.artifact_type === "playbook") {
11270
+ return `existing Playbook (${file})`;
11271
+ }
11272
+ } catch {
11273
+ }
11274
+ }
11275
+ return void 0;
11276
+ }
11277
+ function planStarter(params) {
11278
+ if (params.rootExists) return "present";
11279
+ return params.integrationReason && !params.scaffold ? "skipped" : "create";
11280
+ }
11119
11281
  function projectNameFromDir(dirName) {
11120
11282
  const slug = dirName.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-_.]+|[-_.]+$/g, "");
11121
11283
  return slug || "my-app";
@@ -11208,20 +11370,34 @@ function validatePlaybook(config) {
11208
11370
  // src/lib/init-skills.ts
11209
11371
  var SKILLS_SOURCE = "revt-eng/revturbine-skills";
11210
11372
  var START_HERE_SKILL = "revturbine-start-here";
11211
- function detectHarness(env) {
11212
- if (env["CLAUDECODE"] || env["CLAUDE_CODE_SESSION_ID"] || env["CLAUDE_CODE_ENTRYPOINT"]) {
11213
- return { label: "Claude Code", invocation: `Run the skill: /${START_HERE_SKILL}` };
11373
+ var SUPPORTED_AGENTS = ["claude-code", "cursor", "codex"];
11374
+ function isAgentId(value) {
11375
+ return SUPPORTED_AGENTS.some((id) => id === value);
11376
+ }
11377
+ function detectHarness(env, explicit) {
11378
+ let agentId = explicit;
11379
+ if (!agentId) {
11380
+ if (env["CLAUDECODE"] || env["CLAUDE_CODE_SESSION_ID"] || env["CLAUDE_CODE_ENTRYPOINT"]) agentId = "claude-code";
11381
+ else if (env["CURSOR_TRACE_ID"] || env["CURSOR"]) agentId = "cursor";
11382
+ else if (env["CODEX_THREAD_ID"]) agentId = "codex";
11383
+ }
11384
+ if (agentId === "claude-code") {
11385
+ return { agentId, label: "Claude Code", invocation: `Run the skill: /${START_HERE_SKILL}` };
11386
+ }
11387
+ if (agentId === "cursor") {
11388
+ return { agentId, label: "Cursor", invocation: `Mention the skill: @${START_HERE_SKILL}` };
11214
11389
  }
11215
- if (env["CURSOR_TRACE_ID"] || env["CURSOR"]) {
11216
- return { label: "Cursor", invocation: `Mention the skill: @${START_HERE_SKILL}` };
11390
+ if (agentId === "codex") {
11391
+ return { agentId, label: "Codex", invocation: `Run the skill: $${START_HERE_SKILL}` };
11217
11392
  }
11218
11393
  return {
11394
+ agentId: null,
11219
11395
  label: null,
11220
11396
  invocation: `Ask your coding agent to run the "${START_HERE_SKILL}" skill.`
11221
11397
  };
11222
11398
  }
11223
- function skillsAddArgs(source = SKILLS_SOURCE) {
11224
- return ["--yes", "skills", "add", source, "-y", "--copy"];
11399
+ function skillsAddArgs(agent) {
11400
+ return ["--yes", "skills", "add", SKILLS_SOURCE, "-y", "--copy", "-a", agent];
11225
11401
  }
11226
11402
  function finalOutputLines(params) {
11227
11403
  const lines = ["", "RevTurbine is set up.", ""];
@@ -11235,12 +11411,14 @@ function finalOutputLines(params) {
11235
11411
  lines.push(" \u2713 Installed the Agent Skills");
11236
11412
  } else if (params.skills === "skipped") {
11237
11413
  lines.push(" \u2022 Skipped the Agent Skills (--no-skills)");
11414
+ } else if (params.skills === "unknown") {
11415
+ lines.push(" \u2022 Agent Skills not installed \u2014 select a target with --agent");
11238
11416
  } else {
11239
11417
  lines.push(" \u26A0 Agent Skills not installed \u2014 see above to add them by hand");
11240
11418
  }
11241
11419
  lines.push("", "Next step:");
11242
11420
  lines.push(` ${params.harness.invocation}`);
11243
- if (params.harness.label) lines.push(` (detected ${params.harness.label})`);
11421
+ if (params.harness.label) lines.push(` (${params.harness.label})`);
11244
11422
  lines.push("", "Your setup path:");
11245
11423
  lines.push(" create playbook \u2192 app wiring \u2192 billing \u2192 verify \u2192 launch");
11246
11424
  lines.push("", "Docs: https://revturbine.com/docs", "");
@@ -11618,6 +11796,7 @@ function queryAnalyticsView(baseUrl, headers, body, fetchImpl) {
11618
11796
  // src/cli.ts
11619
11797
  var DOCS_URL = "https://github.com/revt-eng/revturbine-cli#readme";
11620
11798
  var DEFAULT_URL = "https://revturbine.com/app";
11799
+ var WHOAMI_REQUEST_TIMEOUT_MS = 3e3;
11621
11800
  function loadConfig(file) {
11622
11801
  const resolved = path2.resolve(file);
11623
11802
  if (!existsSync2(resolved)) fail(EXIT.USAGE, `Config not found: ${resolved}`);
@@ -11653,15 +11832,15 @@ function verifyConfig(file) {
11653
11832
  if (check.advisories.length > 0) diagRaw(formatFindings(check.advisories));
11654
11833
  return check.body;
11655
11834
  }
11656
- function connect(rawUrl, explicitTenantId) {
11835
+ function connect(rawUrl, explicitTenantId, diagnostic = diag) {
11657
11836
  const url = normalizeBaseUrl(rawUrl);
11658
11837
  const { dir, source } = resolveConfigDir();
11659
11838
  const cred = getCredential(url);
11660
11839
  const tenantId = explicitTenantId ?? cred?.tenant_id ?? "dev-tenant-001";
11661
11840
  const tenantSource = explicitTenantId ? "--tenant-id" : cred?.tenant_id ? "stored token" : "default";
11662
- diag(`Tenant ${tenantId} (${tenantSource}); credentials: ${dir} [${source}].`);
11841
+ diagnostic(`Tenant ${tenantId} (${tenantSource}); credentials: ${dir} [${source}].`);
11663
11842
  if (source === "global") {
11664
- diag(
11843
+ diagnostic(
11665
11844
  `WARNING: using the global ${dir} - NOT worktree-scoped. If this session is for a specific customer, run from that customer's worktree (with its own .revturbine/) so a stale login can't target the wrong tenant.`
11666
11845
  );
11667
11846
  }
@@ -11879,6 +12058,7 @@ Command groups:
11879
12058
  Codegen generate types
11880
12059
  Analytics analytics catalog|templates|views|view|create|preview|query
11881
12060
  Keys ingest-keys create (alias: mint)|list|revoke
12061
+ Events events track
11882
12062
 
11883
12063
  Version selectors (no defaults \u2014 a command that reads a config requires one):
11884
12064
  <file> a local Playbook file (positional, or --file <path>)
@@ -11889,6 +12069,10 @@ Version selectors (no defaults \u2014 a command that reads a config requires one
11889
12069
  Common workflows:
11890
12070
  # Add RevTurbine to an app (same routine as \`npm create revturbine@latest\`)
11891
12071
  revturbine init
12072
+ # Preserve an existing integration; select one skills target explicitly
12073
+ revturbine init --agent codex
12074
+ # Opt into a missing root starter; existing Playbooks are never overwritten
12075
+ revturbine init --scaffold --no-skills
11892
12076
 
11893
12077
  # Author, validate, and ship against the default instance (revturbine.com/app)
11894
12078
  revturbine login
@@ -11910,6 +12094,9 @@ Common workflows:
11910
12094
 
11911
12095
  # Mint an origin-restricted browser token (shown once), then revoke it
11912
12096
  revturbine ingest-keys mint --origin https://app.example.com --json
12097
+
12098
+ # Send analytics events with your own login (no ingest key, no browser origin)
12099
+ revturbine events track --file ./events.ndjson
11913
12100
  revturbine ingest-keys list
11914
12101
  revturbine ingest-keys revoke <ingest-key-id> --yes
11915
12102
 
@@ -11921,6 +12108,10 @@ Auth:
11921
12108
  ~/.revturbine/credentials.json (0600); the token's tenant is used by default,
11922
12109
  override with -t/--tenant-id. Mutating commands (discard, restore) prompt
11923
12110
  for confirmation unless --yes.
12111
+ \`whoami\` leads with absent, accepted, rejected, or unverifiable credentials;
12112
+ a configured tenant alone does not prove login. Verification and subsequent
12113
+ telemetry each time out after 3 seconds. Status reports exit 0; --json keeps
12114
+ token_present boolean and token_valid true/false/null (null = unverified).
11924
12115
 
11925
12116
  Full reference: ${DOCS_URL}
11926
12117
  `;
@@ -11931,7 +12122,7 @@ program.hook("postAction", async (_thisCommand, actionCommand) => {
11931
12122
  if (!shouldTrackCommandExecution(actionCommand.name(), Boolean(opts.url))) return;
11932
12123
  await trackEvent(opts.url, opts.tenantId, "cli_command_executed", {
11933
12124
  command: actionCommand.name()
11934
- });
12125
+ }, actionCommand.name() === "whoami" ? globalThis.AbortSignal.timeout(WHOAMI_REQUEST_TIMEOUT_MS) : void 0);
11935
12126
  });
11936
12127
  program.name("revturbine").description("Validate RevTurbine Playbooks and ship them through the playbook-version lifecycle (draft \u2192 Release).").version(`${pkgVersion} (schema ${SCHEMA_VERSION})`, "-V, --version", "Print the revturbine and bundled schema versions").option(NO_LOCAL_FLAG, "Ignore the repo-pinned CLI and run this installation").showHelpAfterError().addHelpText("after", HELP_AFTER);
11937
12128
  function runInstall(manager, args, cwd) {
@@ -11941,7 +12132,26 @@ function runInstall(manager, args, cwd) {
11941
12132
  child.on("close", (code) => resolve(code ?? 1));
11942
12133
  });
11943
12134
  }
11944
- program.command("init").alias("create").description("Scaffold RevTurbine into this app: detect the stack, install the SDK, pin the CLI, drop a starter Playbook, and install the Agent Skills. Offers to start a new project when the directory has no package.json.").option("-d, --dir <path>", "Target directory (defaults to the current directory)").option("-y, --yes", "Skip prompts \u2014 create a new project non-interactively when the directory has none").option("--dry-run", "Report what would be installed without running the package manager").option("--no-skills", "Do not install the RevTurbine Agent Skills").option("--json", "Emit the scaffold plan as JSON").action(async (opts) => {
12135
+ function findIntegration(dir, root = dir) {
12136
+ if (!existsSync2(dir)) return void 0;
12137
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
12138
+ if (entry.name.startsWith(".") || ["node_modules", "dist", "build", "coverage", "target"].includes(entry.name)) continue;
12139
+ const file = path2.join(dir, entry.name);
12140
+ if (entry.isDirectory()) {
12141
+ const reason = findIntegration(file, root);
12142
+ if (reason) return reason;
12143
+ } else if (entry.isFile() && /\.(?:[cm]?[jt]sx?|json)$/.test(entry.name) && !entry.name.endsWith("lock.json")) {
12144
+ const reason = integrationFileReason(path2.relative(root, file), readFileSync2(file, "utf8"));
12145
+ if (reason) return reason;
12146
+ }
12147
+ }
12148
+ return void 0;
12149
+ }
12150
+ program.command("init").alias("create").description("Set up RevTurbine while preserving existing integrations: install missing packages, create a starter for a fresh integration, and install skills for one selected agent. Offers to start a new project when no package.json exists.").option("-d, --dir <path>", "Target directory (defaults to the current directory)").option("-y, --yes", "Skip prompts \u2014 create a new project non-interactively when the directory has none").option("--dry-run", "Report what would be installed without running the package manager").option("--no-skills", "Do not install the RevTurbine Agent Skills").option("--scaffold", "Create a missing root starter Playbook even when an integration exists; never overwrite a file").option("--agent <id>", `Install skills for one agent (${SUPPORTED_AGENTS.join(", ")}); overrides environment detection`).option("--json", "Emit the scaffold plan as JSON").action(async (opts) => {
12151
+ if (opts.agent !== void 0 && !isAgentId(opts.agent)) {
12152
+ fail(EXIT.USAGE, `unknown skills agent '${opts.agent}' \u2014 choose ${SUPPORTED_AGENTS.join(", ")}.`);
12153
+ }
12154
+ const harness = detectHarness(process.env, opts.agent !== void 0 && isAgentId(opts.agent) ? opts.agent : void 0);
11945
12155
  const dir = path2.resolve(opts.dir ?? process.cwd());
11946
12156
  const manifestPath = path2.join(dir, "package.json");
11947
12157
  let manifest;
@@ -11979,8 +12189,14 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
11979
12189
  for (const note of plan.skipped) diag(`\u2022 ${note}`);
11980
12190
  const playbookPath = path2.join(dir, STARTER_PLAYBOOK_FILENAME);
11981
12191
  const playbookExists = existsSync2(playbookPath);
11982
- if (playbookExists) diag(`\u2022 ${STARTER_PLAYBOOK_FILENAME} already present \u2014 left as-is`);
11983
- const installSkills = opts.skills !== false;
12192
+ const integrationReason = playbookExists || opts.scaffold ? void 0 : declaredSdk(manifest) ? "@revturbine/sdk already declared" : findIntegration(dir);
12193
+ const starter = planStarter({ rootExists: playbookExists, integrationReason, scaffold: opts.scaffold === true });
12194
+ if (starter === "present") diag(`\u2022 ${STARTER_PLAYBOOK_FILENAME} already present \u2014 left as-is`);
12195
+ if (starter === "skipped") diag(`\u2022 Skipping starter Playbook: ${integrationReason}. Use --scaffold to create a missing ${STARTER_PLAYBOOK_FILENAME}.`);
12196
+ const skillsArgs = opts.skills !== false && harness.agentId ? skillsAddArgs(harness.agentId) : null;
12197
+ if (opts.skills !== false && !harness.agentId) {
12198
+ diag(`\u2022 Skipping Agent Skills: no supported agent detected. Choose a target, for example: revturbine init --agent codex (${SUPPORTED_AGENTS.join(", ")}).`);
12199
+ }
11984
12200
  if (opts.json) {
11985
12201
  emit(
11986
12202
  {
@@ -11990,8 +12206,9 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
11990
12206
  stack,
11991
12207
  install: plan.install,
11992
12208
  skipped: plan.skipped,
11993
- playbook: playbookExists ? "present" : STARTER_PLAYBOOK_FILENAME,
11994
- skills: installSkills ? SKILLS_SOURCE : "skipped"
12209
+ playbook: starter === "create" ? STARTER_PLAYBOOK_FILENAME : starter,
12210
+ skills: skillsArgs ? SKILLS_SOURCE : "skipped",
12211
+ skills_agent: skillsArgs ? harness.agentId : null
11995
12212
  },
11996
12213
  true
11997
12214
  );
@@ -12001,9 +12218,9 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12001
12218
  for (const step of plan.install) {
12002
12219
  diag(`would run: ${manager.name} ${installArgs(manager.name, step).join(" ")}`);
12003
12220
  }
12004
- if (!playbookExists) diag(`would write: ${STARTER_PLAYBOOK_FILENAME}`);
12005
- if (installSkills) diag(`would run: npx ${skillsAddArgs().join(" ")}`);
12006
- if (plan.install.length === 0 && playbookExists) diag("\u2713 Already set up \u2014 nothing to do.");
12221
+ if (starter === "create") diag(`would write: ${STARTER_PLAYBOOK_FILENAME}`);
12222
+ if (skillsArgs) diag(`would run: npx ${skillsArgs.join(" ")}`);
12223
+ if (plan.install.length === 0 && starter !== "create" && !skillsArgs) diag("\u2713 No setup changes planned.");
12007
12224
  return;
12008
12225
  }
12009
12226
  for (const step of plan.install) {
@@ -12020,7 +12237,7 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12020
12237
  if (plan.install.length > 0) {
12021
12238
  diag(`\u2713 Installed the RevTurbine SDK and CLI (CLI pinned to ${pkgVersion})`);
12022
12239
  }
12023
- const playbookAdded = !playbookExists;
12240
+ const playbookAdded = starter === "create";
12024
12241
  if (playbookAdded) {
12025
12242
  const check = validatePlaybook(STARTER_PLAYBOOK);
12026
12243
  if (!check.ok) {
@@ -12030,13 +12247,13 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12030
12247
  );
12031
12248
  }
12032
12249
  writeFileSync2(playbookPath, `${JSON.stringify(STARTER_PLAYBOOK, null, 2)}
12033
- `, "utf8");
12250
+ `, { encoding: "utf8", flag: "wx" });
12034
12251
  diag(`\u2713 Added a starter playbook (${STARTER_PLAYBOOK_FILENAME} \u2014 local mode, no account needed)`);
12035
12252
  }
12036
- let skillsOutcome = installSkills ? "installed" : "skipped";
12037
- if (installSkills) {
12253
+ let skillsOutcome = skillsArgs ? "installed" : opts.skills === false ? "skipped" : "unknown";
12254
+ if (skillsArgs) {
12038
12255
  diag("Installing the RevTurbine Agent Skills (npx skills)\u2026");
12039
- const args = skillsAddArgs();
12256
+ const args = skillsArgs;
12040
12257
  let code;
12041
12258
  try {
12042
12259
  code = await runInstall("npx", args, dir);
@@ -12050,7 +12267,7 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12050
12267
  diag("\u26A0 Could not install the Agent Skills automatically. Add them by hand:");
12051
12268
  diag(` npx ${args.join(" ")}`);
12052
12269
  }
12053
- } else {
12270
+ } else if (opts.skills === false) {
12054
12271
  diag("\u2022 Skipping the Agent Skills (--no-skills)");
12055
12272
  }
12056
12273
  if (!opts.json) {
@@ -12060,7 +12277,7 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12060
12277
  cliVersion: pkgVersion,
12061
12278
  playbookAdded,
12062
12279
  skills: skillsOutcome,
12063
- harness: detectHarness(process.env)
12280
+ harness
12064
12281
  });
12065
12282
  process.stdout.write(`${lines.join("\n")}
12066
12283
  `);
@@ -12153,13 +12370,22 @@ program.command("logout").description("Remove the stored token for <url>.").argu
12153
12370
  const removed = removeCredential(normalized);
12154
12371
  diag(removed ? `Logged out of ${normalized}.` : `No stored credential for ${normalized}.`);
12155
12372
  });
12156
- program.command("whoami").description("Show the resolved instance, tenant, credentials source, and whether the stored token works.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--json", "Machine-readable output").action(async (opts) => {
12157
- const conn = connect(opts.url, opts.tenantId);
12158
- let tokenValid = null;
12373
+ program.command("whoami").description("Show authentication state first (absent, accepted, rejected, or unverifiable), then instance, tenant, and credentials source.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--json", "Machine-readable output").action(async (opts) => {
12374
+ const diagnostics = [];
12375
+ const conn = connect(opts.url, opts.tenantId, (message) => diagnostics.push(message));
12376
+ let probeStatus = null;
12159
12377
  if (conn.hasToken) {
12160
- const probe = await resolveActiveDraft(conn.url, conn.headers).catch(() => ({ ok: false, status: 0, draft: null }));
12161
- tokenValid = probe.ok || probe.status !== 401 && probe.status !== 403 && probe.status !== 0;
12162
- }
12378
+ probeStatus = await fetch(`${conn.url}/api/optimization/drafts`, {
12379
+ headers: conn.headers,
12380
+ signal: globalThis.AbortSignal.timeout(WHOAMI_REQUEST_TIMEOUT_MS),
12381
+ // A redirected login page returning 200 is not proof of authentication.
12382
+ redirect: "manual"
12383
+ }).then(async (response) => {
12384
+ await response.body?.cancel();
12385
+ return response.status;
12386
+ }).catch(() => null);
12387
+ }
12388
+ const tokenValid = classifyToken(conn.hasToken, probeStatus);
12163
12389
  const data = {
12164
12390
  instance: conn.url,
12165
12391
  tenant: conn.tenantId,
@@ -12173,12 +12399,13 @@ program.command("whoami").description("Show the resolved instance, tenant, crede
12173
12399
  data,
12174
12400
  Boolean(opts.json),
12175
12401
  [
12402
+ formatAuthentication(conn.hasToken, tokenValid),
12176
12403
  `instance: ${data.instance}`,
12177
12404
  `tenant: ${data.tenant} (${data.tenant_source})`,
12178
- `credentials: ${data.credentials_dir} [${data.credentials_source}]`,
12179
- `token: ${data.token_present ? tokenValid ? "present, valid" : "present, NOT accepted" : "absent \u2014 run `revturbine login`"}`
12405
+ `credentials: ${data.credentials_dir} [${data.credentials_source}]`
12180
12406
  ].join("\n")
12181
12407
  );
12408
+ for (const message of diagnostics) diag(message);
12182
12409
  });
12183
12410
  program.command("schema").description("Emit the bundled RevTurbineConfig JSON schema for an agent to author against.").option("--json", "Accepted for symmetry; output is always JSON").action(() => {
12184
12411
  try {
@@ -12633,6 +12860,69 @@ ingestKeys.command("revoke").description("Revoke a public ingest key by id. The
12633
12860
  if (!result.ok) httpFail(conn, "ingest-keys revoke", result.status);
12634
12861
  diag(`\u2713 Revoked ingest key ${id}.`);
12635
12862
  });
12863
+ var events = program.command("events").description("Send analytics events to the RevTurbine ingest pipeline.");
12864
+ events.command("track").description("Ingest a batch of analytics events from a file or inline JSON.").option("-f, --file <path>", 'Batch file: JSON array, { "events": [...] } envelope, or NDJSON').option("-e, --event <json>", "A single event as inline JSON").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (selects among your tenants; defaults to the stored token tenant)").option("--json", "Machine-readable per-batch results").addHelpText(
12865
+ "after",
12866
+ [
12867
+ "",
12868
+ "Examples:",
12869
+ " revturbine events track --file ./events.ndjson",
12870
+ " revturbine events track --file ./batch.json --json",
12871
+ ` revturbine events track --event '{"environment_id":"production","user_id":"u1","account_id":"a1","event_name":"product_used","event_ts":"2026-09-10T00:00:00.000Z"}'`,
12872
+ "",
12873
+ `Every event needs environment_id, user_id, account_id, event_name and`,
12874
+ `event_ts. Batches larger than ${MAX_EVENTS_PER_BATCH} are chunked automatically.`,
12875
+ "",
12876
+ "This authenticates with your device-auth token, so it needs no ingest key",
12877
+ "and no browser origin. Quarantined and rejected counts are always reported",
12878
+ 'separately from accepted \u2014 a load is not "clean" because it returned 202.'
12879
+ ].join("\n")
12880
+ ).action(async (opts) => {
12881
+ if (!opts.file && !opts.event) fail(EXIT.USAGE, "events track needs --file <path> or --event <json>");
12882
+ if (opts.file && opts.event) fail(EXIT.USAGE, "events track takes --file or --event, not both");
12883
+ let text;
12884
+ if (opts.file) {
12885
+ try {
12886
+ text = readFileSync2(path2.resolve(opts.file), "utf8");
12887
+ } catch (err) {
12888
+ fail(EXIT.USAGE, `cannot read ${opts.file}: ${err.message}`);
12889
+ }
12890
+ } else {
12891
+ text = opts.event;
12892
+ }
12893
+ const parsed = parseEventBatch(text);
12894
+ if (parsed.errors.length > 0) {
12895
+ for (const e of parsed.errors) diagRaw(` ${e}`);
12896
+ fail(EXIT.VALIDATION, `${parsed.errors.length} event(s) failed local validation; nothing was sent`);
12897
+ }
12898
+ if (parsed.events.length === 0) fail(EXIT.VALIDATION, "no events to send");
12899
+ const conn = connect(opts.url, opts.tenantId);
12900
+ const chunks = chunkEvents(parsed.events);
12901
+ const results = [];
12902
+ for (const [i, chunk] of chunks.entries()) {
12903
+ if (chunks.length > 1 && !opts.json) {
12904
+ diag(`sending batch ${i + 1}/${chunks.length} (${chunk.length} event(s))\u2026`);
12905
+ }
12906
+ let posted;
12907
+ try {
12908
+ posted = await postBatch(conn.url, conn.headers, chunk);
12909
+ } catch (err) {
12910
+ if (isNetworkError(err)) {
12911
+ fail(EXIT.NETWORK, `network failure reaching ${conn.url}: ${err.message}`);
12912
+ }
12913
+ throw err;
12914
+ }
12915
+ if (!posted.ok || !posted.result) {
12916
+ if (results.length > 0) {
12917
+ diag(`${summarize(results, parsed.events.length).accepted} event(s) were accepted before this failure.`);
12918
+ }
12919
+ httpFail(conn, `events track (batch ${i + 1}/${chunks.length})`, posted.status, posted.error);
12920
+ }
12921
+ results.push(posted.result);
12922
+ }
12923
+ const summary = summarize(results, parsed.events.length);
12924
+ emit(summary, !!opts.json, formatSummary(summary));
12925
+ });
12636
12926
  var COMMAND_EXAMPLES = {
12637
12927
  download: [
12638
12928
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.17.2",
3
+ "version": "0.18.1",
4
4
  "description": "revturbine — validate RevTurbine Playbooks and ship them to a RevTurbine instance through the playbook-version lifecycle (draft → Release).",
5
5
  "license": "MIT",
6
6
  "repository": {