@aiaiai-pt/martha-cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `@aiaiai-pt/martha-cli`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project adheres to semver — `0.x` releases may include breaking changes between minor versions.
4
4
 
5
+ ## [0.5.0] — 2026-05-20
6
+
7
+ ### Added — #372 PR4 collection-hierarchy CLI parity
8
+ - `documents collections --tree` renders the parent → child hierarchy as ASCII, mirroring the admin tree UX from #372 PR3.
9
+ - `documents create-collection --parent <slug-or-id>` creates a sub-collection under the named parent. Same slug/name/UUID resolution the agent uses.
10
+ - `documents move-collection <id> --parent <slug-or-id> | --root` moves a collection atomically. Cycle prevention is server-side; CLI rejects self-as-parent eagerly.
11
+ - `clients grant <c> function <fn> --collection <slug-or-id>` and the matching `clients revoke ... --collection`. Scoped grants only apply to functions (#372 PR2 cascade); passing `--collection` to a workflow or agent grant is a clean Validation error.
12
+ - `agents add-function <a> <fn> --collection <slug-or-id>` and `agents remove-function <a> <fn> --collection` likewise. Omit the flag to target the root grant (`collection_id IS NULL` per the allowlist semantics).
13
+ - Shared `lib/collections.ts:resolveCollectionIdForGrant` resolves UUID, slug, or name with a single `GET /api/admin/collections` round-trip.
14
+
5
15
  ## [0.3.0] — 2026-05-10
6
16
 
7
17
  First-run UX for third-party developers and agent runtimes.
package/dist/index.js CHANGED
@@ -13218,6 +13218,27 @@ var workflowsConfig = {
13218
13218
 
13219
13219
  // src/commands/agents.ts
13220
13220
  init_errors();
13221
+
13222
+ // src/lib/collections.ts
13223
+ init_errors();
13224
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13225
+ async function resolveCollectionIdForGrant(ctx, ref) {
13226
+ if (UUID_RE.test(ref)) {
13227
+ return ref;
13228
+ }
13229
+ const all = await ctx.api.get("/api/admin/collections", {
13230
+ params: { active_only: "false" }
13231
+ });
13232
+ const bySlug = all.find((c) => c.slug === ref);
13233
+ if (bySlug)
13234
+ return bySlug.id;
13235
+ const byName = all.find((c) => c.name === ref);
13236
+ if (byName)
13237
+ return byName.id;
13238
+ throw new CLIError(`Collection '${ref}' not found (tried UUID, slug, and name).`, 3 /* NotFound */, "Run `martha documents collections --tree` to see available collections.");
13239
+ }
13240
+
13241
+ // src/commands/agents.ts
13221
13242
  function parseSetFlags(items) {
13222
13243
  const result = {};
13223
13244
  for (const item of items) {
@@ -13375,7 +13396,7 @@ var agentsConfig = {
13375
13396
  console.log(` Updated: ${agent.updated_at}`);
13376
13397
  },
13377
13398
  extraCommands: (parentCmd, getCtx, isJson) => {
13378
- parentCmd.command("add-function <agent> <functionName>").description("Add a function to an agent").option("--set <items...>", "Config overrides (key=value)").action(async (agent, functionName, opts) => {
13399
+ parentCmd.command("add-function <agent> <functionName>").description("Add a function to an agent").option("--set <items...>", "Config overrides (key=value)").option("--collection <slug-or-id>", "Scope the grant to one collection (#372 PR2). " + "Server rejects with 400 if the function is collection-agnostic.").action(async (agent, functionName, opts) => {
13379
13400
  const ctx = getCtx();
13380
13401
  const body = {
13381
13402
  function_name: functionName
@@ -13383,25 +13404,36 @@ var agentsConfig = {
13383
13404
  if (opts.set) {
13384
13405
  body.config_overrides = parseSetFlags(opts.set);
13385
13406
  }
13407
+ if (opts.collection) {
13408
+ body.collection_id = await resolveCollectionIdForGrant(ctx, opts.collection);
13409
+ }
13386
13410
  const result = await ctx.api.post(`${API_PATH}/${encodeURIComponent(agent)}/functions`, body);
13387
13411
  if (isJson()) {
13388
13412
  console.log(JSON.stringify(result, null, 2));
13389
13413
  return;
13390
13414
  }
13391
- console.log(`Added function '${functionName}' to agent '${agent}'`);
13415
+ const scopeLabel = opts.collection ? ` (scope: ${opts.collection})` : "";
13416
+ console.log(`Added function '${functionName}' to agent '${agent}'${scopeLabel}`);
13392
13417
  });
13393
- parentCmd.command("remove-function <agent> <functionName>").description("Remove a function from an agent").action(async (agent, functionName) => {
13418
+ parentCmd.command("remove-function <agent> <functionName>").description("Remove a function from an agent").option("--collection <slug-or-id>", "Revoke just the given collection scope (#372 PR2). " + "Omit to revoke the root grant (collection_id IS NULL).").action(async (agent, functionName, opts) => {
13394
13419
  const ctx = getCtx();
13395
- await ctx.api.del(`${API_PATH}/${encodeURIComponent(agent)}/functions/${encodeURIComponent(functionName)}`);
13420
+ let endpoint = `${API_PATH}/${encodeURIComponent(agent)}/functions/${encodeURIComponent(functionName)}`;
13421
+ if (opts.collection) {
13422
+ const collId = await resolveCollectionIdForGrant(ctx, opts.collection);
13423
+ endpoint += `?collection_id=${encodeURIComponent(collId)}`;
13424
+ }
13425
+ await ctx.api.del(endpoint);
13396
13426
  if (isJson()) {
13397
13427
  console.log(JSON.stringify({
13398
13428
  agent,
13399
13429
  function_name: functionName,
13430
+ collection: opts.collection ?? null,
13400
13431
  removed: true
13401
13432
  }));
13402
13433
  return;
13403
13434
  }
13404
- console.log(`Removed function '${functionName}' from agent '${agent}'`);
13435
+ const scopeLabel = opts.collection ? ` (scope: ${opts.collection})` : "";
13436
+ console.log(`Removed function '${functionName}' from agent '${agent}'${scopeLabel}`);
13405
13437
  });
13406
13438
  parentCmd.command("provision-auth <agent>").description("Provision, switch, or rotate agent auth credentials").requiredOption("--method <method>", "Auth method: service-account or api-key").action(async (agent, opts) => {
13407
13439
  const ctx = getCtx();
@@ -13507,6 +13539,32 @@ function formatBytes(bytes) {
13507
13539
  return `${(bytes / 1024).toFixed(1)} KB`;
13508
13540
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13509
13541
  }
13542
+ function renderCollectionTree(items) {
13543
+ const byParent = new Map;
13544
+ const ids = new Set(items.map((c) => c.id));
13545
+ for (const c of items) {
13546
+ const key = c.parent_collection_id && ids.has(c.parent_collection_id) ? c.parent_collection_id : null;
13547
+ const list = byParent.get(key);
13548
+ if (list)
13549
+ list.push(c);
13550
+ else
13551
+ byParent.set(key, [c]);
13552
+ }
13553
+ const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
13554
+ for (const list of byParent.values())
13555
+ list.sort(byName);
13556
+ const roots = byParent.get(null) ?? [];
13557
+ function walk(node, prefix, isLast) {
13558
+ const connector = isLast ? "└── " : "├── ";
13559
+ const count = node.document_count ?? 0;
13560
+ const status = node.is_active === false ? source_default.dim(" [inactive]") : "";
13561
+ console.log(`${prefix}${connector}${node.name}${source_default.dim(` (${count})`)}${status}`);
13562
+ const children = byParent.get(node.id) ?? [];
13563
+ const nextPrefix = prefix + (isLast ? " " : "│ ");
13564
+ children.forEach((child, idx) => walk(child, nextPrefix, idx === children.length - 1));
13565
+ }
13566
+ roots.forEach((root, idx) => walk(root, "", idx === roots.length - 1));
13567
+ }
13510
13568
  var STATUS_COLORS2 = {
13511
13569
  ready: source_default.green,
13512
13570
  active: source_default.green,
@@ -13626,7 +13684,25 @@ function registerDocumentCommands(program2) {
13626
13684
  function isJson() {
13627
13685
  return !!program2.opts().json;
13628
13686
  }
13629
- cmd.command("collections").description("List document collections").option("--inactive", "Include inactive collections").option("--limit <n>", "Max results", "50").action(async (opts) => {
13687
+ async function resolveCollection(ctx, ref) {
13688
+ const looksLikeUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref);
13689
+ if (looksLikeUuid) {
13690
+ try {
13691
+ return await ctx.api.get(`/api/admin/collections/${encodeURIComponent(ref)}`);
13692
+ } catch {}
13693
+ }
13694
+ const all = await ctx.api.get("/api/admin/collections", {
13695
+ params: { active_only: "false" }
13696
+ });
13697
+ const bySlug = all.find((c) => c.slug === ref);
13698
+ if (bySlug)
13699
+ return bySlug;
13700
+ const byName = all.find((c) => c.name === ref);
13701
+ if (byName)
13702
+ return byName;
13703
+ throw new CLIError(`Collection '${ref}' not found (tried UUID, slug, and name).`, 3 /* NotFound */);
13704
+ }
13705
+ cmd.command("collections").description("List document collections").option("--inactive", "Include inactive collections").option("--limit <n>", "Max results", "50").option("--tree", "Render the hierarchy as ASCII (--limit ignored in tree mode)").action(async (opts) => {
13630
13706
  const ctx = getCtx();
13631
13707
  const limitN = parseInt(opts.limit ?? "50", 10);
13632
13708
  if (isNaN(limitN) || limitN < 1) {
@@ -13635,13 +13711,17 @@ function registerDocumentCommands(program2) {
13635
13711
  const params = {};
13636
13712
  if (!opts.inactive)
13637
13713
  params.active_only = "true";
13638
- const items = await ctx.api.get("/api/admin/collections", {
13639
- params
13640
- });
13714
+ const items = await ctx.api.get("/api/admin/collections", { params });
13641
13715
  if (isJson()) {
13642
13716
  console.log(JSON.stringify(items, null, 2));
13643
13717
  return;
13644
13718
  }
13719
+ if (opts.tree) {
13720
+ renderCollectionTree(items);
13721
+ console.log(source_default.dim(`
13722
+ ${items.length} collections`));
13723
+ return;
13724
+ }
13645
13725
  const columns = [
13646
13726
  { header: "NAME", accessor: (i) => i.name },
13647
13727
  {
@@ -13693,7 +13773,7 @@ ${items.length} collections`));
13693
13773
  if (col.updated_at)
13694
13774
  console.log(` Updated: ${col.updated_at}`);
13695
13775
  });
13696
- cmd.command("create-collection").description("Create a new document collection").requiredOption("--name <name>", "Collection name").option("--description <text>", "Collection description").action(async (opts) => {
13776
+ cmd.command("create-collection").description("Create a new document collection").requiredOption("--name <name>", "Collection name").option("--description <text>", "Collection description").option("--parent <slug-or-id>", "Create as a sub-collection of the given collection (#372 PR1)").action(async (opts) => {
13697
13777
  const ctx = getCtx();
13698
13778
  const tenantId = await ctx.getTenantId();
13699
13779
  const body = {
@@ -13702,12 +13782,41 @@ ${items.length} collections`));
13702
13782
  };
13703
13783
  if (opts.description)
13704
13784
  body.description = opts.description;
13785
+ if (opts.parent) {
13786
+ const parent = await resolveCollection(ctx, opts.parent);
13787
+ body.parent_collection_id = parent.id;
13788
+ }
13705
13789
  const result = await ctx.api.post("/api/admin/collections", body);
13706
13790
  if (isJson()) {
13707
13791
  console.log(JSON.stringify(result, null, 2));
13708
13792
  return;
13709
13793
  }
13710
- console.log(`Created collection '${result.name}' (${result.id})`);
13794
+ const parentLabel = opts.parent ? ` under '${opts.parent}'` : "";
13795
+ console.log(`Created collection '${result.name}' (${result.id})${parentLabel}`);
13796
+ });
13797
+ cmd.command("move-collection <id>").description("Move a collection to a new parent or to the root (#372 PR1)").option("--parent <slug-or-id>", "New parent collection (mutually exclusive with --root)").option("--root", "Move to the top level (no parent)").action(async (id, opts) => {
13798
+ if (!!opts.parent === !!opts.root) {
13799
+ throw new CLIError("Pass exactly one of --parent <slug-or-id> or --root", 4 /* Validation */);
13800
+ }
13801
+ const ctx = getCtx();
13802
+ const moving = await resolveCollection(ctx, id);
13803
+ let newParentId;
13804
+ if (opts.root) {
13805
+ newParentId = null;
13806
+ } else {
13807
+ const parent = await resolveCollection(ctx, opts.parent);
13808
+ if (parent.id === moving.id) {
13809
+ throw new CLIError("Cannot move a collection into itself.", 4 /* Validation */);
13810
+ }
13811
+ newParentId = parent.id;
13812
+ }
13813
+ const result = await ctx.api.put(`/api/admin/collections/${encodeURIComponent(moving.id)}`, { parent_collection_id: newParentId });
13814
+ if (isJson()) {
13815
+ console.log(JSON.stringify(result, null, 2));
13816
+ return;
13817
+ }
13818
+ const target = newParentId ? `under '${opts.parent}'` : "to root (top-level)";
13819
+ console.log(`Moved collection '${moving.name}' ${target}`);
13711
13820
  });
13712
13821
  cmd.command("upload <collection-id> <file>").description("Upload a document to a collection").option("--wait", "Wait for ingestion to complete").option("--follow", "Follow ingestion progress in real time").action(async (collectionId, filePath, opts) => {
13713
13822
  const ctx = getCtx();
@@ -16137,7 +16246,7 @@ ${clients.length} client(s)`));
16137
16246
  }
16138
16247
  console.log(`Deleted client '${nameOrId}'`);
16139
16248
  });
16140
- cmd.command("grant <clientNameOrId> <type> <defName>").description("Grant a client access to a function, workflow, or agent").option("--config <items...>", "Config overrides (key=value, functions/agents only)").action(async (clientNameOrId, type, defName, opts) => {
16249
+ cmd.command("grant <clientNameOrId> <type> <defName>").description("Grant a client access to a function, workflow, or agent").option("--config <items...>", "Config overrides (key=value, functions/agents only)").option("--collection <slug-or-id>", "Scope the grant to one collection (function grants only; #372 PR2)").action(async (clientNameOrId, type, defName, opts) => {
16141
16250
  if (!VALID_TYPES.has(type)) {
16142
16251
  throw new CLIError(`Invalid type: '${type}'. Must be function, workflow, or agent.`, 4 /* Validation */);
16143
16252
  }
@@ -16165,20 +16274,30 @@ ${clients.length} client(s)`));
16165
16274
  }
16166
16275
  body.config_overrides = parseSetFlags(opts.config);
16167
16276
  }
16277
+ if (opts.collection) {
16278
+ if (accessType !== "function") {
16279
+ throw new CLIError("--collection only applies to function grants (#372 PR2).", 4 /* Validation */);
16280
+ }
16281
+ body.collection_id = await resolveCollectionIdForGrant(ctx, opts.collection);
16282
+ }
16168
16283
  const result = await ctx.api.post(endpoint, body);
16169
16284
  if (isJson()) {
16170
16285
  console.log(JSON.stringify(result, null, 2));
16171
16286
  return;
16172
16287
  }
16173
- console.log(`Granted ${accessType} '${defName}' to client '${clientNameOrId}'`);
16288
+ const scopeLabel = opts.collection ? ` (scope: ${opts.collection})` : "";
16289
+ console.log(`Granted ${accessType} '${defName}' to client '${clientNameOrId}'${scopeLabel}`);
16174
16290
  });
16175
- cmd.command("revoke <clientNameOrId> <type> <defName>").description("Revoke a client's access to a function, workflow, or agent").action(async (clientNameOrId, type, defName) => {
16291
+ cmd.command("revoke <clientNameOrId> <type> <defName>").description("Revoke a client's access to a function, workflow, or agent").option("--collection <slug-or-id>", "Revoke just the given collection scope (function grants only; #372 PR2). " + "Omit to revoke the root grant (collection_id IS NULL).").action(async (clientNameOrId, type, defName, opts) => {
16176
16292
  if (!VALID_TYPES.has(type)) {
16177
16293
  throw new CLIError(`Invalid type: '${type}'. Must be function, workflow, or agent.`, 4 /* Validation */);
16178
16294
  }
16179
16295
  const accessType = type;
16180
16296
  const ctx = getCtx();
16181
16297
  const clientId = await resolveClientId(ctx, clientNameOrId);
16298
+ if (opts.collection && accessType !== "function") {
16299
+ throw new CLIError("--collection only applies to function grants (#372 PR2).", 4 /* Validation */);
16300
+ }
16182
16301
  let endpoint;
16183
16302
  if (accessType === "agent") {
16184
16303
  endpoint = `${DEFS_API}/clients/${clientId}/agents/${encodeURIComponent(defName)}`;
@@ -16186,6 +16305,10 @@ ${clients.length} client(s)`));
16186
16305
  const defId = await resolveDefinitionId(ctx, accessType, defName);
16187
16306
  const typePlural = `${accessType}s`;
16188
16307
  endpoint = `${DEFS_API}/clients/${clientId}/${typePlural}/${encodeURIComponent(defId)}`;
16308
+ if (opts.collection) {
16309
+ const collId = await resolveCollectionIdForGrant(ctx, opts.collection);
16310
+ endpoint += `?collection_id=${encodeURIComponent(collId)}`;
16311
+ }
16189
16312
  }
16190
16313
  await ctx.api.del(endpoint);
16191
16314
  if (isJson()) {
@@ -16193,11 +16316,13 @@ ${clients.length} client(s)`));
16193
16316
  client: clientNameOrId,
16194
16317
  type: accessType,
16195
16318
  name: defName,
16319
+ collection: opts.collection ?? null,
16196
16320
  revoked: true
16197
16321
  }));
16198
16322
  return;
16199
16323
  }
16200
- console.log(`Revoked ${accessType} '${defName}' from client '${clientNameOrId}'`);
16324
+ const scopeLabel = opts.collection ? ` (scope: ${opts.collection})` : "";
16325
+ console.log(`Revoked ${accessType} '${defName}' from client '${clientNameOrId}'${scopeLabel}`);
16201
16326
  });
16202
16327
  const embed = cmd.command("embed").description("Create, configure, and test embeddable chat clients");
16203
16328
  function addEmbedConfigOptions(command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/martha-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Terminal-first client for the Martha AI platform",
5
5
  "homepage": "https://docs.martha.nomadriver.co",
6
6
  "repository": {
@@ -272,11 +272,13 @@ martha agents generate-key <agent>
272
272
  **Function grants:**
273
273
 
274
274
  ```bash
275
- martha agents add-function <agent> <function> [--set key=value] # config_overrides
276
- martha agents remove-function <agent> <function>
275
+ martha agents add-function <agent> <function> [--set key=value] [--collection <slug-or-id>]
276
+ martha agents remove-function <agent> <function> [--collection <slug-or-id>]
277
277
  martha agents functions <agent> # List granted functions
278
278
  ```
279
279
 
280
+ `--collection` scopes the grant to one collection per the #372 PR2 cascade. Omit it to grant at the root (tenant-wide); pass it on `remove-function` to revoke just that scope. Server returns 400 if the function is collection-agnostic (e.g. `recall`).
281
+
280
282
  ---
281
283
 
282
284
  ## Tasks: queue + executor lifecycle
@@ -579,9 +581,10 @@ Document collections are tenant-scoped containers that ingest files (PDF, DOCX,
579
581
 
580
582
  ```bash
581
583
  # Collections
582
- martha documents collections [--inactive] [--limit 50]
584
+ martha documents collections [--inactive] [--limit 50] [--tree] # --tree renders the parent/child hierarchy as ASCII (#372)
583
585
  martha documents collection <id> # Stats, ingestion status, total size
584
- martha documents create-collection --name "My Docs" [--description "..."]
586
+ martha documents create-collection --name "My Docs" [--description "..."] [--parent <slug-or-id>]
587
+ martha documents move-collection <id> --parent <slug-or-id> | --root # Atomic move; server rejects cycles
585
588
 
586
589
  # Document lifecycle
587
590
  martha documents upload <collection-id> <file> [--wait] [--follow] # --follow streams ingestion progress
@@ -653,11 +656,13 @@ martha clients update <name-or-id> [--name <n>] [--system-prompts '...']
653
656
  martha clients delete <name-or-id> [--force] [--yes] # --force drops sessions
654
657
 
655
658
  # Access grants — what definitions this client's sessions can use
656
- martha clients grant <client> function|workflow|agent <def-name> [--config key=value]
657
- martha clients revoke <client> function|workflow|agent <def-name>
659
+ martha clients grant <client> function|workflow|agent <def-name> [--config key=value] [--collection <slug-or-id>]
660
+ martha clients revoke <client> function|workflow|agent <def-name> [--collection <slug-or-id>]
658
661
  martha clients access <name-or-id> # All grants for this client
659
662
  ```
660
663
 
664
+ `--collection` is only valid on `function` grants (#372 PR2). The same UUID, slug, or name accepted by `documents move-collection` works here. Granting a scope on a collection-agnostic function (like `recall`) returns 400.
665
+
661
666
  ---
662
667
 
663
668
  ## Sessions + Chat
@@ -859,6 +864,35 @@ martha integrations specs --json | jq -r '.[] | select(.name == "linear") | .id'
859
864
  done
860
865
  ```
861
866
 
867
+ ### Collection hierarchy + scoped grants (#372)
868
+
869
+ ```bash
870
+ # See the tree
871
+ martha documents collections --tree
872
+
873
+ # Build a hierarchy
874
+ martha documents create-collection --name "Reports"
875
+ martha documents create-collection --name "2026 Reports" --parent reports
876
+ martha documents create-collection --name "Q1 2026" --parent 2026-reports
877
+
878
+ # Reorganize
879
+ martha documents move-collection q1-2026 --parent reports # promote a level
880
+ martha documents move-collection 2026-reports --root # back to top-level
881
+
882
+ # Scoped grants. Agent only sees docs in /Reports and descendants.
883
+ martha agents add-function planner list_docs --collection reports
884
+ martha agents add-function planner read_doc --collection reports
885
+
886
+ # Most-specific ancestor wins: an additional grant on a child
887
+ # collection overrides config_overrides for that subtree only.
888
+ martha agents add-function planner list_docs --collection q1-2026 --set max_results=50
889
+
890
+ # Revoke just one scope; the parent root grant (if any) survives.
891
+ martha agents remove-function planner list_docs --collection q1-2026
892
+ ```
893
+
894
+ `--collection` accepts UUID, slug, or name — same identifier the agent uses. Resolution mirrors the backend.
895
+
862
896
  ---
863
897
 
864
898
  ## Troubleshooting