@mnemom/mnemom 0.13.0 → 0.14.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.
@@ -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
+ }
@@ -807,9 +807,11 @@ export async function cardEditCommand(agentName, options = {}) {
807
807
  },
808
808
  audit: { retention_days: 30, queryable: false, trace_format: "otel" },
809
809
  }, { lineWidth: 120, noRefs: true });
810
- // Write to temp file
811
- const tmpDir = os.tmpdir();
812
- 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`);
813
815
  fs.writeFileSync(tmpFile, cardYaml);
814
816
  // Open in editor
815
817
  const editor = process.env.EDITOR || process.env.VISUAL || "vi";
@@ -578,8 +578,11 @@ export async function protectionEditCommand(agentName, options = {}) {
578
578
  },
579
579
  trusted_sources: { domains: [], agent_ids: [], ip_ranges: [] },
580
580
  }, { lineWidth: 120, noRefs: true });
581
- const tmpDir = os.tmpdir();
582
- const tmpFile = path.join(tmpDir, `mnemom-protection-${agentId}.yaml`);
581
+ // Per-invocation temp dir via mkdtemp (mode 0700, unpredictable suffix) so
582
+ // the editor file can't be pre-created or symlink-raced by another user in
583
+ // the shared os.tmpdir().
584
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnemom-protection-"));
585
+ const tmpFile = path.join(tmpDir, `${agentId}.yaml`);
583
586
  fs.writeFileSync(tmpFile, cardYaml);
584
587
  const editor = process.env.EDITOR || process.env.VISUAL || "vi";
585
588
  console.log(`Opening ${editor}...`);
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { program } from "commander";
3
+ export { program };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from "commander";
3
+ import { isEntrypoint } from "./lib/entrypoint.js";
3
4
  import { CLI_VERSION } from "./version.js";
4
5
  import { statusCommand } from "./commands/status.js";
5
6
  import { integrityCommand } from "./commands/integrity.js";
@@ -8,7 +9,7 @@ import { licenseActivateCommand, licenseStatusCommand, licenseDeactivateCommand,
8
9
  import { cardShowCommand, cardPublishCommand, cardValidateCommand, cardEditCommand, cardEvaluateCommand, } from "./commands/card.js";
9
10
  import { policyInitCommand, policyValidateCommand, policyPublishCommand, policyListCommand, policyTestCommand, policyEvaluateCommand, } from "./commands/policy.js";
10
11
  import { protectionShowCommand, protectionPublishCommand, protectionValidateCommand, protectionEditCommand, } from "./commands/protection.js";
11
- import { agentsListCommand } from "./commands/agents.js";
12
+ import { agentsListCommand, agentsClaimCommand } from "./commands/agents.js";
12
13
  import { orgListCommand, orgShowCommand } from "./commands/org.js";
13
14
  import { teamListCommand, teamShowCommand, teamTemplateCommand, teamPreviewComposeCommand, teamAdminGrantCommand, teamAdminRevokeCommand, teamAdminListCommand, teamCoverageCommand, } from "./commands/team.js";
14
15
  import { advisoriesListCommand, advisoriesShowCommand } from "./commands/advisories.js";
@@ -295,12 +296,36 @@ policyCmd
295
296
  // ============================================================================
296
297
  // Agent management
297
298
  // ============================================================================
298
- program
299
+ const agentsCmd = program
299
300
  .command("agents")
300
- .description("List agents in your account")
301
- .action(async () => {
301
+ .description("List agents across the orgs you belong to (ADR-062 org-scoped)")
302
+ .option("--org <id>", "Scope to a single org (default: all orgs you belong to)")
303
+ .action(async (opts) => {
302
304
  try {
303
- await agentsListCommand();
305
+ await agentsListCommand({ org: opts.org });
306
+ }
307
+ catch (error) {
308
+ console.error("Error:", error instanceof Error ? error.message : error);
309
+ process.exit(1);
310
+ }
311
+ });
312
+ agentsCmd
313
+ .command("claim <id-or-name>")
314
+ .description("Claim an agent into an org (ADR-062 claim-to-org)")
315
+ .option("--org <slug>", "Org slug or id to claim into (default: your personal org)")
316
+ .option("--key <key>", "The agent's provider API key — the CLI derives the hash proof from it")
317
+ .option("--hash-proof <hex>", "A pre-computed 64-hex SHA-256 proof (advanced/CI; alternative to --key)")
318
+ .option("--name <name>", "Provisioned agent name used in proof derivation (defaults to a name-shaped positional arg)")
319
+ .option("--json", "Output JSON instead of human-readable text")
320
+ .action(async (idOrName, opts) => {
321
+ try {
322
+ await agentsClaimCommand(idOrName, {
323
+ org: opts.org,
324
+ key: opts.key,
325
+ hashProof: opts.hashProof,
326
+ name: opts.name,
327
+ json: opts.json,
328
+ });
304
329
  }
305
330
  catch (error) {
306
331
  console.error("Error:", error instanceof Error ? error.message : error);
@@ -1296,4 +1321,13 @@ program
1296
1321
  process.exit(1);
1297
1322
  }
1298
1323
  });
1299
- program.parse();
1324
+ // Export the fully-assembled commander program so tooling (e.g. the
1325
+ // command-tree snapshot generator in scripts/gen-command-tree.mjs) can
1326
+ // statically introspect the command surface WITHOUT executing the CLI.
1327
+ export { program };
1328
+ // Parse argv only when invoked as the CLI entrypoint — not when imported.
1329
+ // The symlink-safe guard lives in ./lib/entrypoint so it can be unit-tested
1330
+ // without executing this module's top-level parse().
1331
+ if (isEntrypoint(import.meta.url, process.argv[1])) {
1332
+ program.parse();
1333
+ }
package/dist/lib/api.d.ts CHANGED
@@ -207,6 +207,13 @@ export interface AgentListItem {
207
207
  containment_status: string | null;
208
208
  key_prefix?: string | null;
209
209
  }
210
+ /**
211
+ * @deprecated Legacy per-user listing — `GET /v1/agents` is scoped by the
212
+ * caller's `claimed_by` rows, which ADR-062 retired as an authorization /
213
+ * listing boundary (org_id is now the sole boundary). New code must list via
214
+ * {@link listOrgAgents} (org-scoped). Retained only for back-compat callers;
215
+ * the `mnemom agents` command and name resolution no longer use it.
216
+ */
210
217
  export declare function listAgents(): Promise<AgentListItem[]>;
211
218
  export interface OrgListItem {
212
219
  org_id: string;
@@ -231,6 +238,85 @@ export interface PersonalOrgRef {
231
238
  * first; multi-user orgs follow.
232
239
  */
233
240
  export declare function listMyOrgs(): Promise<OrgListItem[]>;
241
+ /**
242
+ * One agent row from the org-scoped fleet (`get_org_agent_fleet`). Mirrors the
243
+ * website's `OrgFleetAgent` shape so the CLI and dashboard read the same
244
+ * surface. This is the ADR-062-canonical listing: every agent has exactly one
245
+ * governing org, and the fleet is keyed on `org_id` (not `claimed_by`).
246
+ */
247
+ export interface OrgFleetAgent {
248
+ agent_id: string;
249
+ agent_name: string;
250
+ owner_email: string | null;
251
+ last_seen: string | null;
252
+ created_at: string;
253
+ integrity_score: number;
254
+ coverage_ratio: number;
255
+ latest_verdict: string | null;
256
+ active_drift_alerts: number;
257
+ worst_drift_severity: string | null;
258
+ check_count: number;
259
+ containment_status?: string | null;
260
+ avatar_url?: string | null;
261
+ }
262
+ /**
263
+ * GET /v1/orgs/:org_id/agents — the org-scoped agent fleet (ADR-062). Any
264
+ * member of the org may read it; non-members get 403.
265
+ */
266
+ export declare function fetchOrgFleet(orgId: string): Promise<OrgFleetAgent[]>;
267
+ /** An {@link AgentListItem} tagged with the org it belongs to. */
268
+ export interface OrgAgentRow extends AgentListItem {
269
+ org_id: string;
270
+ org_name: string;
271
+ }
272
+ /**
273
+ * List agents the ADR-062 way: scoped by org membership, never by `claimed_by`.
274
+ *
275
+ * - With `orgId`: lists that single org's fleet.
276
+ * - Without `orgId`: aggregates the fleet across every org the caller belongs
277
+ * to (the same pattern as {@link listAllTeams}), tagging each row with its
278
+ * org. Orgs the caller can't read (403/404) are skipped silently so one
279
+ * inaccessible org doesn't fail the whole listing.
280
+ */
281
+ export declare function listOrgAgents(orgId?: string): Promise<OrgAgentRow[]>;
282
+ /**
283
+ * Result of a successful agent claim (ADR-062). Mirrors the locked
284
+ * `POST /v1/agents/:id/claim` 200 body verbatim — only the fields the
285
+ * endpoint actually returns. `org_id` is the org the agent ACTUALLY landed
286
+ * in (resolved server-side: the requested org, or the caller's personal org
287
+ * when none was supplied), so the CLI can confirm placement without a
288
+ * follow-up read. Optional/defaulted on the type so a mid-deploy CLI pointed
289
+ * at an older API that omits a field degrades to a sane value rather than
290
+ * crashing.
291
+ */
292
+ export interface ClaimAgentResult {
293
+ claimed: boolean;
294
+ agent_id: string;
295
+ /** Resolved landing org (may differ from the request); null if unscoped. */
296
+ org_id: string | null;
297
+ /** ISO-8601 claim timestamp; null if the server omits it. */
298
+ claimed_at: string | null;
299
+ }
300
+ /**
301
+ * POST /v1/agents/:id/claim — claim an agent into an org (ADR-062).
302
+ *
303
+ * Body is `{ hash_proof, org_id? }`. `hash_proof` (the agent's full SHA-256
304
+ * proof) authenticates the caller as the agent's owner; the user's own auth
305
+ * header rides along too so the platform can validate org membership and link
306
+ * the principal. Omit `orgId` to land in the caller's personal org (the
307
+ * platform default); a supplied `orgId` is validated against the caller's
308
+ * memberships server-side (a non-member gets a 403 the command layer turns
309
+ * into a teaching error listing claimable orgs).
310
+ *
311
+ * An Idempotency-Key is minted (and held across the 401-refresh retry) so a
312
+ * retried claim replays the same logical operation server-side.
313
+ */
314
+ export declare function claimAgent(agentId: string, body: {
315
+ hashProof: string;
316
+ orgId?: string;
317
+ }, opts?: {
318
+ idempotencyKey?: string;
319
+ }): Promise<ClaimAgentResult>;
234
320
  /**
235
321
  * GET /v1/auth/me/personal-org — accessor for the user's personal org.
236
322
  * Idempotent: lazily provisions for legacy accounts that pre-date the
@@ -369,11 +455,11 @@ export declare function revokeTeamAdmin(teamId: string, userId: string): Promise
369
455
  */
370
456
  export declare function listTeamAdmins(teamId: string): Promise<TeamAdminListResponse>;
371
457
  /**
372
- * Look up an agent in the authenticated user's account by name.
458
+ * Look up an agent by name across the caller's org fleets (ADR-062-scoped via
459
+ * {@link listOrgAgents}, not the legacy `claimed_by` list).
373
460
  * Tries exact match first, then single partial match.
374
461
  * Throws if multiple agents partially match (ambiguous).
375
462
  * Returns null if no match found.
376
- * Note: capped at 100 agents by listAgents().
377
463
  */
378
464
  export declare function getAgentByName(name: string): Promise<AgentListItem | null>;
379
465
  /**
package/dist/lib/api.js CHANGED
@@ -205,6 +205,13 @@ async function fetchWithAuthRetry(url, buildInit) {
205
205
  export async function getAgent(id) {
206
206
  return fetchApi(`/v1/agents/${id}`);
207
207
  }
208
+ /**
209
+ * @deprecated Legacy per-user listing — `GET /v1/agents` is scoped by the
210
+ * caller's `claimed_by` rows, which ADR-062 retired as an authorization /
211
+ * listing boundary (org_id is now the sole boundary). New code must list via
212
+ * {@link listOrgAgents} (org-scoped). Retained only for back-compat callers;
213
+ * the `mnemom agents` command and name resolution no longer use it.
214
+ */
208
215
  export async function listAgents() {
209
216
  const url = validateUrl(`${API_BASE}/v1/agents?limit=100`);
210
217
  const response = await fetchWithAuthRetry(url, async () => ({
@@ -238,6 +245,127 @@ export async function listMyOrgs() {
238
245
  const data = (await response.json());
239
246
  return data.orgs ?? [];
240
247
  }
248
+ /**
249
+ * GET /v1/orgs/:org_id/agents — the org-scoped agent fleet (ADR-062). Any
250
+ * member of the org may read it; non-members get 403.
251
+ */
252
+ export async function fetchOrgFleet(orgId) {
253
+ const url = validateUrl(`${API_BASE}/v1/orgs/${encodeURIComponent(orgId)}/agents`);
254
+ const response = await fetchWithAuthRetry(url, async () => ({
255
+ headers: { ...(await authHeaders()), Accept: "application/json" },
256
+ }));
257
+ if (!response.ok) {
258
+ if (response.status === 401) {
259
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
260
+ }
261
+ throw await readApiError(response, "Failed to list org agents");
262
+ }
263
+ const data = (await response.json());
264
+ return data.agents ?? [];
265
+ }
266
+ function fleetToRow(a, org) {
267
+ return {
268
+ id: a.agent_id,
269
+ name: a.agent_name ?? null,
270
+ email: a.owner_email ?? null,
271
+ created_at: a.created_at,
272
+ last_seen: a.last_seen ?? null,
273
+ containment_status: a.containment_status ?? null,
274
+ org_id: org.org_id,
275
+ org_name: org.name,
276
+ };
277
+ }
278
+ /**
279
+ * List agents the ADR-062 way: scoped by org membership, never by `claimed_by`.
280
+ *
281
+ * - With `orgId`: lists that single org's fleet.
282
+ * - Without `orgId`: aggregates the fleet across every org the caller belongs
283
+ * to (the same pattern as {@link listAllTeams}), tagging each row with its
284
+ * org. Orgs the caller can't read (403/404) are skipped silently so one
285
+ * inaccessible org doesn't fail the whole listing.
286
+ */
287
+ export async function listOrgAgents(orgId) {
288
+ let orgs;
289
+ if (orgId) {
290
+ // Resolve a friendly name if the caller is a member; fall back to the id.
291
+ const mine = await listMyOrgs().catch(() => []);
292
+ const match = mine.find((o) => o.org_id === orgId);
293
+ orgs = [{ org_id: orgId, name: match?.name ?? orgId }];
294
+ }
295
+ else {
296
+ orgs = (await listMyOrgs()).map((o) => ({ org_id: o.org_id, name: o.name }));
297
+ }
298
+ const rows = [];
299
+ const seen = new Set();
300
+ for (const org of orgs) {
301
+ let fleet;
302
+ try {
303
+ fleet = await fetchOrgFleet(org.org_id);
304
+ }
305
+ catch (err) {
306
+ // A specific --org that we can't read is a real error worth surfacing;
307
+ // a skipped org during aggregation is not.
308
+ if (orgId)
309
+ throw err;
310
+ continue;
311
+ }
312
+ for (const a of fleet) {
313
+ if (seen.has(a.agent_id))
314
+ continue;
315
+ seen.add(a.agent_id);
316
+ rows.push(fleetToRow(a, org));
317
+ }
318
+ }
319
+ return rows;
320
+ }
321
+ /**
322
+ * POST /v1/agents/:id/claim — claim an agent into an org (ADR-062).
323
+ *
324
+ * Body is `{ hash_proof, org_id? }`. `hash_proof` (the agent's full SHA-256
325
+ * proof) authenticates the caller as the agent's owner; the user's own auth
326
+ * header rides along too so the platform can validate org membership and link
327
+ * the principal. Omit `orgId` to land in the caller's personal org (the
328
+ * platform default); a supplied `orgId` is validated against the caller's
329
+ * memberships server-side (a non-member gets a 403 the command layer turns
330
+ * into a teaching error listing claimable orgs).
331
+ *
332
+ * An Idempotency-Key is minted (and held across the 401-refresh retry) so a
333
+ * retried claim replays the same logical operation server-side.
334
+ */
335
+ export async function claimAgent(agentId, body, opts = {}) {
336
+ const url = validateUrl(`${API_BASE}/v1/agents/${encodeURIComponent(agentId)}/claim`);
337
+ const payload = {
338
+ hash_proof: sanitizeForHttp(body.hashProof),
339
+ };
340
+ if (body.orgId)
341
+ payload.org_id = sanitizeForHttp(body.orgId);
342
+ const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
343
+ const response = await fetchWithAuthRetry(url, async () => ({
344
+ method: "POST",
345
+ headers: {
346
+ ...(await authHeaders()),
347
+ "Content-Type": "application/json",
348
+ Accept: "application/json",
349
+ "Idempotency-Key": idempotencyKey,
350
+ },
351
+ body: JSON.stringify(payload),
352
+ }));
353
+ if (!response.ok) {
354
+ if (response.status === 401) {
355
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
356
+ }
357
+ throw await readApiError(response, "Failed to claim agent");
358
+ }
359
+ const data = (await response.json());
360
+ return {
361
+ claimed: data.claimed ?? true,
362
+ agent_id: data.agent_id ?? agentId,
363
+ // Reflect what the server resolved — null (not "") when absent/unscoped,
364
+ // matching the locked SDK contract so the two surfaces don't drift.
365
+ org_id: data.org_id ?? null,
366
+ claimed_at: data.claimed_at ?? null,
367
+ };
368
+ }
241
369
  /**
242
370
  * GET /v1/auth/me/personal-org — accessor for the user's personal org.
243
371
  * Idempotent: lazily provisions for legacy accounts that pre-date the
@@ -511,14 +639,14 @@ export async function listTeamAdmins(teamId) {
511
639
  return (await response.json());
512
640
  }
513
641
  /**
514
- * Look up an agent in the authenticated user's account by name.
642
+ * Look up an agent by name across the caller's org fleets (ADR-062-scoped via
643
+ * {@link listOrgAgents}, not the legacy `claimed_by` list).
515
644
  * Tries exact match first, then single partial match.
516
645
  * Throws if multiple agents partially match (ambiguous).
517
646
  * Returns null if no match found.
518
- * Note: capped at 100 agents by listAgents().
519
647
  */
520
648
  export async function getAgentByName(name) {
521
- const agents = await listAgents();
649
+ const agents = await listOrgAgents();
522
650
  const lower = name.toLowerCase();
523
651
  const exact = agents.find((a) => a.name?.toLowerCase() === lower);
524
652
  if (exact)
@@ -607,7 +735,10 @@ export async function getTraces(id, limit = 10) {
607
735
  */
608
736
  export async function getAlignmentCard(agentId, format = "yaml") {
609
737
  const accept = format === "yaml" ? "text/yaml" : "application/json";
610
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/alignment-card`);
738
+ // Canonical Resources × Scope × Verb surface (ADR-062 / cards-as-primitive
739
+ // W1.2b). The legacy `/v1/agents/:id/alignment-card` only 308-redirects to
740
+ // this — first-party callers target canonical directly (no legacy shapes).
741
+ const url = validateUrl(`${API_BASE}/v1/alignment/agent/${encodeURIComponent(agentId)}`);
611
742
  const response = await fetch(url, {
612
743
  headers: { Accept: accept, ...(await authHeaders()) },
613
744
  });
@@ -628,7 +759,10 @@ export const ALIGNMENT_CARD_MAX_BYTES = 128 * 1024;
628
759
  * Accepts YAML or JSON body; set contentType accordingly.
629
760
  */
630
761
  export async function putAlignmentCard(agentId, body, contentType = "text/yaml", opts = {}) {
631
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/alignment-card`);
762
+ // Canonical Resources × Scope × Verb surface (ADR-062 / cards-as-primitive
763
+ // W1.2b). The legacy `/v1/agents/:id/alignment-card` only 308-redirects to
764
+ // this — first-party callers target canonical directly (no legacy shapes).
765
+ const url = validateUrl(`${API_BASE}/v1/alignment/agent/${encodeURIComponent(agentId)}`);
632
766
  // Lock in a single Idempotency-Key for this logical mutation. If the auth
633
767
  // token is stale and the first attempt returns 401, fetchWithAuthRetry
634
768
  // refreshes the token and retries — but the Idempotency-Key must be the
@@ -662,7 +796,9 @@ export async function putAlignmentCard(agentId, body, contentType = "text/yaml",
662
796
  */
663
797
  export async function getProtectionCard(agentId, format = "yaml") {
664
798
  const accept = format === "yaml" ? "text/yaml" : "application/json";
665
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/protection-card`);
799
+ // Canonical surface (ADR-062 / W1.2b); legacy `/v1/agents/:id/protection-card`
800
+ // only 308-redirects here. First-party callers use canonical directly.
801
+ const url = validateUrl(`${API_BASE}/v1/protection/agent/${encodeURIComponent(agentId)}`);
666
802
  const response = await fetch(url, {
667
803
  headers: { Accept: accept, ...(await authHeaders()) },
668
804
  });
@@ -682,7 +818,9 @@ export const PROTECTION_CARD_MAX_BYTES = 64 * 1024;
682
818
  * Publish (create or update) a protection card.
683
819
  */
684
820
  export async function putProtectionCard(agentId, body, contentType = "text/yaml", opts = {}) {
685
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/protection-card`);
821
+ // Canonical surface (ADR-062 / W1.2b); legacy `/v1/agents/:id/protection-card`
822
+ // only 308-redirects here. First-party callers use canonical directly.
823
+ const url = validateUrl(`${API_BASE}/v1/protection/agent/${encodeURIComponent(agentId)}`);
686
824
  // See putAlignmentCard for why the Idempotency-Key is computed once outside
687
825
  // the retry closure.
688
826
  const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Decide whether the current module is being run as the CLI entrypoint
3
+ * (so it should `program.parse()`) rather than merely imported.
4
+ *
5
+ * ESM has no `require.main`, so we compare this module's URL to argv[1].
6
+ * The catch: npm installs the global bin as a SYMLINK
7
+ * (…/bin/mnemom → …/dist/index.js). When invoked through that symlink Node
8
+ * sets `process.argv[1]` to the symlink path while `import.meta.url` is the
9
+ * real resolved file — so a naive comparison never matches and the CLI
10
+ * silently no-ops. Canonicalize argv[1] with `realpathSync` before comparing
11
+ * so the symlinked global bin and the real path both resolve to the same URL.
12
+ *
13
+ * `realpathSync` is wrapped in try/catch because argv[1] may be missing or
14
+ * point at a path that no longer exists (ENOENT); in that case we return
15
+ * `false` to preserve the safe default of not auto-running.
16
+ */
17
+ export declare function isEntrypoint(moduleUrl: string, argv1: string | undefined): boolean;
@@ -0,0 +1,28 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
3
+ /**
4
+ * Decide whether the current module is being run as the CLI entrypoint
5
+ * (so it should `program.parse()`) rather than merely imported.
6
+ *
7
+ * ESM has no `require.main`, so we compare this module's URL to argv[1].
8
+ * The catch: npm installs the global bin as a SYMLINK
9
+ * (…/bin/mnemom → …/dist/index.js). When invoked through that symlink Node
10
+ * sets `process.argv[1]` to the symlink path while `import.meta.url` is the
11
+ * real resolved file — so a naive comparison never matches and the CLI
12
+ * silently no-ops. Canonicalize argv[1] with `realpathSync` before comparing
13
+ * so the symlinked global bin and the real path both resolve to the same URL.
14
+ *
15
+ * `realpathSync` is wrapped in try/catch because argv[1] may be missing or
16
+ * point at a path that no longer exists (ENOENT); in that case we return
17
+ * `false` to preserve the safe default of not auto-running.
18
+ */
19
+ export function isEntrypoint(moduleUrl, argv1) {
20
+ if (argv1 === undefined)
21
+ return false;
22
+ try {
23
+ return pathToFileURL(realpathSync(argv1)).href === moduleUrl;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
@@ -2,6 +2,9 @@
2
2
  // smoltbot is deprecated — this shim prints a warning then hands off to mnemom
3
3
  process.stderr.write("\n⚠️ The smoltbot command is deprecated. Use mnemom instead.\n" +
4
4
  " Install: npm install -g @mnemom/mnemom\n\n");
5
- // Dynamic import runs index.ts which calls program.parse(process.argv) automatically
6
- await import("./index.js");
5
+ // argv[1] here is the smoltbot bin path, so index.js's entrypoint guard never
6
+ // fires (it resolves to dist/index.js, not this shim). Import the assembled
7
+ // program and call parse() explicitly instead of relying on its auto-parse.
8
+ const { program } = await import("./index.js");
9
+ program.parse(process.argv);
7
10
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,8 @@
10
10
  "scripts": {
11
11
  "build": "npm install --prefix ../shared/policy-engine && npm run build --prefix ../shared/policy-engine && tsc",
12
12
  "dev": "tsx src/index.ts",
13
+ "gen:command-tree": "tsx scripts/gen-command-tree.mjs",
14
+ "check:command-tree": "tsx scripts/gen-command-tree.mjs --check",
13
15
  "test": "vitest"
14
16
  },
15
17
  "dependencies": {