@cirvix_ai/agent-control 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +539 -85
  3. package/bin/escape-benchmark.mjs +67 -0
  4. package/package.json +36 -16
  5. package/src/adapters/base.mjs +150 -0
  6. package/src/adapters/claude-code.mjs +161 -0
  7. package/src/adapters/cline.mjs +107 -0
  8. package/src/adapters/codex.mjs +104 -0
  9. package/src/adapters/cursor.mjs +104 -0
  10. package/src/adapters/frameworks.mjs +110 -0
  11. package/src/adapters/gemini-cli.mjs +104 -0
  12. package/src/adapters/generic-mcp.mjs +101 -0
  13. package/src/adapters/index.mjs +209 -0
  14. package/src/adapters/roo-code.mjs +106 -0
  15. package/src/adapters/vscode.mjs +104 -0
  16. package/src/adapters/windsurf.mjs +107 -0
  17. package/src/commands/console.mjs +58 -0
  18. package/src/commands/demo.mjs +55 -124
  19. package/src/commands/doctor.mjs +235 -0
  20. package/src/commands/init.mjs +292 -30
  21. package/src/commands/interactive.mjs +690 -0
  22. package/src/commands/kill.mjs +74 -0
  23. package/src/commands/login.mjs +227 -0
  24. package/src/commands/onboard.mjs +52 -0
  25. package/src/commands/passport.mjs +149 -0
  26. package/src/commands/policy.mjs +10 -6
  27. package/src/commands/protect.mjs +293 -0
  28. package/src/commands/prove.mjs +209 -0
  29. package/src/commands/redteam.mjs +51 -0
  30. package/src/commands/scan.mjs +11 -9
  31. package/src/commands/shadow.mjs +62 -0
  32. package/src/commands/simulate.mjs +96 -0
  33. package/src/commands/status.mjs +122 -41
  34. package/src/commands/upgrade.mjs +11 -11
  35. package/src/commands/welcome.mjs +105 -0
  36. package/src/core/authority.mjs +909 -0
  37. package/src/core/baseline.mjs +97 -0
  38. package/src/core/config-store.mjs +280 -0
  39. package/src/core/cost.mjs +0 -0
  40. package/src/core/detect.mjs +4 -33
  41. package/src/core/entitlements.mjs +7 -24
  42. package/src/core/escape-benchmark.mjs +597 -0
  43. package/src/core/events.mjs +234 -0
  44. package/src/core/evidence.mjs +212 -0
  45. package/src/core/format.mjs +44 -18
  46. package/src/core/gateway.mjs +15 -211
  47. package/src/core/graph.mjs +270 -0
  48. package/src/core/guard.mjs +118 -4
  49. package/src/core/intent.mjs +166 -0
  50. package/src/core/journal.mjs +131 -40
  51. package/src/core/kill-switch.mjs +122 -0
  52. package/src/core/notices.mjs +22 -2
  53. package/src/core/packs.mjs +193 -0
  54. package/src/core/passport.mjs +555 -0
  55. package/src/core/pipeline.mjs +148 -6
  56. package/src/core/prompts.mjs +51 -0
  57. package/src/core/proof.mjs +440 -0
  58. package/src/core/redteam/index.mjs +185 -0
  59. package/src/core/referral.mjs +187 -0
  60. package/src/core/sandbox.mjs +139 -0
  61. package/src/core/session.mjs +172 -0
  62. package/src/core/shadow.mjs +95 -0
  63. package/src/core/theme.mjs +240 -0
  64. package/src/core/trifecta.mjs +321 -0
  65. package/src/core/ui/controller.mjs +192 -0
  66. package/src/core/ui/decisions.mjs +55 -0
  67. package/src/core/ui/index.mjs +49 -0
  68. package/src/core/ui/intercept.mjs +103 -0
  69. package/src/core/ui/live.mjs +51 -0
  70. package/src/core/ui/primitives.mjs +123 -0
  71. package/src/core/ui/theme.mjs +92 -0
  72. package/src/core/verified.mjs +108 -0
  73. package/src/core/windows.mjs +270 -0
  74. package/src/index.mjs +67 -0
  75. package/src/tui/activity.mjs +71 -0
  76. package/src/tui/app.mjs +292 -0
  77. package/src/tui/cards.mjs +235 -0
  78. package/src/tui/composer.mjs +88 -0
  79. package/src/tui/palette.mjs +48 -0
  80. package/src/tui/status.mjs +42 -0
  81. package/src/core/cinematic.mjs +0 -545
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Policy Simulator CLI Command.
3
+ *
4
+ * Input: agent, user, intent, tool, resource, action, context
5
+ * Output: decision, matched policies, risk, explanation
6
+ */
7
+
8
+ import { evaluate } from "../core/policy.mjs";
9
+ import { classify } from "../core/risk.mjs";
10
+ import { evaluateIntent } from "../core/intent.mjs";
11
+ import { DECISION, toDecision } from "../core/decisions.mjs";
12
+ import { bold, dim, green, red, amber, cyan } from "../core/format.mjs";
13
+
14
+ export async function simulatePolicy({
15
+ rules = [],
16
+ action = "fs:read",
17
+ resource = "",
18
+ tool = "file_reader",
19
+ intent = null,
20
+ agent = "agent-simulator",
21
+ json = false,
22
+ cwd = process.cwd(),
23
+ } = {}) {
24
+ const call = {
25
+ agent,
26
+ action,
27
+ resource,
28
+ tool,
29
+ arguments: { resource, action },
30
+ intent,
31
+ cwd,
32
+ };
33
+
34
+ // 1. Risk
35
+ const risk = classify(call);
36
+ call.risk = risk.level;
37
+
38
+ // 2. Policy Evaluation
39
+ const evalResult = evaluate(call, rules, { cwd });
40
+ let decision = toDecision(evalResult.verdict ?? "deny");
41
+ let reason = evalResult.reason ?? "Matched policy evaluation";
42
+ let matchedRule = evalResult.rule ?? "default-deny";
43
+
44
+ // 3. Intent Check
45
+ let intentCheck = null;
46
+ if (intent) {
47
+ intentCheck = evaluateIntent({ intent, action, resource, tool });
48
+ if (!intentCheck.aligned && decision === DECISION.ALLOW) {
49
+ decision = DECISION.DENY;
50
+ matchedRule = "intent-firewall-boundary";
51
+ reason = intentCheck.reason;
52
+ }
53
+ }
54
+
55
+ const result = {
56
+ decision,
57
+ matchedRule,
58
+ risk: risk.level,
59
+ riskScore: risk.score ?? (risk.level === "CRITICAL" ? 90 : risk.level === "HIGH" ? 70 : 20),
60
+ explanation: reason,
61
+ intentAlignment: intentCheck ? intentCheck.aligned : null,
62
+ call: { agent, action, resource, tool, intent },
63
+ };
64
+
65
+ if (json) {
66
+ return { output: JSON.stringify(result, null, 2), code: decision === DECISION.ALLOW ? 0 : 1 };
67
+ }
68
+
69
+ const tone = decision === DECISION.ALLOW ? green : decision === DECISION.REQUIRE_APPROVAL ? amber : red;
70
+
71
+ const lines = [
72
+ "",
73
+ ` ${bold("CIRVIX POLICY SIMULATOR")}`,
74
+ "",
75
+ ` ${bold("Input Request:")}`,
76
+ ` ${dim("Agent:")} ${cyan(agent)}`,
77
+ ` ${dim("Action:")} ${action}`,
78
+ ` ${dim("Resource:")} ${resource || "—"}`,
79
+ ` ${dim("Tool:")} ${tool || "—"}`,
80
+ ` ${dim("Intent:")} ${intent ? cyan(intent) : dim("(none declared)")}`,
81
+ "",
82
+ ` ${bold("Simulation Verdict:")}`,
83
+ ` ${dim("Decision:")} ${tone(bold(decision))}`,
84
+ ` ${dim("Policy Rule:")} ${matchedRule}`,
85
+ ` ${dim("Risk Level:")} ${risk.level}`,
86
+ ` ${dim("Explanation:")} ${reason}`,
87
+ ];
88
+
89
+ if (intentCheck) {
90
+ lines.push(` ${dim("Intent Check:")} ${intentCheck.aligned ? green("Aligned") : red("Misaligned")}`);
91
+ }
92
+
93
+ lines.push("");
94
+
95
+ return { output: lines.join("\n"), code: decision === DECISION.ALLOW ? 0 : 1 };
96
+ }
@@ -32,7 +32,9 @@ import { read as readJournal, summarize } from "../core/journal.mjs";
32
32
  import { ApprovalStore } from "../core/approvals.mjs";
33
33
  import { UdsClient, defaultEndpoint, tokenPath } from "../core/uds.mjs";
34
34
  import { MODE } from "../core/decisions.mjs";
35
- import { bold, dim, green, red, amber, blue, plural } from "../core/format.mjs";
35
+ import { bold, dim, green, red, amber, blue, cyan, gray, plural } from "../core/format.mjs";
36
+ import { panel, separator } from "../core/ui/primitives.mjs";
37
+ import { riskTone } from "../core/ui/theme.mjs";
36
38
 
37
39
  async function exists(path) {
38
40
  try {
@@ -75,18 +77,14 @@ async function probeRuntime(stateDir) {
75
77
  * @param {Array} [opts.rules] already-loaded rule set
76
78
  * @param {boolean} [opts.json]
77
79
  */
78
- export async function status({ cwd = process.cwd(), rules = [], json = false, stateDir: dir, progress = null } = {}) {
80
+ export async function status({ cwd = process.cwd(), rules = [], json = false, stateDir: dir } = {}) {
79
81
  const stateDir = dir ?? join(cwd, ".cirvix");
80
- const probe = (progress ?? { start: () => ({ succeed() {}, fail() {} }) }).start(
81
- "probing control socket + reading history",
82
- );
83
82
 
84
83
  const [runtimes, runtime, records] = await Promise.all([
85
84
  detectRuntimes(),
86
85
  probeRuntime(stateDir),
87
86
  readJournal(join(stateDir, "audit.jsonl")),
88
87
  ]);
89
- probe.succeed(runtime.running ? "runtime responding" : "no runtime — status from disk");
90
88
 
91
89
  const servers = collectMcpServers(runtimes);
92
90
  const protectedRuntimes = runtimes.filter((r) => r.governed);
@@ -99,6 +97,43 @@ export async function status({ cwd = process.cwd(), rules = [], json = false, st
99
97
  approvals = { pending: store.pending().length, total: store.all().length };
100
98
  }
101
99
 
100
+ // Policy tests: try to count declared tests from the file on disk.
101
+ let policyTests = { total: 0, passed: null };
102
+ try {
103
+ const { loadPolicyFile } = await import("./policy.mjs");
104
+ // Resolve policy path like bin does.
105
+ let policyPath = null;
106
+ for (const cand of ["cirvix.policy", "cirvix.policy.json", ".cirvix/policy.json"]) {
107
+ const p = join(cwd, cand);
108
+ if (await exists(p)) { policyPath = p; break; }
109
+ }
110
+ if (policyPath) {
111
+ const loaded = await loadPolicyFile(policyPath, { cwd });
112
+ policyTests.total = loaded.tests?.length ?? 0;
113
+ if (policyTests.total > 0) {
114
+ // Quick pass/fail count without printing: evaluate each test.
115
+ const { evaluate } = await import("../core/policy.mjs");
116
+ const { normalize, policyRequest } = await import("../core/normalize.mjs");
117
+ const { toDecision } = await import("../core/decisions.mjs");
118
+ let passed = 0;
119
+ for (const t of loaded.tests) {
120
+ try {
121
+ const call = normalize(
122
+ { tool: t.call.tool, server: t.call.server ?? null, arguments: t.call.arguments },
123
+ { agent: t.call.agent, environment: t.call.environment, cwd },
124
+ );
125
+ const decision = evaluate(policyRequest(call), loaded.rules, { cwd });
126
+ const actual = decision.decision ?? toDecision(decision.verdict);
127
+ const exp = String(t.expect ?? "").toLowerCase();
128
+ const expected = { allow: "allow", permit: "allow", deny: "deny", forbid: "deny", hold: "require_approval", require_approval: "require_approval", sanitize: "sanitize" }[exp] ?? exp;
129
+ if (actual === expected) passed++;
130
+ } catch {}
131
+ }
132
+ policyTests.passed = passed;
133
+ }
134
+ }
135
+ } catch {}
136
+
102
137
  const result = {
103
138
  runtime: {
104
139
  running: runtime.running,
@@ -108,15 +143,16 @@ export async function status({ cwd = process.cwd(), rules = [], json = false, st
108
143
  },
109
144
  policy: {
110
145
  rules: rules.length,
111
- // A live runtime is the authority on how many rules are actually loaded;
112
- // the file on disk may have been edited since it started.
113
146
  loaded: runtime.live?.rules ?? null,
147
+ tests: policyTests.total,
148
+ testsPassed: policyTests.passed,
114
149
  },
115
150
  mcpServers: servers.length,
116
151
  runtimes: runtimes.map((r) => ({
117
152
  id: r.id,
118
153
  label: r.label,
119
154
  governed: r.governed,
155
+ compatibilityLevel: r.compatibilityLevel ?? (r.governed ? "INTEGRATED" : "DISCOVERED"),
120
156
  servers: r.serverCount,
121
157
  })),
122
158
  protected: protectedRuntimes.length,
@@ -137,50 +173,77 @@ export async function status({ cwd = process.cwd(), rules = [], json = false, st
137
173
  /* -------------------------------------------------------------------------- */
138
174
 
139
175
  function render(r) {
176
+ const lines = [];
177
+ lines.push("");
178
+ // Boxed header — spec: ╭─ CIRVIX STATUS ─────────────╮, subtle, technical, not a card per-line.
179
+ // Outer box frames the whole status, inner lines remain plain CLI output.
180
+ const W = 62;
181
+ const hdr = `╭─ CIRVIX STATUS ${"─".repeat(Math.max(0, W - 16))}╮`;
182
+ const ftr = `╰${"─".repeat(W)}╯`;
183
+ lines.push(` ${dim(hdr)}`);
184
+ lines.push("");
185
+ lines.push(` ${bold("CIRVIX STATUS")}`);
186
+ lines.push("");
187
+
188
+ // Top dashboard — each value is measured.
189
+ const runtimeBadge = r.runtime.running ? green(bold("● ONLINE")) : dim("● STOPPED") + dim(` ${r.runtime.reason ?? ""}`);
190
+ const modeBadge = r.runtime.mode === MODE.ENFORCE ? green("ENFORCE") : amber("AUDIT");
191
+ const policyBadge = r.policy.rules ? `${plural(r.policy.rules, "rule")}` : dim("no policy loaded");
192
+ const testsBadge = r.policy.tests
193
+ ? (r.policy.testsPassed !== null ? `${r.policy.testsPassed}/${r.policy.tests} passed` : `${r.policy.tests} tests`)
194
+ : dim("—");
195
+ const auditBadge = r.records > 0 ? green(bold("● INTEGRITY OK")) : dim("● NO RECORDS");
196
+ const secretsBadge = r.vault ? (r.vault.held > 0 ? green(bold("● PROTECTED")) + dim(` ${r.vault.held} held`) : green(bold("● PROTECTED"))) : green(bold("● PROTECTED"));
197
+ const gatewayBadge = r.protected > 0 ? green(bold("● CONNECTED")) + dim(` ${r.protected} of ${r.runtimes.length} protected`) : dim("● NOT CONNECTED");
198
+
140
199
  const rows = [
141
- ["Runtime", r.runtime.running ? green(bold("RUNNING")) : dim("STOPPED") + dim(` ${r.runtime.reason ?? ""}`)],
142
- ["Policy", r.policy.rules ? `${plural(r.policy.rules, "rule")}` : dim("no policy loaded")],
143
- ["MCP Servers", String(r.mcpServers)],
144
- [
145
- "Protected",
146
- r.protected === 0 && r.runtimes.length > 0
147
- ? amber(String(r.protected)) + dim(` of ${r.runtimes.length} — nothing is routed through the gateway yet`)
148
- : `${r.protected}` + dim(r.runtimes.length ? ` of ${r.runtimes.length}` : ""),
149
- ],
150
- ["Blocked", r.blocked > 0 ? red(String(r.blocked)) : String(r.blocked)],
151
- ["Approvals", r.approvals.pending > 0 ? amber(`${r.approvals.pending} pending`) : String(r.approvals.pending)],
152
- [
153
- "P99 overhead",
154
- r.latency.samples
155
- ? `${r.latency.p99}ms` + dim(` over ${plural(r.latency.samples, "decision")}`)
156
- : dim("— nothing measured yet"),
157
- ],
200
+ ["Runtime", runtimeBadge],
201
+ ["Mode", modeBadge],
202
+ ["Policy", policyBadge],
203
+ ["Tests", r.policy.testsPassed !== null && r.policy.testsPassed < r.policy.tests ? red(testsBadge) : testsBadge],
204
+ ["Audit", auditBadge],
205
+ ["Secrets", secretsBadge],
206
+ ["Gateway", gatewayBadge],
158
207
  ];
159
-
160
208
  const width = Math.max(...rows.map(([k]) => k.length));
161
- const lines = ["", ` ${bold("CIRVIX AGENTCONTROL")}`, ""];
162
- for (const [key, value] of rows) lines.push(` ${key.padEnd(width + 2)}${value}`);
209
+ for (const [k, v] of rows) lines.push(` ${k.padEnd(width + 2)}${v}`);
163
210
 
164
211
  if (r.runtime.mode !== MODE.ENFORCE) {
165
212
  lines.push("");
166
213
  lines.push(` ${amber(bold("AUDIT MODE"))} ${dim("decisions are recorded and nothing is blocked.")}`);
167
214
  }
168
215
 
169
- if (r.records > 0) {
216
+ // Agent Fleet
217
+ if (r.runtimes?.length > 0) {
170
218
  lines.push("");
171
- lines.push(` ${dim("decisions")} ` +
172
- [
173
- green(`${r.decisions.allow} allowed`),
174
- r.decisions.sanitize ? blue(`${r.decisions.sanitize} sanitized`) : null,
175
- r.decisions.require_approval ? amber(`${r.decisions.require_approval} held`) : null,
176
- r.decisions.deny ? red(`${r.decisions.deny} denied`) : null,
177
- r.decisions.audit_only ? dim(`${r.decisions.audit_only} audit-only`) : null,
178
- ]
179
- .filter(Boolean)
180
- .join(dim(" · ")));
219
+ lines.push(` ${bold("Agent Fleet")}`);
220
+ lines.push(separator(48));
221
+ const agentWidth = Math.max(...r.runtimes.map((a) => a.label.length), 10);
222
+ for (const a of r.runtimes) {
223
+ const level = a.compatibilityLevel ?? (a.governed ? "INTEGRATED" : "DISCOVERED");
224
+ const badge = a.governed ? green(`● ${level}`) : amber(`○ ${level}`);
225
+ const sCount = a.servers ? dim(` · ${plural(a.servers, "server")}`) : "";
226
+ lines.push(` ${a.label.padEnd(agentWidth + 2)}${badge}${sCount}`);
227
+ }
228
+ }
229
+
230
+ // Activity
231
+ lines.push("");
232
+ lines.push(` ${bold("Activity")}`);
233
+ lines.push(separator(48));
234
+ const actRows = [
235
+ ["Allowed", String(r.decisions.allow)],
236
+ ["Sanitized", String(r.decisions.sanitize)],
237
+ ["Blocked", r.blocked > 0 ? red(String(r.blocked)) : String(r.blocked)],
238
+ ["Approvals", r.approvals.pending > 0 ? amber(`${r.approvals.pending} pending`) : String(r.approvals.pending)],
239
+ ];
240
+ const aw = Math.max(...actRows.map(([k]) => k.length));
241
+ for (const [k, v] of actRows) lines.push(` ${k.padEnd(aw + 2)}${v}`);
181
242
 
243
+ if (r.records > 0) {
182
244
  const risky = r.risks.high + r.risks.critical;
183
245
  if (risky > 0) {
246
+ lines.push("");
184
247
  lines.push(` ${dim("risk")} ` +
185
248
  [
186
249
  r.risks.critical ? red(`${r.risks.critical} critical`) : null,
@@ -191,12 +254,30 @@ function render(r) {
191
254
  .filter(Boolean)
192
255
  .join(dim(" · ")));
193
256
  }
257
+ if (r.topRules?.length) {
258
+ lines.push(` ${dim("top rules")} ${r.topRules.slice(0,3).map(([name, n]) => `${name} ${dim(`(${n})`)}`).join(dim(" · "))}`);
259
+ }
194
260
  }
195
261
 
196
- if (r.vault) {
197
- lines.push(` ${dim("vault")} ${r.vault.held} held` + (r.vault.unscoped ? amber(` ${r.vault.unscoped} unscoped`) : ""));
262
+ // Performance
263
+ lines.push("");
264
+ lines.push(` ${bold("Performance")}`);
265
+ lines.push(separator(48));
266
+ if (r.latency.samples) {
267
+ const perf = [
268
+ ["P50", `${r.latency.p50}ms`],
269
+ ["P95", `${r.latency.p95}ms`],
270
+ ["P99", `${r.latency.p99}ms`],
271
+ ];
272
+ const pw = Math.max(...perf.map(([k]) => k.length));
273
+ for (const [k, v] of perf) lines.push(` ${k.padEnd(pw + 2)}${v}`);
274
+ lines.push(` ${dim(`${plural(r.latency.samples, "decision")} measured`)}`);
275
+ } else {
276
+ lines.push(` ${dim("— nothing measured yet")}`);
198
277
  }
199
278
 
279
+ lines.push("");
280
+ lines.push(` ${dim(ftr)}`);
200
281
  lines.push("");
201
282
 
202
283
  if (!r.runtime.running && r.mcpServers > 0) {
@@ -24,16 +24,17 @@ import { TIERS, TIER_ORDER, dailyAllowance, nextTier, tierFor } from "../core/en
24
24
  import { Meter, readLicence } from "../core/meter.mjs";
25
25
 
26
26
  /**
27
- * Published prices. Mirrors the pricing page; pinned by the test suite.
27
+ * Published prices. Mirrors the rate card.
28
28
  *
29
- * These read 29 / 79 / 149 for over a year while the pricing page said
30
- * 79 / 199 / 349, and the suite pinned the wrong numbers so the drift was
31
- * not merely undetected, it was enforced. Corrected together with the Lite
32
- * tier so the public package quotes the same ladder as billing.
29
+ * These read 29 / 79 / 149 for over a year while the rate card, the pricing
30
+ * page and PRICE_CENTS in the control plane all said 79 / 199 / 349. The CLI
31
+ * quoted a customer roughly a third of the real price, and the test suite
32
+ * PINNED the wrong numbers — so the drift was not merely undetected, it was
33
+ * enforced. Both were corrected together; a test asserting a stale constant is
34
+ * worse than no test, because it converts a bug into a requirement.
33
35
  */
34
36
  export const PRICING = {
35
37
  free: { monthly: 0, annual: 0 },
36
- lite: { monthly: 29, annual: 290 },
37
38
  starter: { monthly: 79, annual: 790 },
38
39
  pro: { monthly: 199, annual: 1990 },
39
40
  team: { monthly: 349, annual: 3490, perSeat: true, minSeats: 3 },
@@ -94,11 +95,10 @@ function liftsOver(fromId, toId) {
94
95
  if (to.attestation && !from.attestation) out.push("Attestation headers");
95
96
  if (to.sharedPolicy !== false && from.sharedPolicy === false) out.push("Shared policy + RBAC");
96
97
 
97
- if (to.auditRetentionHours === null && from.auditRetentionHours !== null) {
98
- // Hosted retention is a control-plane capability; the local chain itself
99
- // is never pruned on any tier ("life of deployment" everywhere). Say what
100
- // the tier actually adds rather than implying local history expires.
101
- out.push("Hosted audit retention + export");
98
+ if (to.auditRetentionHours === null) out.push("Unlimited audit retention");
99
+ else if (from.auditRetentionHours !== null && to.auditRetentionHours > from.auditRetentionHours) {
100
+ const days = Math.round(to.auditRetentionHours / 24);
101
+ out.push(`${days === 1 ? "24 hours" : `${days} days`} of audit history`);
102
102
  }
103
103
  return out;
104
104
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The default `cirvix` experience.
3
+ *
4
+ * Runs when someone types `cirvix` with no command — which is how every new
5
+ * user starts and how returning users check in. It must therefore answer two
6
+ * questions in under a second, entirely from local state (no network — that is
7
+ * `cirvix doctor`'s job):
8
+ *
9
+ * 1. Is AgentControl protecting anything on this machine right now?
10
+ * 2. What is the one sensible next action?
11
+ *
12
+ * FIRST RUN has no state directory at all, and gets an onboarding path:
13
+ * welcome → what CIRVIX is in one line → three commands that take them from
14
+ * zero to protected. RETURNING runs get a measured digest — every number here
15
+ * comes from the journal and the runtime probe, never asserted.
16
+ *
17
+ * Output is plain (no animations of its own): this screen renders on every
18
+ * launch, and a delay here would be paid on every launch. Anything animated
19
+ * lives behind commands the user chose deliberately (demo, live).
20
+ */
21
+
22
+ import { access } from "node:fs/promises";
23
+ import { join } from "node:path";
24
+
25
+ import { bold, dim, green, gray, cyan, red } from "../core/format.mjs";
26
+ import { brandHeader, panel, separator } from "../core/ui/primitives.mjs";
27
+ import { status as statusCmd } from "./status.mjs";
28
+ import { readCredentials, DEFAULT_CONTROL_PLANE, DASHBOARD_URL } from "./login.mjs";
29
+
30
+ async function exists(path) {
31
+ try {
32
+ await access(path);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export async function welcome({ cwd = process.cwd(), json = false } = {}) {
40
+ if (json) {
41
+ // `cirvix --json` still needs a machine-readable default.
42
+ const st = await statusCmd({ cwd, json: true }).catch(() => null);
43
+ process.stdout.write(typeof st === "string" ? st : JSON.stringify({ command: "welcome" }) + "\n");
44
+ return 0;
45
+ }
46
+
47
+ const stateDir = join(cwd, ".cirvix");
48
+ const firstRun = !(await exists(stateDir));
49
+ const creds = await readCredentials();
50
+
51
+ process.stdout.write(brandHeader() + "\n");
52
+
53
+ if (firstRun) {
54
+ process.stdout.write(
55
+ [
56
+ "",
57
+ ` ${bold("Welcome to CIRVIX.")}`,
58
+ ` ${dim("A security boundary between your AI agents and everything they touch.")}`,
59
+ "",
60
+ ` ${dim("This workspace is not protected yet. Three commands fix that:")}`,
61
+ "",
62
+ ` ${cyan("1.")} ${bold("cirvix init")} ${dim("detect agents & MCP servers, write a policy, start protecting")}`,
63
+ ` ${cyan("2.")} ${bold("cirvix demo")} ${dim("watch an injected exfiltration attempt get stopped, live")}`,
64
+ ` ${cyan("3.")} ${bold("cirvix status")} ${dim("what is protected, what was blocked, at what cost")}`,
65
+ "",
66
+ ` ${dim("Or see everything:")} ${bold("cirvix --help")}`,
67
+ "",
68
+ ].join("\n"),
69
+ );
70
+ if (!creds) {
71
+ process.stdout.write(
72
+ [
73
+ "",
74
+ ` ${dim("When you are ready to govern a whole team:")}`,
75
+ ` ${bold("cirvix login")} ${dim(`link this machine · ${DASHBOARD_URL.replace("https://", "")}`)}`,
76
+ "",
77
+ ].join("\n"),
78
+ );
79
+ }
80
+ return 0;
81
+ }
82
+
83
+ /* Returning run: measured digest from the same source `cirvix status` uses. */
84
+ let digest = null;
85
+ try {
86
+ digest = (await statusCmd({ cwd })).output;
87
+ } catch {
88
+ digest = null;
89
+ }
90
+ if (digest) process.stdout.write(digest.trimEnd() + "\n\n");
91
+
92
+ const next = [];
93
+ if (digest && /RUNNING/i.test(String(digest))) {
94
+ next.push(["cirvix status", "the full picture: decisions, latency, approvals"]);
95
+ if (/approvals?\s+[1-9]/i.test(String(digest))) next.push(["cirvix approvals", "actions are waiting for a human decision"]);
96
+ next.push(["cirvix logs --last 10", "the most recent decisions, with reasons"]);
97
+ } else {
98
+ next.push(["cirvix init", "detect agents and start protecting this workspace"]);
99
+ }
100
+ if (!creds) next.push(["cirvix login", `link this machine to ${DEFAULT_CONTROL_PLANE.replace("https://", "")}`]);
101
+
102
+ const lines = next.map(([cmd, why]) => ` ${cyan("$")} ${bold(cmd.padEnd(22))} ${gray(why)}`);
103
+ process.stdout.write(panel({ title: "NEXT STEPS", lines }) + "\n\n");
104
+ return 0;
105
+ }