@aiaiai-pt/martha-cli 0.3.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
@@ -1019,7 +1019,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
1019
1019
  this._exitCallback = (err) => {
1020
1020
  if (err.code !== "commander.executeSubCommandAsync") {
1021
1021
  throw err;
1022
- } else {}
1022
+ }
1023
1023
  };
1024
1024
  }
1025
1025
  return this;
@@ -10830,7 +10830,7 @@ function registerDefinitionCommands(program2, config) {
10830
10830
  function isJson() {
10831
10831
  return !!program2.opts().json;
10832
10832
  }
10833
- const listCmd = cmd.command("list").description(`List ${config.typeNamePlural}`).option("--inactive", "Include inactive items").option("--limit <n>", "Max results", "50");
10833
+ const listCmd = cmd.command("list").description(`List ${config.typeNamePlural}`).option("--inactive", "Include inactive items").option("--limit <n>", "Max results per page", "50").option("--search <term>", "Case-insensitive substring filter on name (server-side)").option("--all", "Auto-paginate until every matching item is fetched (server caps at 500/page)");
10834
10834
  if (config.extraListOptions) {
10835
10835
  config.extraListOptions(listCmd);
10836
10836
  }
@@ -10840,17 +10840,65 @@ function registerDefinitionCommands(program2, config) {
10840
10840
  if (isNaN(limitN) || limitN < 1) {
10841
10841
  throw new CLIError("--limit must be a positive integer", 4 /* Validation */);
10842
10842
  }
10843
- const params = {
10844
- limit: String(limitN),
10845
- skip: "0"
10846
- };
10843
+ const baseParams = {};
10847
10844
  if (!opts.inactive)
10848
- params.active_only = "true";
10845
+ baseParams.active_only = "true";
10846
+ if (opts.search)
10847
+ baseParams.name_contains = String(opts.search);
10849
10848
  if (config.buildListParams) {
10850
- Object.assign(params, config.buildListParams(opts));
10849
+ Object.assign(baseParams, config.buildListParams(opts));
10850
+ }
10851
+ let items;
10852
+ let total;
10853
+ if (opts.all) {
10854
+ const pageSize = 500;
10855
+ items = [];
10856
+ let skip = 0;
10857
+ while (true) {
10858
+ const params = {
10859
+ ...baseParams,
10860
+ limit: String(pageSize),
10861
+ skip: String(skip)
10862
+ };
10863
+ const res = await ctx.api.getRaw(config.apiPath, { params });
10864
+ if (!res.ok) {
10865
+ throw new CLIError(`List ${config.typeNamePlural} failed: HTTP ${res.status}`, 1 /* Error */);
10866
+ }
10867
+ const headerTotal = res.headers.get("x-total-count");
10868
+ if (headerTotal !== null && total === undefined) {
10869
+ total = Number(headerTotal);
10870
+ }
10871
+ const body = await res.json();
10872
+ const pageItems = config.extractList(body);
10873
+ items.push(...pageItems);
10874
+ if (pageItems.length < pageSize)
10875
+ break;
10876
+ if (total !== undefined && items.length >= total)
10877
+ break;
10878
+ skip += pageSize;
10879
+ if (skip > 50000) {
10880
+ throw new CLIError(`Aborted --all after fetching ${items.length} ${config.typeNamePlural}; suspected pagination bug`, 1 /* Error */);
10881
+ }
10882
+ }
10883
+ } else {
10884
+ const params = {
10885
+ ...baseParams,
10886
+ limit: String(limitN),
10887
+ skip: "0"
10888
+ };
10889
+ const res = await ctx.api.getRaw(config.apiPath, { params });
10890
+ if (!res.ok) {
10891
+ throw new CLIError(`List ${config.typeNamePlural} failed: HTTP ${res.status}`, 1 /* Error */);
10892
+ }
10893
+ const headerTotal = res.headers.get("x-total-count");
10894
+ if (headerTotal !== null)
10895
+ total = Number(headerTotal);
10896
+ const data = await res.json();
10897
+ items = config.extractList(data);
10898
+ const bodyTotal = config.extractTotal?.(data);
10899
+ if (bodyTotal !== undefined)
10900
+ total = bodyTotal;
10851
10901
  }
10852
- const data = await ctx.api.get(config.apiPath, { params });
10853
- const items = config.extractList(data);
10854
10902
  if (isJson()) {
10855
10903
  console.log(JSON.stringify(items, null, 2));
10856
10904
  return;
@@ -10862,10 +10910,10 @@ function registerDefinitionCommands(program2, config) {
10862
10910
  for (const item of items) {
10863
10911
  console.log(config.listColumns.map((col, i) => col.accessor(item).padEnd(widths[i])).join(" "));
10864
10912
  }
10865
- const total = config.extractTotal?.(data);
10866
10913
  if (total !== undefined && total > items.length) {
10914
+ const hint = opts.search ? `(narrow with --search or use --all)` : `(use --search <term> or --all to load everything)`;
10867
10915
  console.log(source_default.dim(`
10868
- ${items.length} of ${total} ${config.typeNamePlural} (use --limit ${total} to see all)`));
10916
+ ${items.length} of ${total} ${config.typeNamePlural} ${hint}`));
10869
10917
  } else {
10870
10918
  console.log(source_default.dim(`
10871
10919
  ${items.length} ${config.typeNamePlural}`));
@@ -11456,16 +11504,46 @@ Error: ${err instanceof Error ? err.message : String(err)}
11456
11504
  `);
11457
11505
  }
11458
11506
  async function runOneShot(ctx, sessionId, message, isJson, clientId, showTools, timeoutMs) {
11507
+ const toolCalls = [];
11508
+ function recordToolStatus(data) {
11509
+ try {
11510
+ const tools = JSON.parse(data);
11511
+ for (const t of tools) {
11512
+ const name = t.tool_name ?? t.tool_label;
11513
+ if (!name)
11514
+ continue;
11515
+ toolCalls.push({ name, status: t.status });
11516
+ }
11517
+ } catch {}
11518
+ }
11519
+ function recordToolCall(data) {
11520
+ try {
11521
+ const parsed = JSON.parse(data);
11522
+ if (parsed.name)
11523
+ toolCalls.push({ name: parsed.name });
11524
+ } catch {}
11525
+ }
11459
11526
  const { response } = await sendMessage(ctx, sessionId, message, {
11460
11527
  clientId,
11461
11528
  timeoutMs,
11462
- onToolCall: showTools ? (data) => process.stderr.write(formatToolCall(data)) : undefined,
11529
+ onToolStatus: isJson ? recordToolStatus : showTools ? (data) => {
11530
+ recordToolStatus(data);
11531
+ process.stderr.write(formatToolStatus(data));
11532
+ } : undefined,
11533
+ onToolCall: isJson ? recordToolCall : showTools ? (data) => {
11534
+ recordToolCall(data);
11535
+ process.stderr.write(formatToolCall(data));
11536
+ } : undefined,
11463
11537
  onToolResult: showTools ? (data) => process.stderr.write(formatToolResult(data)) : undefined,
11464
11538
  onClear: showTools ? () => process.stderr.write(source_default.dim(` --- tool iteration ---
11465
11539
  `)) : undefined
11466
11540
  });
11467
11541
  if (isJson) {
11468
- console.log(JSON.stringify({ session_id: sessionId, response }));
11542
+ console.log(JSON.stringify({
11543
+ session_id: sessionId,
11544
+ response,
11545
+ tool_calls: toolCalls
11546
+ }));
11469
11547
  } else {
11470
11548
  console.log(response);
11471
11549
  }
@@ -13140,6 +13218,27 @@ var workflowsConfig = {
13140
13218
 
13141
13219
  // src/commands/agents.ts
13142
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
13143
13242
  function parseSetFlags(items) {
13144
13243
  const result = {};
13145
13244
  for (const item of items) {
@@ -13297,7 +13396,7 @@ var agentsConfig = {
13297
13396
  console.log(` Updated: ${agent.updated_at}`);
13298
13397
  },
13299
13398
  extraCommands: (parentCmd, getCtx, isJson) => {
13300
- 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) => {
13301
13400
  const ctx = getCtx();
13302
13401
  const body = {
13303
13402
  function_name: functionName
@@ -13305,25 +13404,36 @@ var agentsConfig = {
13305
13404
  if (opts.set) {
13306
13405
  body.config_overrides = parseSetFlags(opts.set);
13307
13406
  }
13407
+ if (opts.collection) {
13408
+ body.collection_id = await resolveCollectionIdForGrant(ctx, opts.collection);
13409
+ }
13308
13410
  const result = await ctx.api.post(`${API_PATH}/${encodeURIComponent(agent)}/functions`, body);
13309
13411
  if (isJson()) {
13310
13412
  console.log(JSON.stringify(result, null, 2));
13311
13413
  return;
13312
13414
  }
13313
- 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}`);
13314
13417
  });
13315
- 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) => {
13316
13419
  const ctx = getCtx();
13317
- 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);
13318
13426
  if (isJson()) {
13319
13427
  console.log(JSON.stringify({
13320
13428
  agent,
13321
13429
  function_name: functionName,
13430
+ collection: opts.collection ?? null,
13322
13431
  removed: true
13323
13432
  }));
13324
13433
  return;
13325
13434
  }
13326
- 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}`);
13327
13437
  });
13328
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) => {
13329
13439
  const ctx = getCtx();
@@ -13429,6 +13539,32 @@ function formatBytes(bytes) {
13429
13539
  return `${(bytes / 1024).toFixed(1)} KB`;
13430
13540
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13431
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
+ }
13432
13568
  var STATUS_COLORS2 = {
13433
13569
  ready: source_default.green,
13434
13570
  active: source_default.green,
@@ -13548,7 +13684,25 @@ function registerDocumentCommands(program2) {
13548
13684
  function isJson() {
13549
13685
  return !!program2.opts().json;
13550
13686
  }
13551
- 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) => {
13552
13706
  const ctx = getCtx();
13553
13707
  const limitN = parseInt(opts.limit ?? "50", 10);
13554
13708
  if (isNaN(limitN) || limitN < 1) {
@@ -13557,13 +13711,17 @@ function registerDocumentCommands(program2) {
13557
13711
  const params = {};
13558
13712
  if (!opts.inactive)
13559
13713
  params.active_only = "true";
13560
- const items = await ctx.api.get("/api/admin/collections", {
13561
- params
13562
- });
13714
+ const items = await ctx.api.get("/api/admin/collections", { params });
13563
13715
  if (isJson()) {
13564
13716
  console.log(JSON.stringify(items, null, 2));
13565
13717
  return;
13566
13718
  }
13719
+ if (opts.tree) {
13720
+ renderCollectionTree(items);
13721
+ console.log(source_default.dim(`
13722
+ ${items.length} collections`));
13723
+ return;
13724
+ }
13567
13725
  const columns = [
13568
13726
  { header: "NAME", accessor: (i) => i.name },
13569
13727
  {
@@ -13615,7 +13773,7 @@ ${items.length} collections`));
13615
13773
  if (col.updated_at)
13616
13774
  console.log(` Updated: ${col.updated_at}`);
13617
13775
  });
13618
- 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) => {
13619
13777
  const ctx = getCtx();
13620
13778
  const tenantId = await ctx.getTenantId();
13621
13779
  const body = {
@@ -13624,12 +13782,41 @@ ${items.length} collections`));
13624
13782
  };
13625
13783
  if (opts.description)
13626
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
+ }
13627
13789
  const result = await ctx.api.post("/api/admin/collections", body);
13628
13790
  if (isJson()) {
13629
13791
  console.log(JSON.stringify(result, null, 2));
13630
13792
  return;
13631
13793
  }
13632
- 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}`);
13633
13820
  });
13634
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) => {
13635
13822
  const ctx = getCtx();
@@ -15729,23 +15916,30 @@ async function resolveClientId(ctx, nameOrId) {
15729
15916
  async function resolveDefinitionId(ctx, type, name) {
15730
15917
  if (type === "agent")
15731
15918
  return name;
15732
- const path5 = type === "function" ? `${DEFS_API}/functions` : `${DEFS_API}/workflows`;
15733
- const data = await ctx.api.get(path5, {
15734
- params: { limit: "500", skip: "0" }
15735
- });
15736
- let items;
15737
- if (Array.isArray(data)) {
15738
- items = data;
15739
- } else if (data && typeof data === "object" && "items" in data) {
15740
- items = data.items;
15741
- } else {
15742
- throw new CLIError(`Unexpected response shape from ${type}s list`, 1 /* Error */);
15743
- }
15744
- const match = items.find((d) => String(d.name ?? "").toLowerCase() === name.toLowerCase());
15745
- if (!match) {
15746
- throw new CLIError(`${type} not found: '${name}'`, 3 /* NotFound */, `Run \`martha ${type}s list\` to see available ${type}s.`);
15919
+ const listPath = type === "function" ? `${DEFS_API}/functions` : `${DEFS_API}/workflows`;
15920
+ const exactPath = `${listPath}/${encodeURIComponent(name)}`;
15921
+ try {
15922
+ const exact = await ctx.api.get(exactPath);
15923
+ if (exact && typeof exact === "object" && "id" in exact) {
15924
+ return String(exact.id);
15925
+ }
15926
+ } catch (e) {
15927
+ const code = e?.status ?? e?.statusCode;
15928
+ if (code !== 404)
15929
+ throw e;
15747
15930
  }
15748
- return String(match.id);
15931
+ let suggestion = "";
15932
+ try {
15933
+ const fuzzy = await ctx.api.get(listPath, {
15934
+ params: { name_contains: name, limit: "5", skip: "0" }
15935
+ });
15936
+ const items = Array.isArray(fuzzy) ? fuzzy : fuzzy && typeof fuzzy === "object" && ("items" in fuzzy) ? fuzzy.items : [];
15937
+ const names = items.map((d) => String(d.name ?? "")).filter((n) => n.length > 0).slice(0, 5);
15938
+ if (names.length > 0) {
15939
+ suggestion = `Did you mean: ${names.join(", ")}?`;
15940
+ }
15941
+ } catch {}
15942
+ throw new CLIError(`${type} not found: '${name}'`, 3 /* NotFound */, suggestion || `Run \`martha ${type}s list --search ${name}\` to find it.`);
15749
15943
  }
15750
15944
  function formatDate(val) {
15751
15945
  if (!val)
@@ -16052,7 +16246,7 @@ ${clients.length} client(s)`));
16052
16246
  }
16053
16247
  console.log(`Deleted client '${nameOrId}'`);
16054
16248
  });
16055
- 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) => {
16056
16250
  if (!VALID_TYPES.has(type)) {
16057
16251
  throw new CLIError(`Invalid type: '${type}'. Must be function, workflow, or agent.`, 4 /* Validation */);
16058
16252
  }
@@ -16080,20 +16274,30 @@ ${clients.length} client(s)`));
16080
16274
  }
16081
16275
  body.config_overrides = parseSetFlags(opts.config);
16082
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
+ }
16083
16283
  const result = await ctx.api.post(endpoint, body);
16084
16284
  if (isJson()) {
16085
16285
  console.log(JSON.stringify(result, null, 2));
16086
16286
  return;
16087
16287
  }
16088
- 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}`);
16089
16290
  });
16090
- 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) => {
16091
16292
  if (!VALID_TYPES.has(type)) {
16092
16293
  throw new CLIError(`Invalid type: '${type}'. Must be function, workflow, or agent.`, 4 /* Validation */);
16093
16294
  }
16094
16295
  const accessType = type;
16095
16296
  const ctx = getCtx();
16096
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
+ }
16097
16301
  let endpoint;
16098
16302
  if (accessType === "agent") {
16099
16303
  endpoint = `${DEFS_API}/clients/${clientId}/agents/${encodeURIComponent(defName)}`;
@@ -16101,6 +16305,10 @@ ${clients.length} client(s)`));
16101
16305
  const defId = await resolveDefinitionId(ctx, accessType, defName);
16102
16306
  const typePlural = `${accessType}s`;
16103
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
+ }
16104
16312
  }
16105
16313
  await ctx.api.del(endpoint);
16106
16314
  if (isJson()) {
@@ -16108,11 +16316,13 @@ ${clients.length} client(s)`));
16108
16316
  client: clientNameOrId,
16109
16317
  type: accessType,
16110
16318
  name: defName,
16319
+ collection: opts.collection ?? null,
16111
16320
  revoked: true
16112
16321
  }));
16113
16322
  return;
16114
16323
  }
16115
- 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}`);
16116
16326
  });
16117
16327
  const embed = cmd.command("embed").description("Create, configure, and test embeddable chat clients");
16118
16328
  function addEmbedConfigOptions(command) {
@@ -16231,7 +16441,11 @@ ${clients.length} client(s)`));
16231
16441
  const result = await ctx.api.get(`${API}/${clientId}`);
16232
16442
  const assetBaseUrl = getEmbedAssetBaseUrl(ctx.profile.api_url);
16233
16443
  if (isJson()) {
16234
- console.log(JSON.stringify({ ...result, asset_base_url: assetBaseUrl, embed_credentials: credentials }, null, 2));
16444
+ console.log(JSON.stringify({
16445
+ ...result,
16446
+ asset_base_url: assetBaseUrl,
16447
+ embed_credentials: credentials
16448
+ }, null, 2));
16235
16449
  return;
16236
16450
  }
16237
16451
  printEmbedSummary(result, assetBaseUrl);
@@ -16331,7 +16545,9 @@ ${clients.length} client(s)`));
16331
16545
  const manifestPath = `/api/embed/manifest/${encodeURIComponent(key)}`;
16332
16546
  const manifest = await ctx.api.get(manifestPath, { headers: { Origin: origin } });
16333
16547
  const deniedUrl = `${deriveMarthaWebOrigin(ctx.profile.api_url)}${manifestPath}`;
16334
- const denied = await fetch(deniedUrl, { headers: { Origin: deniedOrigin } });
16548
+ const denied = await fetch(deniedUrl, {
16549
+ headers: { Origin: deniedOrigin }
16550
+ });
16335
16551
  if (denied.status !== 403) {
16336
16552
  throw new CLIError(`Denied-origin manifest check expected 403, got ${denied.status}`, 1 /* Error */);
16337
16553
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/martha-cli",
3
- "version": "0.3.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