@mnemom/mnemom 0.12.1 → 0.14.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.
@@ -1 +1,37 @@
1
- export declare function agentsListCommand(): Promise<void>;
1
+ export interface AgentsListOptions {
2
+ /** Scope to a single org. When omitted, aggregates across every org you belong to. */
3
+ org?: string;
4
+ }
5
+ export declare function agentsListCommand(options?: AgentsListOptions): Promise<void>;
6
+ export interface AgentsClaimOptions {
7
+ /** Org slug OR org_id to claim into. Omit → land in your personal org (the API default). */
8
+ org?: string;
9
+ /** The agent's provider API key — the CLI derives the hash proof from it. */
10
+ key?: string;
11
+ /** A pre-computed 64-hex hash proof (advanced/CI; alternative to --key). */
12
+ hashProof?: string;
13
+ /**
14
+ * The name the agent was provisioned with, used in the proof derivation
15
+ * `sha256(`${key}|${name}`)`. Defaults to a name-shaped positional arg, else
16
+ * the agent is treated as an unnamed singleton (`sha256(key)`).
17
+ */
18
+ name?: string;
19
+ json?: boolean;
20
+ }
21
+ /**
22
+ * Derive the full 64-hex hash proof the claim endpoint expects:
23
+ * `sha256(`${apiKey}|${agentName}`)` for a named agent, or `sha256(apiKey)`
24
+ * for an unnamed singleton. This mirrors the gateway's provisioning
25
+ * derivation (gateway/src/index.ts) and agent-proof.ts on the API side.
26
+ */
27
+ export declare function deriveHashProof(apiKey: string, agentName?: string): string;
28
+ /**
29
+ * `mnemom agents claim <id-or-name> [--org <slug>] (--key <k> | --hash-proof <h>)`
30
+ *
31
+ * Claims an agent into an org (ADR-062). Possession of the agent's key (via
32
+ * `--key`, from which the CLI derives the proof) or a pre-computed
33
+ * `--hash-proof` authenticates the claim; `--org` selects the landing org
34
+ * (omit → your personal org). On a not-a-member 403 the command teaches by
35
+ * listing your claimable orgs.
36
+ */
37
+ export declare function agentsClaimCommand(idOrName: string, options?: AgentsClaimOptions): Promise<void>;
@@ -1,13 +1,14 @@
1
- import { listAgents } from "../lib/api.js";
1
+ import { createHash } from "node:crypto";
2
+ import { listOrgAgents, listMyOrgs, getAgentByName, claimAgent, MnemomApiError, } from "../lib/api.js";
2
3
  import { requireAuth } from "../lib/auth.js";
3
4
  import { fmt } from "../lib/format.js";
4
- export async function agentsListCommand() {
5
+ export async function agentsListCommand(options = {}) {
5
6
  await requireAuth();
6
7
  console.log(fmt.header("Agents"));
7
8
  console.log();
8
9
  let agents;
9
10
  try {
10
- agents = await listAgents();
11
+ agents = await listOrgAgents(options.org);
11
12
  }
12
13
  catch (err) {
13
14
  const msg = err instanceof Error ? err.message : String(err);
@@ -15,24 +16,248 @@ export async function agentsListCommand() {
15
16
  process.exit(1);
16
17
  }
17
18
  if (agents.length === 0) {
18
- console.log(" No agents found.\n");
19
+ console.log(options.org ? ` No agents found in org '${options.org}'.\n` : " No agents found.\n");
19
20
  return;
20
21
  }
21
- // Table header
22
+ // Show the Org column only when aggregating across orgs; with --org it's redundant.
23
+ const showOrg = !options.org;
22
24
  const nameW = 24;
23
25
  const idW = 40;
24
26
  const seenW = 14;
25
27
  const statusW = 14;
26
- const header = "Name".padEnd(nameW) + "ID".padEnd(idW) + "Last Seen".padEnd(seenW) + "Containment";
28
+ const orgW = 22;
29
+ let header = "Name".padEnd(nameW) +
30
+ "ID".padEnd(idW) +
31
+ "Last Seen".padEnd(seenW) +
32
+ "Containment".padEnd(statusW);
33
+ if (showOrg)
34
+ header += "Org";
27
35
  console.log(` ${header}`);
28
- console.log(` ${"─".repeat(nameW + idW + seenW + statusW)}`);
36
+ console.log(` ${"─".repeat(nameW + idW + seenW + statusW + (showOrg ? orgW : 0))}`);
29
37
  for (const agent of agents) {
30
38
  const name = (agent.name ?? "-").slice(0, nameW - 2).padEnd(nameW);
31
39
  const id = agent.id.padEnd(idW);
32
40
  const lastSeen = agent.last_seen ? new Date(agent.last_seen).toLocaleDateString() : "-";
33
41
  const seen = lastSeen.padEnd(seenW);
34
- const containment = agent.containment_status ?? "-";
35
- console.log(` ${name}${id}${seen}${containment}`);
42
+ const containment = (agent.containment_status ?? "-").padEnd(statusW);
43
+ const org = showOrg ? agent.org_name.slice(0, orgW - 2) : "";
44
+ console.log(` ${name}${id}${seen}${containment}${org}`);
36
45
  }
37
46
  console.log(`\n Total: ${agents.length} agent(s)\n`);
38
47
  }
48
+ // ─── agents claim (ADR-062 claim-to-org) ──────────────────────────────────
49
+ /** Matches the canonical agent-id shapes (mirrors resolveAgentId in lib/api). */
50
+ const AGENT_ID_RE = /^(smolt-[0-9a-f]{8}|mnm-[0-9a-f-]{36})$/;
51
+ /** A presented hash proof is the full 64-hex SHA-256 digest. */
52
+ const FULL_PROOF_RE = /^[0-9a-f]{64}$/;
53
+ // SHA-256 → lowercase hex. NOTE the apiKey-derived input below: this is NOT
54
+ // password-at-rest hashing — it's the platform's deterministic proof-of-
55
+ // possession (`hash_proof`). It MUST equal the server's stored value, which the
56
+ // gateway + mnemom-api/src/auth/agent-proof.ts compute as plain SHA-256 of
57
+ // `${apiKey}|${name}`. A slow KDF (bcrypt/scrypt) would produce a different
58
+ // digest and break the claim handshake entirely, so SHA-256 is mandated by the
59
+ // protocol — the "insufficient computational effort" alert is a false positive.
60
+ function sha256Hex(input) {
61
+ // lgtm[js/insufficient-password-hash] — protocol proof, not password storage (see note above)
62
+ return createHash("sha256").update(input, "utf8").digest("hex"); // codeql[js/insufficient-password-hash]
63
+ }
64
+ /**
65
+ * Derive the full 64-hex hash proof the claim endpoint expects:
66
+ * `sha256(`${apiKey}|${agentName}`)` for a named agent, or `sha256(apiKey)`
67
+ * for an unnamed singleton. This mirrors the gateway's provisioning
68
+ * derivation (gateway/src/index.ts) and agent-proof.ts on the API side.
69
+ */
70
+ export function deriveHashProof(apiKey, agentName) {
71
+ const key = apiKey.trim();
72
+ const name = agentName?.trim();
73
+ return name ? sha256Hex(`${key}|${name}`) : sha256Hex(key);
74
+ }
75
+ /**
76
+ * Resolve an `--org <slug>` value to an org_id. Accepts either a slug or a raw
77
+ * org_id (both are matched against the caller's memberships). Returns the
78
+ * resolved org_id, or throws a teaching error listing the caller's orgs when
79
+ * the value matches none of them.
80
+ */
81
+ function resolveOrgId(slugOrId, orgs) {
82
+ const needle = slugOrId.trim();
83
+ const match = orgs.find((o) => o.slug === needle) ?? orgs.find((o) => o.org_id === needle);
84
+ if (match)
85
+ return match.org_id;
86
+ const choices = orgs.map((o) => ` ${o.slug.padEnd(24)} ${o.name}`).join("\n");
87
+ throw new Error(`No org of yours matches '${needle}'. You can claim into one of:\n${choices}\n` +
88
+ ` Pass --org <slug>, or omit --org to land in your personal org.`);
89
+ }
90
+ /** The not-a-member 403 code emitted by the merged endpoint. */
91
+ const NOT_A_MEMBER_CODE = "agent_org_not_member";
92
+ /**
93
+ * Distinguish a not-a-member 403 (the requested org isn't one of yours) from
94
+ * the other 403s this endpoint emits — `invalid_hash_proof` and
95
+ * `agent_cross_tenant` (already claimed by a different owner) — which should
96
+ * fall through to the generic error. Source-verified against #713: the
97
+ * not-a-member 403 carries `code: "agent_org_not_member"` and
98
+ * `details.claimable_orgs`. A loose match guards against minor drift.
99
+ */
100
+ function isNotAMemberError(err) {
101
+ if (err.code === NOT_A_MEMBER_CODE)
102
+ return true;
103
+ if (extractClaimableOrgs(err.details).length > 0)
104
+ return true;
105
+ return /org_not_member|not_a_member|not_org_member/.test(err.code ?? "");
106
+ }
107
+ /** Pull the server-provided claimable-org list off a 403 body, if present. */
108
+ function extractClaimableOrgs(details) {
109
+ const list = details?.claimable_orgs;
110
+ if (!Array.isArray(list))
111
+ return [];
112
+ return list.filter((o) => !!o && typeof o === "object" && typeof o.org_id === "string");
113
+ }
114
+ /**
115
+ * `mnemom agents claim <id-or-name> [--org <slug>] (--key <k> | --hash-proof <h>)`
116
+ *
117
+ * Claims an agent into an org (ADR-062). Possession of the agent's key (via
118
+ * `--key`, from which the CLI derives the proof) or a pre-computed
119
+ * `--hash-proof` authenticates the claim; `--org` selects the landing org
120
+ * (omit → your personal org). On a not-a-member 403 the command teaches by
121
+ * listing your claimable orgs.
122
+ */
123
+ export async function agentsClaimCommand(idOrName, options = {}) {
124
+ await requireAuth();
125
+ // 1. Resolve the agent id to claim. An UNCLAIMED agent is not in your org
126
+ // fleet yet, so a name can only be resolved for agents you can already
127
+ // see; the reliable path for a fresh claim is to pass the agent id.
128
+ const looksLikeId = AGENT_ID_RE.test(idOrName);
129
+ let agentId = idOrName;
130
+ let derivationName = options.name;
131
+ if (!looksLikeId) {
132
+ // A name-shaped arg: try the fleet, else guide the user to the id.
133
+ const found = await getAgentByName(idOrName).catch(() => null);
134
+ if (found) {
135
+ agentId = found.id;
136
+ derivationName = derivationName ?? found.name ?? idOrName;
137
+ }
138
+ else {
139
+ // The arg names an agent we can't see yet (unclaimed agents aren't in
140
+ // your fleet), so we have no id to address the claim — guide to the id.
141
+ console.log(fmt.error(`Could not resolve '${idOrName}' to an agent id you can see yet — unclaimed agents are not in your fleet.`));
142
+ console.log(fmt.dim(` Pass the agent id directly: mnemom agents claim mnm-... --key <key> [--name ${idOrName}]`) + "\n");
143
+ process.exit(1);
144
+ return;
145
+ }
146
+ }
147
+ // 2. Establish the hash proof: --hash-proof (raw) takes precedence, else
148
+ // derive from --key. Exactly one source is needed.
149
+ let hashProof;
150
+ if (options.hashProof) {
151
+ const proof = options.hashProof.trim().toLowerCase();
152
+ if (!FULL_PROOF_RE.test(proof)) {
153
+ console.log(fmt.error("--hash-proof should be the full 64-character hex SHA-256 proof.") + "\n");
154
+ process.exit(1);
155
+ return;
156
+ }
157
+ hashProof = proof;
158
+ }
159
+ else if (options.key) {
160
+ hashProof = deriveHashProof(options.key, derivationName);
161
+ }
162
+ else {
163
+ console.log(fmt.error("Claiming needs proof you hold the agent's key.") + "\n");
164
+ console.log(fmt.dim(" Pass --key <agent-api-key> (the CLI derives the proof), or --hash-proof <64-hex> directly.") + "\n");
165
+ process.exit(1);
166
+ return;
167
+ }
168
+ // 3. Resolve --org slug → org_id (omit → server default / personal org).
169
+ let orgId;
170
+ let orgs = [];
171
+ if (options.org) {
172
+ try {
173
+ orgs = await listMyOrgs();
174
+ }
175
+ catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ console.log(fmt.error(`Failed to read your orgs: ${msg}`) + "\n");
178
+ process.exit(1);
179
+ return;
180
+ }
181
+ try {
182
+ orgId = resolveOrgId(options.org, orgs);
183
+ }
184
+ catch (err) {
185
+ const msg = err instanceof Error ? err.message : String(err);
186
+ console.log(fmt.error(msg) + "\n");
187
+ process.exit(1);
188
+ return;
189
+ }
190
+ }
191
+ // 4. Claim.
192
+ let result;
193
+ try {
194
+ result = await claimAgent(agentId, { hashProof, orgId });
195
+ }
196
+ catch (err) {
197
+ if (err instanceof MnemomApiError && err.effectiveStatus === 403 && isNotAMemberError(err)) {
198
+ // Teaching error: list the orgs the caller can actually claim into.
199
+ // Prefer `mnemom org list` data (listMyOrgs) — it carries slugs, so we
200
+ // can print the actionable `--org <slug>`. The server's
201
+ // `details.claimable_orgs` (org_id + name, NO slug) is the fallback when
202
+ // the membership list is unavailable.
203
+ const memberOrgs = orgs.length ? orgs : await listMyOrgs().catch(() => []);
204
+ console.log(fmt.error(`You are not a member of '${options.org}', so the agent can't land there.`));
205
+ if (memberOrgs.length) {
206
+ console.log("\n You can claim into one of (use the slug):");
207
+ for (const o of memberOrgs) {
208
+ const tag = o.is_personal ? " (personal)" : "";
209
+ console.log(` ${o.slug.padEnd(24)} ${o.name}${tag}`);
210
+ }
211
+ console.log(`\n Re-run with --org <slug>, or omit --org to land in your personal org.\n`);
212
+ }
213
+ else {
214
+ const fromServer = extractClaimableOrgs(err.details);
215
+ if (fromServer.length) {
216
+ console.log("\n You can claim into one of (use the org id):");
217
+ for (const o of fromServer) {
218
+ const tag = o.is_personal ? " (personal)" : "";
219
+ console.log(` ${o.org_id.padEnd(40)} ${o.name}${tag}`);
220
+ }
221
+ console.log(`\n Re-run with --org <id>, or omit --org to land in your personal org.\n`);
222
+ }
223
+ else {
224
+ console.log(fmt.dim(" Run `mnemom org list` to see the orgs you belong to.") + "\n");
225
+ }
226
+ }
227
+ process.exit(1);
228
+ return;
229
+ }
230
+ if (err instanceof MnemomApiError && err.effectiveStatus === 503) {
231
+ // The platform is still provisioning the caller's personal org. Transient.
232
+ console.log(fmt.warn("Your personal org is still being set up. Give it a moment and run claim again."));
233
+ console.log("");
234
+ process.exit(1);
235
+ return;
236
+ }
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ console.log(fmt.error(`Failed to claim agent: ${msg}`) + "\n");
239
+ process.exit(1);
240
+ return;
241
+ }
242
+ // 5. Report where it landed.
243
+ if (options.json) {
244
+ console.log(JSON.stringify(result, null, 2));
245
+ return;
246
+ }
247
+ // Map the resolved org_id to a friendly name when we can.
248
+ const landed = result.org_id;
249
+ let where;
250
+ if (!landed) {
251
+ where = "your personal org (default scope)";
252
+ }
253
+ else {
254
+ const named = (orgs.length ? orgs : await listMyOrgs().catch(() => [])).find((o) => o.org_id === landed);
255
+ where = named ? `${named.name} (${named.slug})` : landed;
256
+ }
257
+ console.log(fmt.success(`Claimed ${result.agent_id}.`));
258
+ console.log(fmt.label(" Landed in:", where));
259
+ if (result.claimed_at) {
260
+ console.log(fmt.label(" Claimed at:", new Date(result.claimed_at).toLocaleString()));
261
+ }
262
+ console.log();
263
+ }
@@ -29,7 +29,10 @@ export declare function cardShowCommand(agentName?: string): Promise<void>;
29
29
  export declare function cardPublishCommand(file: string, agentName?: string, options?: {
30
30
  idempotencyKey?: string;
31
31
  }): Promise<void>;
32
- export declare function cardValidateCommand(file: string): Promise<void>;
32
+ export declare function cardValidateCommand(file: string, opts?: {
33
+ offline?: boolean;
34
+ agent?: string;
35
+ }): Promise<void>;
33
36
  export declare function cardEditCommand(agentName?: string, options?: {
34
37
  idempotencyKey?: string;
35
38
  }): Promise<void>;
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import * as os from "node:os";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import yaml from "js-yaml";
6
- import { ALIGNMENT_CARD_MAX_BYTES, getAlignmentCard, putAlignmentCard, resolveAgentId, } from "../lib/api.js";
6
+ import { ALIGNMENT_CARD_MAX_BYTES, getAlignmentCard, putAlignmentCard, resolveAgentId, getAgentByName, previewComposeAgentCard, MnemomApiError, } from "../lib/api.js";
7
7
  import { requireAuth } from "../lib/auth.js";
8
8
  import { fmt } from "../lib/format.js";
9
9
  import { askYesNo, isInteractive } from "../lib/prompt.js";
@@ -194,6 +194,28 @@ export function validateUnifiedCard(card) {
194
194
  else {
195
195
  const v = card.values;
196
196
  const decl = v.declared;
197
+ // A declared entry is a non-empty STRING or a parameterized OBJECT
198
+ // { id, ...string params } — mirrors mnemom-api/src/composition/validate.ts
199
+ // `declaredValueRefId`. (#8 DELTA-1: the old `decl.every(typeof === "string")`
200
+ // rejected the valid object form, making the CLI STRICTER than the server —
201
+ // a false-negative that blocked good cards in pre-flight.)
202
+ const declRefId = (entry) => {
203
+ if (typeof entry === "string")
204
+ return entry.length > 0 ? entry : null;
205
+ if (!isObj(entry))
206
+ return null;
207
+ const o = entry;
208
+ if (typeof o.id !== "string" || o.id.length === 0)
209
+ return null;
210
+ for (const [k, pv] of Object.entries(o)) {
211
+ if (k === "id")
212
+ continue;
213
+ if (typeof pv !== "string")
214
+ return null;
215
+ }
216
+ return o.id;
217
+ };
218
+ const declIds = [];
197
219
  if (decl.length === 0) {
198
220
  checks.push({
199
221
  name: "values.declared",
@@ -201,21 +223,32 @@ export function validateUnifiedCard(card) {
201
223
  message: "Must contain at least one value.",
202
224
  });
203
225
  }
204
- else if (!decl.every((s) => typeof s === "string")) {
205
- checks.push({
206
- name: "values.declared",
207
- passed: false,
208
- message: "All entries must be strings.",
209
- });
210
- }
211
226
  else {
212
- checks.push({
213
- name: "values.declared",
214
- passed: true,
215
- message: `${decl.length} value(s) declared`,
227
+ let badRef = false;
228
+ decl.forEach((entry, i) => {
229
+ const id = declRefId(entry);
230
+ if (id === null) {
231
+ checks.push({
232
+ name: `values.declared[${i}]`,
233
+ passed: false,
234
+ message: "Must be a non-empty string or an object with a non-empty string `id` and string-only parameters.",
235
+ });
236
+ badRef = true;
237
+ }
238
+ else {
239
+ declIds.push(id);
240
+ }
216
241
  });
242
+ if (!badRef) {
243
+ checks.push({
244
+ name: "values.declared",
245
+ passed: true,
246
+ message: `${decl.length} value(s) declared`,
247
+ });
248
+ }
217
249
  }
218
- // definitions ⊆ declared (ADR-039 Decision 10)
250
+ // definitions ⊆ declared (ADR-039 Decision 10) — build the subset Set from
251
+ // the RESOLVED declared ids (string entries + each object's `id`).
219
252
  if (v.definitions !== undefined) {
220
253
  if (!isObj(v.definitions)) {
221
254
  checks.push({
@@ -225,7 +258,7 @@ export function validateUnifiedCard(card) {
225
258
  });
226
259
  }
227
260
  else {
228
- const declSet = new Set(decl.filter((s) => typeof s === "string"));
261
+ const declSet = new Set(declIds);
229
262
  for (const key of Object.keys(v.definitions)) {
230
263
  if (!declSet.has(key)) {
231
264
  checks.push({
@@ -626,7 +659,27 @@ export async function cardPublishCommand(file, agentName, options = {}) {
626
659
  process.exit(1);
627
660
  }
628
661
  }
629
- export async function cardValidateCommand(file) {
662
+ const AGENT_ID_RE = /^(smolt-[0-9a-f]{8}|mnm-[0-9a-f-]{36})$/;
663
+ /**
664
+ * Soft agent resolution for `validate`: returns an agent id WITHOUT exiting the
665
+ * process (unlike resolveAgentId). Returns null when no agent is configured, or
666
+ * the name can't be resolved (not authenticated / not found) — the caller then
667
+ * falls back to offline validation.
668
+ */
669
+ async function softResolveAgentId(agent) {
670
+ const name = agent ?? process.env.MNEMOM_AGENT;
671
+ if (!name)
672
+ return null;
673
+ if (AGENT_ID_RE.test(name))
674
+ return name;
675
+ try {
676
+ return (await getAgentByName(name))?.id ?? null;
677
+ }
678
+ catch {
679
+ return null;
680
+ }
681
+ }
682
+ export async function cardValidateCommand(file, opts = {}) {
630
683
  // Resolve file path
631
684
  const filePath = path.resolve(file);
632
685
  if (!fs.existsSync(filePath)) {
@@ -643,7 +696,35 @@ export async function cardValidateCommand(file) {
643
696
  console.log("\n" + fmt.error(`Could not parse file: ${msg}`) + "\n");
644
697
  process.exit(1);
645
698
  }
646
- // Validate the parsed object
699
+ // Prefer server-authoritative validation (composes against the agent's
700
+ // org/platform floor — catches conflicts the offline validator cannot) when
701
+ // online + an agent is available. Fall back to the local validator on
702
+ // 401/network. `--offline` forces local-only (#9).
703
+ if (!opts.offline) {
704
+ const agentId = await softResolveAgentId(opts.agent);
705
+ if (agentId) {
706
+ const contentType = parsed.format === "yaml" ? "text/yaml" : "application/json";
707
+ try {
708
+ const result = await previewComposeAgentCard(agentId, "alignment", parsed.raw, contentType);
709
+ renderServerCardValidation(result, filePath, parsed.format);
710
+ return;
711
+ }
712
+ catch (err) {
713
+ if (err instanceof MnemomApiError && err.status !== 401) {
714
+ // 403 / 5xx — a genuine server error, not the offline-fallback case.
715
+ console.log("\n" + fmt.error(`Server validation failed: ${err.message}`) + "\n");
716
+ process.exit(1);
717
+ }
718
+ // 401 or network error → fall through to offline validation.
719
+ process.stderr.write(fmt.warn("offline validation — server rules may differ") + "\n");
720
+ }
721
+ }
722
+ else if (!opts.agent && !process.env.MNEMOM_AGENT) {
723
+ process.stderr.write(fmt.dim("tip: pass --agent <name> to validate against the server (org/platform floor)") +
724
+ "\n");
725
+ }
726
+ }
727
+ // Offline / fallback path: the local hand-rolled validator (also `--offline`).
647
728
  const checks = validateUnifiedCard(parsed.parsed);
648
729
  const allPassed = checks.every((c) => c.passed);
649
730
  const passCount = checks.filter((c) => c.passed).length;
@@ -670,6 +751,38 @@ export async function cardValidateCommand(file) {
670
751
  process.exit(1);
671
752
  }
672
753
  }
754
+ /** Render a server-authoritative preview-compose result; exit 1 if invalid. */
755
+ function renderServerCardValidation(result, filePath, format) {
756
+ console.log(fmt.header("Card Validation (server-authoritative)"));
757
+ console.log();
758
+ console.log(fmt.label(" File:", ` ${filePath}`));
759
+ console.log(fmt.label(" Format:", ` ${format.toUpperCase()}`));
760
+ console.log();
761
+ if (result.valid) {
762
+ console.log(fmt.success("Server accepted and composed the card."));
763
+ const conflicts = result.conflicts ?? [];
764
+ if (conflicts.length > 0) {
765
+ console.log(fmt.warn(`${conflicts.length} field(s) tightened by the org/platform floor:`));
766
+ console.log(fmt.json(conflicts));
767
+ }
768
+ const coherence = result.coherence_violations ?? [];
769
+ if (coherence.length > 0) {
770
+ console.log(fmt.warn(`${coherence.length} coherence finding(s):`));
771
+ console.log(fmt.json(coherence));
772
+ }
773
+ console.log();
774
+ return;
775
+ }
776
+ console.log(fmt.error(`Server rejected the card${result.error?.code ? ` (${result.error.code})` : ""}:`));
777
+ if (result.error?.message)
778
+ console.log(` ${result.error.message}`);
779
+ if (result.error?.details !== undefined) {
780
+ console.log();
781
+ console.log(fmt.json(result.error.details));
782
+ }
783
+ console.log();
784
+ process.exit(1);
785
+ }
673
786
  export async function cardEditCommand(agentName, options = {}) {
674
787
  const agentId = await resolveAgentId(agentName);
675
788
  await requireAuth();
@@ -694,9 +807,11 @@ export async function cardEditCommand(agentName, options = {}) {
694
807
  },
695
808
  audit: { retention_days: 30, queryable: false, trace_format: "otel" },
696
809
  }, { lineWidth: 120, noRefs: true });
697
- // Write to temp file
698
- const tmpDir = os.tmpdir();
699
- const tmpFile = path.join(tmpDir, `mnemom-card-${agentId}.yaml`);
810
+ // Write to a per-invocation temp dir created with mkdtemp (mode 0700,
811
+ // unpredictable suffix) so the editor file can't be pre-created or
812
+ // symlink-raced by another user in the shared os.tmpdir().
813
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnemom-card-"));
814
+ const tmpFile = path.join(tmpDir, `${agentId}.yaml`);
700
815
  fs.writeFileSync(tmpFile, cardYaml);
701
816
  // Open in editor
702
817
  const editor = process.env.EDITOR || process.env.VISUAL || "vi";
@@ -1,4 +1,4 @@
1
- import { resolveAgentId, getIntegrity } from "../lib/api.js";
1
+ import { resolveAgentId, getIntegrity, MnemomApiError } from "../lib/api.js";
2
2
  import { fmt } from "../lib/format.js";
3
3
  export async function integrityCommand(agentName) {
4
4
  const agentId = await resolveAgentId(agentName);
@@ -25,8 +25,11 @@ export async function integrityCommand(agentName) {
25
25
  }
26
26
  }
27
27
  catch (error) {
28
- const message = error instanceof Error ? error.message : String(error);
29
- if (message.includes("404") || message.includes("not found")) {
28
+ // A 404 means no integrity record exists yet — render the friendly empty state.
29
+ // Branch on effectiveStatus (not status): the enforce hook rewrites an
30
+ // undocumented 404 to a synthetic 500 carrying spec_deviation.original_status,
31
+ // and effectiveStatus surfaces the true status (=== status when documented).
32
+ if (error instanceof MnemomApiError && error.effectiveStatus === 404) {
30
33
  console.log(fmt.header("Integrity Score"));
31
34
  console.log(` ${fmt.label("Score: ", "N/A")}`);
32
35
  console.log(` ${fmt.label("Total: ", "0 traces")}`);
@@ -35,6 +38,7 @@ export async function integrityCommand(agentName) {
35
38
  console.log("\nNo traces recorded yet. Start using Claude to build your integrity score.\n");
36
39
  }
37
40
  else {
41
+ const message = error instanceof Error ? error.message : String(error);
38
42
  console.log("\n" + fmt.error(`Failed to fetch integrity score: ${message}`) + "\n");
39
43
  process.exit(1);
40
44
  }
@@ -1,10 +1,7 @@
1
1
  import { getLicenseJwt, saveLicenseJwt, clearLicenseJwt } from "../lib/auth.js";
2
- import { API_BASE } from "../lib/api.js";
2
+ import { validateLicense, MnemomApiError } from "../lib/api.js";
3
3
  import { fmt } from "../lib/format.js";
4
- /** Sanitize file-sourced data before use in outbound HTTP requests. */
5
- function sanitizeForHttp(data) {
6
- return String(data).trim();
7
- }
4
+ import { CLI_VERSION } from "../version.js";
8
5
  /**
9
6
  * Decode a JWT payload without verifying the signature.
10
7
  */
@@ -34,39 +31,37 @@ export async function licenseActivateCommand(jwt) {
34
31
  process.exit(1);
35
32
  }
36
33
  console.log("\nActivating enterprise license...\n");
37
- // Validate against API (if reachable)
34
+ // Validate against the API via the canonical lib helper (UNAUTH by design —
35
+ // the JWT is the credential). validateLicense throws MnemomApiError on non-2xx,
36
+ // so we render `.message` (the old hand-rolled `err.error` string-coerced the
37
+ // nested {code,message} envelope object → the live "[object Object]" bug).
38
38
  const hostname = (await import("node:os")).hostname();
39
39
  try {
40
- const licenseUrl = new URL(`${API_BASE}/v1/license/validate`).href;
41
- const response = await fetch(licenseUrl, {
42
- method: "POST",
43
- headers: { "Content-Type": "application/json" },
44
- body: JSON.stringify({
45
- license: jwt,
46
- instance_id: hostname,
47
- instance_metadata: {
48
- hostname,
49
- platform: process.platform,
50
- cli_version: "2.1.0",
51
- },
52
- }),
40
+ const result = await validateLicense(jwt, hostname, {
41
+ hostname,
42
+ platform: process.platform,
43
+ cli_version: CLI_VERSION,
53
44
  });
54
- if (response.ok) {
55
- const result = (await response.json());
45
+ if (result.valid) {
56
46
  console.log(" License validated successfully!\n");
57
- if (result.warning) {
58
- console.log(` Warning: ${result.warning}\n`);
59
- }
60
47
  }
61
48
  else {
62
- const err = (await response.json());
63
- console.log(` Warning: Validation returned ${response.status}: ${err.error || "unknown"}`);
64
- console.log(" License stored locally (will retry validation).\n");
49
+ // 2xx body with valid:false — grace period / activation limit reached.
50
+ console.log(" Note: server returned valid:false (grace period or activation limit).\n");
51
+ }
52
+ if (result.warning) {
53
+ console.log(` Warning: ${result.warning}\n`);
65
54
  }
66
55
  }
67
- catch {
68
- console.log(" Warning: Could not reach API for validation.");
69
- console.log(" License stored locally (offline mode).\n");
56
+ catch (err) {
57
+ if (err instanceof MnemomApiError) {
58
+ console.log(` Warning: Validation returned ${err.status}: ${err.message}`);
59
+ console.log(" License stored locally (will retry validation).\n");
60
+ }
61
+ else {
62
+ console.log(" Warning: Could not reach API for validation.");
63
+ console.log(" License stored locally (offline mode).\n");
64
+ }
70
65
  }
71
66
  // Store in auth store
72
67
  saveLicenseJwt(jwt);
@@ -131,24 +126,17 @@ export async function licenseDeactivateCommand() {
131
126
  console.log("\nNo enterprise license to deactivate.\n");
132
127
  return;
133
128
  }
134
- // Try to deactivate via API
129
+ // Try to deactivate via API (best-effort, fire-and-forget — reuses the
130
+ // validate endpoint with deactivating:true; local removal below is the source
131
+ // of truth). Ignore both the result and any MnemomApiError.
135
132
  const claims = decodeJwtPayload(licenseJwt);
136
133
  if (claims) {
137
134
  try {
138
135
  const hostname = (await import("node:os")).hostname();
139
- const deactivateUrl = new URL(`${API_BASE}/v1/license/validate`).href;
140
- await fetch(deactivateUrl, {
141
- method: "POST",
142
- headers: { "Content-Type": "application/json" },
143
- body: sanitizeForHttp(JSON.stringify({
144
- license: String(licenseJwt),
145
- instance_id: hostname,
146
- instance_metadata: { deactivating: true },
147
- })),
148
- });
136
+ await validateLicense(String(licenseJwt), hostname, { deactivating: true });
149
137
  }
150
138
  catch {
151
- // Best-effort
139
+ // Best-effort — ignore.
152
140
  }
153
141
  }
154
142
  // Remove from auth store