@aiaiai-pt/martha-cli 0.58.1 → 0.61.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/index.js +336 -71
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,72 @@
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.61.1] — 2026-09-25
6
+
7
+ ### Fixed — carries 0.60.2's redirect fix onto the 0.61 line
8
+
9
+ `main` shipped the same-host redirect fix as 0.60.2 while `uio` released 0.61.0
10
+ the same day; the main → uio sync brings that fix into the 0.61 line. No other
11
+ change: `martha triggers list/create` and `definitions apply` of a `kind: Trigger`
12
+ no longer read as "Not authenticated" behind the TLS proxy (see 0.60.2 below).
13
+
14
+ ## [0.61.0] — 2026-09-24
15
+
16
+ ### Added — `martha spend` and `martha budget` (#1107)
17
+
18
+ - `martha spend [--tenant <id>] [--group-by day|agent|model|surface|billing_owner] [--since] [--until]`
19
+ reads a tenant's LLM spend from Martha's own ledger (`GET /api/admin/tenants/{t}/spend`).
20
+ - `martha budget list|set|rm` manages per-tenant and per-AI-Agent LLM spend ceilings
21
+ (`warn`, `require_approval` — the run suspends until an ApprovalCase is approved — or `block`).
22
+
23
+ Both default to your own tenant; another tenant needs super-admin (the API answers 403).
24
+
25
+ ### Fixed — commands that default to "your tenant" on realms that use Keycloak Organizations
26
+
27
+ On realms that model tenants as Keycloak Organizations (ubiwhere.io) the access token carries
28
+ an `organizations` claim and no `tenant_id`, so every command that defaults to the caller's
29
+ tenant failed with `No tenant_id claim in access token`. The CLI now resolves the tenant exactly
30
+ as the API does: `tenant_id`, else the first organization.
31
+
32
+ ## [0.60.2] — 2026-09-24
33
+
34
+ ### Fixed — a redirect no longer turns an authenticated request into "Not authenticated"
35
+
36
+ `martha triggers list`, `triggers create` and `definitions apply` of a
37
+ `kind: Trigger` all failed with `Not authenticated` for a logged-in user. The
38
+ API answers `POST /api/admin/triggers` (no trailing slash) with
39
+ `307 → http://<host>/api/admin/triggers/` — a scheme downgrade behind the TLS
40
+ proxy. `fetch` followed it cross-origin and dropped `Authorization`, so the API
41
+ saw no token. Measured on martha.nomadriver.co, 2026-09-24.
42
+
43
+ Every request now handles redirects itself (`redirect: "manual"`): same host
44
+ only, always on the original scheme, at most 3 hops; 307/308 keep method and
45
+ body. A redirect to any other host is refused with a clear error and the
46
+ bearer token is never sent there.
47
+
48
+ ## [0.60.0] — 2026-08-20
49
+
50
+ ### Added — `kind: ClientAccess`, so a declared workflow can also be authorized
51
+
52
+ `definitions apply` could declare a Function, a Workflow, an Agent and a
53
+ Trigger. It could not declare the GRANTS that let a principal call them —
54
+ those existed only as `martha clients grant`. So anything that COMPILES a
55
+ workflow installed the workflow without the authority to run it, and the gap
56
+ only showed at run time as `Unknown function: <name>` on a function that is
57
+ registered and active for the tenant.
58
+
59
+ ```yaml
60
+ kind: ClientAccess
61
+ name: martha-cli-zero # client_key, name or id — what `martha clients` takes
62
+ grants:
63
+ - { type: function, name: get_entity_entities_type_code_entity_id_get }
64
+ - { type: workflow, name: some_workflow }
65
+ ```
66
+
67
+ Additive, like the rest of apply: a document that drops a grant does not revoke
68
+ it. `ClientAccess` documents are applied LAST within a run, so one file can
69
+ carry a workflow and the authority to run it in any order.
70
+
5
71
  ## [0.58.1] — 2026-08-04
6
72
 
7
73
  ### Fixed — provisioning called an existing agent blind when it wasn't
package/dist/index.js CHANGED
@@ -5,43 +5,25 @@ var __getProtoOf = Object.getPrototypeOf;
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- function __accessProp(key) {
9
- return this[key];
10
- }
11
- var __toESMCache_node;
12
- var __toESMCache_esm;
13
8
  var __toESM = (mod, isNodeMode, target) => {
14
- var canCache = mod != null && typeof mod === "object";
15
- if (canCache) {
16
- var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
- var cached = cache.get(mod);
18
- if (cached)
19
- return cached;
20
- }
21
9
  target = mod != null ? __create(__getProtoOf(mod)) : {};
22
10
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
23
11
  for (let key of __getOwnPropNames(mod))
24
12
  if (!__hasOwnProp.call(to, key))
25
13
  __defProp(to, key, {
26
- get: __accessProp.bind(mod, key),
14
+ get: () => mod[key],
27
15
  enumerable: true
28
16
  });
29
- if (canCache)
30
- cache.set(mod, to);
31
17
  return to;
32
18
  };
33
19
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
- var __returnValue = (v) => v;
35
- function __exportSetter(name, newValue) {
36
- this[name] = __returnValue.bind(null, newValue);
37
- }
38
20
  var __export = (target, all) => {
39
21
  for (var name in all)
40
22
  __defProp(target, name, {
41
23
  get: all[name],
42
24
  enumerable: true,
43
25
  configurable: true,
44
- set: __exportSetter.bind(all, name)
26
+ set: (newValue) => all[name] = () => newValue
45
27
  });
46
28
  };
47
29
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -1019,7 +1001,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
1019
1001
  this._exitCallback = (err) => {
1020
1002
  if (err.code !== "commander.executeSubCommandAsync") {
1021
1003
  throw err;
1022
- }
1004
+ } else {}
1023
1005
  };
1024
1006
  }
1025
1007
  return this;
@@ -3546,6 +3528,7 @@ var require_stringify = __commonJS((exports) => {
3546
3528
  nullStr: "null",
3547
3529
  simpleKeys: false,
3548
3530
  singleQuote: null,
3531
+ trailingComma: false,
3549
3532
  trueStr: "true",
3550
3533
  verifyAliasOrder: true
3551
3534
  }, doc.schema.toStringOptions, options);
@@ -4054,13 +4037,20 @@ ${indent}${line}` : `
4054
4037
  if (comment)
4055
4038
  reqNewline = true;
4056
4039
  let str = stringify.stringify(item, itemCtx, () => comment = null);
4057
- if (i < items.length - 1)
4040
+ reqNewline || (reqNewline = lines.length > linesAtValue || str.includes(`
4041
+ `));
4042
+ if (i < items.length - 1) {
4058
4043
  str += ",";
4044
+ } else if (ctx.options.trailingComma) {
4045
+ if (ctx.options.lineWidth > 0) {
4046
+ reqNewline || (reqNewline = lines.reduce((sum, line) => sum + line.length + 2, 2) + (str.length + 2) > ctx.options.lineWidth);
4047
+ }
4048
+ if (reqNewline) {
4049
+ str += ",";
4050
+ }
4051
+ }
4059
4052
  if (comment)
4060
4053
  str += stringifyComment.lineComment(str, itemIndent, commentString(comment));
4061
- if (!reqNewline && (lines.length > linesAtValue || str.includes(`
4062
- `)))
4063
- reqNewline = true;
4064
4054
  lines.push(str);
4065
4055
  linesAtValue = lines.length;
4066
4056
  }
@@ -6855,17 +6845,22 @@ var require_compose_node = __commonJS((exports) => {
6855
6845
  case "block-map":
6856
6846
  case "block-seq":
6857
6847
  case "flow-collection":
6858
- node = composeCollection.composeCollection(CN, ctx, token, props, onError);
6859
- if (anchor)
6860
- node.anchor = anchor.source.substring(1);
6848
+ try {
6849
+ node = composeCollection.composeCollection(CN, ctx, token, props, onError);
6850
+ if (anchor)
6851
+ node.anchor = anchor.source.substring(1);
6852
+ } catch (error) {
6853
+ const message = error instanceof Error ? error.message : String(error);
6854
+ onError(token, "RESOURCE_EXHAUSTION", message);
6855
+ }
6861
6856
  break;
6862
6857
  default: {
6863
6858
  const message = token.type === "error" ? token.message : `Unsupported token (type: ${token.type})`;
6864
6859
  onError(token, "UNEXPECTED_TOKEN", message);
6865
- node = composeEmptyNode(ctx, token.offset, undefined, null, props, onError);
6866
6860
  isSrcToken = false;
6867
6861
  }
6868
6862
  }
6863
+ node ?? (node = composeEmptyNode(ctx, token.offset, undefined, null, props, onError));
6869
6864
  if (anchor && node.anchor === "")
6870
6865
  onError(anchor, "BAD_ALIAS", "Anchor cannot be an empty string");
6871
6866
  if (atKey && ctx.options.stringKeys && (!identity.isScalar(node) || typeof node.value !== "string" || node.tag && node.tag !== "tag:yaml.org,2002:str")) {
@@ -9526,6 +9521,7 @@ function parseDataLine(data) {
9526
9521
  // src/lib/api-client.ts
9527
9522
  var exports_api_client = {};
9528
9523
  __export(exports_api_client, {
9524
+ sameHostRedirect: () => sameHostRedirect,
9529
9525
  MarthaAPIClient: () => MarthaAPIClient
9530
9526
  });
9531
9527
  import fs3 from "node:fs";
@@ -9534,6 +9530,33 @@ function guessMimeType(filename) {
9534
9530
  const ext = nodePath.extname(filename).toLowerCase();
9535
9531
  return MIME_TYPES[ext] ?? "application/octet-stream";
9536
9532
  }
9533
+ function isRedirect(status) {
9534
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
9535
+ }
9536
+ function sameHostRedirect(from, location) {
9537
+ if (!location)
9538
+ return null;
9539
+ const origin = new URL(from);
9540
+ let next;
9541
+ try {
9542
+ next = new URL(location, origin);
9543
+ } catch {
9544
+ return null;
9545
+ }
9546
+ if (next.hostname !== origin.hostname)
9547
+ return null;
9548
+ const port = (u) => u.port || (u.protocol === "https:" ? "443" : "80");
9549
+ if (next.protocol !== origin.protocol) {
9550
+ if (port(next) !== (next.protocol === "https:" ? "443" : "80") || port(origin) !== (origin.protocol === "https:" ? "443" : "80")) {
9551
+ return null;
9552
+ }
9553
+ next.protocol = origin.protocol;
9554
+ next.port = origin.port;
9555
+ } else if (port(next) !== port(origin)) {
9556
+ return null;
9557
+ }
9558
+ return next.toString();
9559
+ }
9537
9560
 
9538
9561
  class MarthaAPIClient {
9539
9562
  baseUrl;
@@ -9551,6 +9574,27 @@ class MarthaAPIClient {
9551
9574
  process.stderr.write(`[DEBUG] ${msg}
9552
9575
  `);
9553
9576
  }
9577
+ async fetchSameHost(url, init) {
9578
+ let target = url;
9579
+ let current = { ...init, redirect: "manual" };
9580
+ let res = await fetch(target, current);
9581
+ for (let hop = 0;isRedirect(res.status); hop++) {
9582
+ const location = res.headers.get("location");
9583
+ const next = sameHostRedirect(target, location);
9584
+ if (!next || hop >= MAX_REDIRECTS) {
9585
+ throw new CLIError(next ? `Too many redirects (${MAX_REDIRECTS}) from ${url}` : `Refused redirect from ${target} to ${location ?? "(no Location)"} — ` + "the credential is only ever sent to the configured API host", 1 /* Error */, "Check the profile's api_url; the server may be missing a trailing-slash route or proxy headers.");
9586
+ }
9587
+ if (res.status !== 307 && res.status !== 308 && current.method !== "GET" && current.method !== "HEAD") {
9588
+ const headers = { ...current.headers };
9589
+ delete headers["Content-Type"];
9590
+ current = { ...current, method: "GET", body: undefined, headers };
9591
+ }
9592
+ this.log(` → ${res.status} redirect to ${next}`);
9593
+ target = next;
9594
+ res = await fetch(target, current);
9595
+ }
9596
+ return res;
9597
+ }
9554
9598
  async get(path3, opts) {
9555
9599
  return this.request("GET", path3, opts);
9556
9600
  }
@@ -9578,7 +9622,7 @@ class MarthaAPIClient {
9578
9622
  formData.append("file", blob, fileName);
9579
9623
  let res;
9580
9624
  try {
9581
- res = await fetch(url, {
9625
+ res = await this.fetchSameHost(url, {
9582
9626
  method: "POST",
9583
9627
  headers: {
9584
9628
  Authorization: `Bearer ${token}`,
@@ -9608,7 +9652,7 @@ class MarthaAPIClient {
9608
9652
  const timeout = opts?.timeout ?? this.defaultTimeout;
9609
9653
  let res;
9610
9654
  try {
9611
- res = await fetch(url, {
9655
+ res = await this.fetchSameHost(url, {
9612
9656
  method: "GET",
9613
9657
  headers: {
9614
9658
  Authorization: `Bearer ${token}`,
@@ -9637,7 +9681,7 @@ class MarthaAPIClient {
9637
9681
  const timeout = opts?.timeout ?? 120000;
9638
9682
  let res;
9639
9683
  try {
9640
- res = await fetch(url, {
9684
+ res = await this.fetchSameHost(url, {
9641
9685
  method: "POST",
9642
9686
  headers: {
9643
9687
  Authorization: `Bearer ${token}`,
@@ -9666,7 +9710,7 @@ class MarthaAPIClient {
9666
9710
  const token = await this.getAccessToken();
9667
9711
  const url = this.buildUrl(path3, opts?.params);
9668
9712
  const timeout = opts?.timeout ?? 120000;
9669
- const res = await fetch(url, {
9713
+ const res = await this.fetchSameHost(url, {
9670
9714
  method: "POST",
9671
9715
  headers: {
9672
9716
  Authorization: `Bearer ${token}`,
@@ -9700,7 +9744,7 @@ class MarthaAPIClient {
9700
9744
  if (opts?.body !== undefined)
9701
9745
  this.log(` body: ${JSON.stringify(opts.body).slice(0, 500)}`);
9702
9746
  try {
9703
- return await fetch(url, {
9747
+ return await this.fetchSameHost(url, {
9704
9748
  method,
9705
9749
  headers,
9706
9750
  body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined,
@@ -9748,7 +9792,7 @@ class MarthaAPIClient {
9748
9792
  return url.toString();
9749
9793
  }
9750
9794
  }
9751
- var MIME_TYPES;
9795
+ var MIME_TYPES, MAX_REDIRECTS = 3;
9752
9796
  var init_api_client = __esm(() => {
9753
9797
  init_errors();
9754
9798
  MIME_TYPES = {
@@ -10753,6 +10797,18 @@ function belongsToClient(tokens, clientId) {
10753
10797
  const azp = claims?.azp ?? claims?.client_id;
10754
10798
  return azp ? azp === clientId : true;
10755
10799
  }
10800
+ function tenantFromClaims(payload) {
10801
+ const tid = payload.tenant_id;
10802
+ if (typeof tid === "string" && tid)
10803
+ return tid;
10804
+ const orgs = payload.organizations;
10805
+ if (orgs && typeof orgs === "object" && !Array.isArray(orgs)) {
10806
+ const first = Object.keys(orgs)[0];
10807
+ if (first)
10808
+ return first;
10809
+ }
10810
+ return null;
10811
+ }
10756
10812
  function createContext(opts) {
10757
10813
  const config = loadConfig(opts.configDir);
10758
10814
  const profileName = opts.profileOverride ?? process.env.MARTHA_PROFILE ?? config.current_profile;
@@ -10811,10 +10867,9 @@ function createContext(opts) {
10811
10867
  });
10812
10868
  const getTenantId = async () => {
10813
10869
  const token = await getAccessToken();
10814
- const payload = decodeJwtPayload(token);
10815
- const tid = payload.tenant_id;
10816
- if (typeof tid !== "string" || !tid) {
10817
- throw new CLIError("No tenant_id claim in access token.", 2 /* Auth */);
10870
+ const tid = tenantFromClaims(decodeJwtPayload(token));
10871
+ if (!tid) {
10872
+ throw new CLIError("No tenant_id or organizations claim in access token.", 2 /* Auth */);
10818
10873
  }
10819
10874
  return tid;
10820
10875
  };
@@ -11149,7 +11204,7 @@ init_config();
11149
11204
  init_errors();
11150
11205
 
11151
11206
  // src/version.ts
11152
- var CLI_VERSION = "0.58.1";
11207
+ var CLI_VERSION = "0.61.1";
11153
11208
 
11154
11209
  // src/commands/sessions.ts
11155
11210
  function relativeTime(iso) {
@@ -12831,9 +12886,16 @@ class BrowserSession {
12831
12886
 
12832
12887
  // src/lib/host-files.ts
12833
12888
  import { promises as fs6 } from "node:fs";
12889
+ import * as os3 from "node:os";
12834
12890
  import * as path5 from "node:path";
12835
12891
  var READ_CAP = 256 * 1024;
12836
12892
  function resolvePath(cwd, p) {
12893
+ if (p === "~") {
12894
+ return os3.homedir();
12895
+ }
12896
+ if (p.startsWith("~/")) {
12897
+ return path5.join(os3.homedir(), p.slice(2));
12898
+ }
12837
12899
  return path5.isAbsolute(p) ? p : path5.resolve(cwd, p);
12838
12900
  }
12839
12901
  async function applyFileAction(cwd, req) {
@@ -14787,8 +14849,8 @@ function registerPrincipalCommands(program2) {
14787
14849
  import { accessSync, constants, mkdirSync, writeFileSync } from "node:fs";
14788
14850
  import { existsSync } from "node:fs";
14789
14851
  import { execFile as execFile2, spawn as spawn3 } from "node:child_process";
14790
- import { homedir } from "node:os";
14791
- import { join as join3 } from "node:path";
14852
+ import { homedir as homedir2 } from "node:os";
14853
+ import { join as join4 } from "node:path";
14792
14854
  init_errors();
14793
14855
  var AGENTS_PATH2 = "/api/admin/definitions/agents";
14794
14856
  var SUPPORTED_CHANNELS = ["buzz"];
@@ -14820,35 +14882,35 @@ function buzzAppDataCandidates(platform, env2, home) {
14820
14882
  ];
14821
14883
  const base = () => {
14822
14884
  if (platform === "darwin")
14823
- return join3(home, "Library", "Application Support");
14885
+ return join4(home, "Library", "Application Support");
14824
14886
  if (platform === "win32")
14825
- return env2.APPDATA ?? join3(home, "AppData", "Roaming");
14826
- return env2.XDG_DATA_HOME ?? join3(home, ".local", "share");
14887
+ return env2.APPDATA ?? join4(home, "AppData", "Roaming");
14888
+ return env2.XDG_DATA_HOME ?? join4(home, ".local", "share");
14827
14889
  };
14828
14890
  const root = base();
14829
- return ids.map(({ build, id }) => ({ build, dir: join3(root, id) }));
14891
+ return ids.map(({ build, id }) => ({ build, dir: join4(root, id) }));
14830
14892
  }
14831
14893
  function chooseAppDataDir(candidates, exists) {
14832
14894
  return candidates.find((c) => exists(c.dir)) ?? null;
14833
14895
  }
14834
14896
  function buzzAppCandidates(platform, env2, home) {
14835
14897
  if (platform === "darwin") {
14836
- return ["/Applications/Buzz.app", join3(home, "Applications", "Buzz.app")];
14898
+ return ["/Applications/Buzz.app", join4(home, "Applications", "Buzz.app")];
14837
14899
  }
14838
14900
  if (platform === "win32") {
14839
- const base = env2.LOCALAPPDATA ?? join3(home, "AppData", "Local");
14840
- return [join3(base, "Programs", "Buzz")];
14901
+ const base = env2.LOCALAPPDATA ?? join4(home, "AppData", "Local");
14902
+ return [join4(base, "Programs", "Buzz")];
14841
14903
  }
14842
14904
  return [
14843
14905
  "/opt/Buzz",
14844
14906
  "/usr/bin/buzz-desktop",
14845
- join3(home, ".local", "bin", "buzz-desktop")
14907
+ join4(home, ".local", "bin", "buzz-desktop")
14846
14908
  ];
14847
14909
  }
14848
14910
  var BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases/latest";
14849
14911
  function buzzSidecarDirs(platform, env2, home) {
14850
14912
  if (platform === "darwin") {
14851
- return buzzAppCandidates(platform, env2, home).map((app) => join3(app, "Contents", "MacOS"));
14913
+ return buzzAppCandidates(platform, env2, home).map((app) => join4(app, "Contents", "MacOS"));
14852
14914
  }
14853
14915
  if (platform === "win32") {
14854
14916
  return buzzAppCandidates(platform, env2, home);
@@ -14862,7 +14924,7 @@ function resolveBuzzBinary(bin, opts) {
14862
14924
  if (bin.includes("/") || bin.includes("\\"))
14863
14925
  return null;
14864
14926
  for (const dir of buzzSidecarDirs(opts.platform, opts.env, opts.home)) {
14865
- const candidate = join3(dir, bin);
14927
+ const candidate = join4(dir, bin);
14866
14928
  if (opts.isExecutable(candidate))
14867
14929
  return { path: candidate, source: "desktop" };
14868
14930
  }
@@ -14910,7 +14972,7 @@ function resolveOnPath(bin, env2, platform, isExecutable) {
14910
14972
  const sep = platform === "win32" ? ";" : ":";
14911
14973
  for (const dir of (env2.PATH ?? "").split(sep).filter(Boolean)) {
14912
14974
  for (const ext of exts) {
14913
- const candidate = join3(dir, bin + ext);
14975
+ const candidate = join4(dir, bin + ext);
14914
14976
  if (isExecutable(candidate))
14915
14977
  return candidate;
14916
14978
  }
@@ -14932,7 +14994,7 @@ function whichBuzz(bin) {
14932
14994
  return resolveBuzzBinary(bin, {
14933
14995
  platform: process.platform,
14934
14996
  env: process.env,
14935
- home: homedir(),
14997
+ home: homedir2(),
14936
14998
  isExecutable: executableOnDisk
14937
14999
  });
14938
15000
  }
@@ -15248,14 +15310,14 @@ function registerChannelCommands(program2) {
15248
15310
  console.log(json);
15249
15311
  return;
15250
15312
  }
15251
- const chosen = opts.appData ? { build: "release", dir: String(opts.appData) } : chooseAppDataDir(buzzAppDataCandidates(process.platform, process.env, homedir()), (d) => existsSync(d));
15313
+ const chosen = opts.appData ? { build: "release", dir: String(opts.appData) } : chooseAppDataDir(buzzAppDataCandidates(process.platform, process.env, homedir2()), (d) => existsSync(d));
15252
15314
  if (!chosen) {
15253
- const candidates = buzzAppDataCandidates(process.platform, process.env, homedir());
15254
- const appInstalled = buzzAppCandidates(process.platform, process.env, homedir()).some((p) => existsSync(p));
15315
+ const candidates = buzzAppDataCandidates(process.platform, process.env, homedir2());
15316
+ const appInstalled = buzzAppCandidates(process.platform, process.env, homedir2()).some((p) => existsSync(p));
15255
15317
  throw new CLIError(appInstalled ? "Buzz Desktop has no data directory yet." : "Buzz Desktop is not installed.", 3 /* NotFound */, missingAppDataMessage(candidates, appInstalled));
15256
15318
  }
15257
- const dir = join3(chosen.dir, "custom_harnesses");
15258
- const file = join3(dir, `${def.id}.json`);
15319
+ const dir = join4(chosen.dir, "custom_harnesses");
15320
+ const file = join4(dir, `${def.id}.json`);
15259
15321
  if (existsSync(file) && !opts.force) {
15260
15322
  throw new CLIError(`${file} already exists.`, 5 /* Conflict */, "Re-run with --force to overwrite it.");
15261
15323
  }
@@ -15326,7 +15388,7 @@ function registerChannelCommands(program2) {
15326
15388
  }
15327
15389
  const acp = whichBuzz(opts.buzzAcpBin);
15328
15390
  const childPath = [
15329
- ...buzzSidecarDirs(process.platform, process.env, homedir()).filter((d) => existsSync(d)),
15391
+ ...buzzSidecarDirs(process.platform, process.env, homedir2()).filter((d) => existsSync(d)),
15330
15392
  process.env.PATH ?? ""
15331
15393
  ].filter(Boolean).join(process.platform === "win32" ? ";" : ":");
15332
15394
  if (opts.dryRun) {
@@ -15767,6 +15829,7 @@ function registerConfigCommand(program2) {
15767
15829
  }
15768
15830
 
15769
15831
  // src/commands/definitions-apply.ts
15832
+ import { createHash as createHash3 } from "node:crypto";
15770
15833
  import fs8 from "node:fs";
15771
15834
  import path8 from "node:path";
15772
15835
  init_dist();
@@ -15988,16 +16051,19 @@ var VALID_KINDS = new Set([
15988
16051
  "Function",
15989
16052
  "Workflow",
15990
16053
  "Agent",
15991
- "Trigger"
16054
+ "Trigger",
16055
+ "ClientAccess"
15992
16056
  ]);
15993
16057
  var KIND_TO_API_PATH = {
15994
16058
  Function: "/api/admin/definitions/functions",
15995
16059
  Workflow: "/api/admin/definitions/workflows",
15996
16060
  Agent: "/api/admin/definitions/agents",
15997
- Trigger: "/api/admin/triggers"
16061
+ Trigger: "/api/admin/triggers",
16062
+ ClientAccess: "/api/admin/definitions/client-access"
15998
16063
  };
15999
16064
  var KIND_TO_PLURAL = {
16000
16065
  Function: "Functions",
16066
+ ClientAccess: "Client access",
16001
16067
  Workflow: "Workflows",
16002
16068
  Agent: "Agents",
16003
16069
  Trigger: "Triggers"
@@ -16246,10 +16312,18 @@ function localSubsetOf(local, remote) {
16246
16312
  if (typeof local === "object") {
16247
16313
  const lObj = local;
16248
16314
  const rObj = remote;
16249
- return Object.keys(lObj).every((key) => localSubsetOf(lObj[key], rObj[key]));
16315
+ return Object.keys(lObj).every((key) => {
16316
+ if (key === "value" && typeof lObj.value === "string" && typeof rObj.value_sha256 === "string" && (rObj.value === null || rObj.value === undefined)) {
16317
+ return sha256Prefix(lObj.value) === rObj.value_sha256;
16318
+ }
16319
+ return localSubsetOf(lObj[key], rObj[key]);
16320
+ });
16250
16321
  }
16251
16322
  return false;
16252
16323
  }
16324
+ function sha256Prefix(value) {
16325
+ return createHash3("sha256").update(value, "utf8").digest("hex").slice(0, 16);
16326
+ }
16253
16327
  var SYMBOLS = {
16254
16328
  create: "+",
16255
16329
  update: "~",
@@ -16272,7 +16346,8 @@ function renderPlan(ops) {
16272
16346
  "Function",
16273
16347
  "Workflow",
16274
16348
  "Agent",
16275
- "Trigger"
16349
+ "Trigger",
16350
+ "ClientAccess"
16276
16351
  ]) {
16277
16352
  const kindOps = grouped.get(kind);
16278
16353
  if (!kindOps)
@@ -16321,6 +16396,7 @@ async function executeApply(api, ops, defs, warnings) {
16321
16396
  defsByKey.set(`${d.kind}:${d.name}`, d);
16322
16397
  }
16323
16398
  const results = [];
16399
+ ops = [...ops].sort((a, b) => (a.kind === "ClientAccess" ? 1 : 0) - (b.kind === "ClientAccess" ? 1 : 0));
16324
16400
  for (const op of ops) {
16325
16401
  if (op.action === "skip") {
16326
16402
  results.push({ name: op.name, kind: op.kind, action: "skipped" });
@@ -19238,11 +19314,11 @@ ${items.length} task(s) available`));
19238
19314
  }
19239
19315
 
19240
19316
  // src/commands/runner.ts
19241
- import os3 from "node:os";
19317
+ import os4 from "node:os";
19242
19318
  init_errors();
19243
19319
  function hostLabel() {
19244
19320
  try {
19245
- return os3.hostname() || undefined;
19321
+ return os4.hostname() || undefined;
19246
19322
  } catch {
19247
19323
  return;
19248
19324
  }
@@ -21626,6 +21702,129 @@ ${models.length} models`));
21626
21702
  });
21627
21703
  }
21628
21704
 
21705
+ // src/commands/spend.ts
21706
+ init_errors();
21707
+ var GROUPINGS = ["day", "agent", "model", "surface", "billing_owner"];
21708
+ function usd(value) {
21709
+ return value == null ? "-" : `$${Number(value).toFixed(4)}`;
21710
+ }
21711
+ function registerSpendCommand(program2) {
21712
+ program2.command("spend").description("LLM spend for a tenant, from Martha's own ledger").option("--tenant <id>", "Tenant (defaults to your own; others need super-admin)").option("--group-by <dim>", `One of: ${GROUPINGS.join(", ")}`, "day").option("--since <YYYY-MM-DD>", "Inclusive UTC start day").option("--until <YYYY-MM-DD>", "Exclusive UTC end day").action(async (opts) => {
21713
+ const ctx = createContext({
21714
+ profileOverride: program2.opts().profile,
21715
+ verbose: program2.opts().verbose
21716
+ });
21717
+ if (program2.opts().apiUrl) {
21718
+ ctx.profile.api_url = program2.opts().apiUrl;
21719
+ }
21720
+ const isJson = !!program2.opts().json;
21721
+ if (!GROUPINGS.includes(opts.groupBy)) {
21722
+ throw new CLIError(`--group-by must be one of: ${GROUPINGS.join(", ")}`, 4 /* Validation */);
21723
+ }
21724
+ const tenant = opts.tenant ?? await ctx.getTenantId();
21725
+ const params = new URLSearchParams({ group_by: opts.groupBy });
21726
+ if (opts.since)
21727
+ params.set("since", opts.since);
21728
+ if (opts.until)
21729
+ params.set("until", opts.until);
21730
+ const data = await ctx.api.get(`/api/admin/tenants/${encodeURIComponent(tenant)}/spend?${params.toString()}`);
21731
+ if (isJson) {
21732
+ console.log(JSON.stringify(data, null, 2));
21733
+ return;
21734
+ }
21735
+ if (data.rows.length === 0) {
21736
+ console.log(source_default.dim(`No LLM spend recorded for ${data.tenant_id}.`));
21737
+ return;
21738
+ }
21739
+ const cols = [
21740
+ { header: data.group_by.toUpperCase(), accessor: (r) => r.key ?? "-" },
21741
+ { header: "CALLS", accessor: (r) => String(r.calls) },
21742
+ { header: "PROMPT", accessor: (r) => String(r.prompt_tokens) },
21743
+ { header: "COMPLETION", accessor: (r) => String(r.completion_tokens) },
21744
+ { header: "CACHE READ", accessor: (r) => String(r.cache_read_tokens) },
21745
+ { header: "CACHE WRITE", accessor: (r) => String(r.cache_write_tokens) },
21746
+ { header: "COST", accessor: (r) => usd(r.cost_usd) },
21747
+ { header: "UNPRICED", accessor: (r) => String(r.unpriced_calls) }
21748
+ ];
21749
+ const widths = cols.map((col) => Math.max(col.header.length, ...data.rows.map((r) => col.accessor(r).length)));
21750
+ const header = cols.map((col, i) => col.header.padEnd(widths[i])).join(" ");
21751
+ console.log(source_default.bold(header));
21752
+ console.log(source_default.dim("-".repeat(header.length)));
21753
+ for (const row of data.rows) {
21754
+ console.log(cols.map((col, i) => col.accessor(row).padEnd(widths[i])).join(" "));
21755
+ }
21756
+ const t = data.totals;
21757
+ console.log(source_default.dim(`
21758
+ ${data.tenant_id}: ${t.calls} calls, ${usd(t.cost_usd)}` + (t.unpriced_calls ? `, ${t.unpriced_calls} unpriced (not included in cost)` : "")));
21759
+ });
21760
+ }
21761
+
21762
+ // src/commands/budget.ts
21763
+ init_errors();
21764
+ var PERIODS = ["day", "month"];
21765
+ var ACTIONS = ["warn", "require_approval", "block"];
21766
+ function oneOf(value, allowed, flag) {
21767
+ if (!allowed.includes(value)) {
21768
+ throw new CLIError(`${flag} must be one of: ${allowed.join(", ")}`, 4 /* Validation */);
21769
+ }
21770
+ }
21771
+ function registerBudgetCommands(program2) {
21772
+ const budget = program2.command("budget").description("LLM spend ceilings for a tenant");
21773
+ const context = () => {
21774
+ const ctx = createContext({
21775
+ profileOverride: program2.opts().profile,
21776
+ verbose: program2.opts().verbose
21777
+ });
21778
+ if (program2.opts().apiUrl)
21779
+ ctx.profile.api_url = program2.opts().apiUrl;
21780
+ return ctx;
21781
+ };
21782
+ const path9 = async (ctx, tenant) => `/api/admin/tenants/${encodeURIComponent(tenant ?? await ctx.getTenantId())}/llm-budgets`;
21783
+ budget.command("list").description("List the tenant's ceilings").option("--tenant <id>", "Tenant (defaults to your own)").action(async (opts) => {
21784
+ const ctx = context();
21785
+ const data = await ctx.api.get(await path9(ctx, opts.tenant));
21786
+ if (program2.opts().json) {
21787
+ console.log(JSON.stringify(data, null, 2));
21788
+ return;
21789
+ }
21790
+ if (data.budgets.length === 0) {
21791
+ console.log(source_default.dim(`No LLM spend ceilings for ${data.tenant_id}.`));
21792
+ return;
21793
+ }
21794
+ for (const b of data.budgets) {
21795
+ const scope = b.agent_id ? `agent ${b.agent_id}` : "tenant";
21796
+ console.log(`${b.id} ${scope.padEnd(42)} ${b.period.padEnd(6)} $${b.limit_usd} ${b.action}`);
21797
+ }
21798
+ });
21799
+ budget.command("set").description("Create or replace a ceiling (one per tenant-or-agent + period)").requiredOption("--period <p>", `One of: ${PERIODS.join(", ")}`).requiredOption("--limit <usd>", "Ceiling in USD (cache reads included)").requiredOption("--action <a>", `One of: ${ACTIONS.join(", ")}`).option("--agent <uuid>", "Scope to one AI Agent (default: whole tenant)").option("--note <text>", "Why this ceiling exists").option("--tenant <id>", "Tenant (defaults to your own)").action(async (opts) => {
21800
+ oneOf(opts.period, PERIODS, "--period");
21801
+ oneOf(opts.action, ACTIONS, "--action");
21802
+ if (!/^\d+(\.\d{1,4})?$/.test(opts.limit)) {
21803
+ throw new CLIError("--limit must be a non-negative USD amount", 4 /* Validation */);
21804
+ }
21805
+ const ctx = context();
21806
+ const saved = await ctx.api.put(await path9(ctx, opts.tenant), {
21807
+ period: opts.period,
21808
+ limit_usd: opts.limit,
21809
+ action: opts.action,
21810
+ agent_id: opts.agent ?? null,
21811
+ note: opts.note ?? null
21812
+ });
21813
+ if (program2.opts().json)
21814
+ console.log(JSON.stringify(saved, null, 2));
21815
+ else
21816
+ console.log(`${saved.id} ${saved.period} $${saved.limit_usd} -> ${saved.action}`);
21817
+ });
21818
+ budget.command("rm <budgetId>").description("Delete a ceiling").option("--tenant <id>", "Tenant (defaults to your own)").action(async (budgetId, opts) => {
21819
+ const ctx = context();
21820
+ await ctx.api.del(`${await path9(ctx, opts.tenant)}/${encodeURIComponent(budgetId)}`);
21821
+ if (program2.opts().json)
21822
+ console.log(JSON.stringify({ deleted: budgetId }));
21823
+ else
21824
+ console.log(`deleted ${budgetId}`);
21825
+ });
21826
+ }
21827
+
21629
21828
  // src/commands/wiki.ts
21630
21829
  import fs11 from "node:fs";
21631
21830
  init_errors();
@@ -22481,7 +22680,7 @@ async function resolveCredentialFlag(value) {
22481
22680
 
22482
22681
  // src/commands/policy.ts
22483
22682
  init_errors();
22484
- var ACTIONS = ["allow", "require_approval", "block"];
22683
+ var ACTIONS2 = ["allow", "require_approval", "block"];
22485
22684
  var KINDS2 = ["risk_tag", "risk_level", "capability"];
22486
22685
  var SCOPES = ["any", "agent", "client"];
22487
22686
  var truncate4 = (s, n) => s.length > n ? s.slice(0, n - 1) + "…" : s;
@@ -22551,8 +22750,8 @@ function registerPolicyCommands(program2) {
22551
22750
  console.log(columns.map((c, i) => c.get(r).padEnd(widths[i])).join(" "));
22552
22751
  }
22553
22752
  });
22554
- cmd.command("set-default <action>").description(`Set the opt-in default action (${ACTIONS.join("|")})`).action(async (action) => {
22555
- assertOneOf(action, ACTIONS, "action");
22753
+ cmd.command("set-default <action>").description(`Set the opt-in default action (${ACTIONS2.join("|")})`).action(async (action) => {
22754
+ assertOneOf(action, ACTIONS2, "action");
22556
22755
  const ctx = getCtx();
22557
22756
  const res = await ctx.api.put("/api/admin/policy/settings", { default_action: action });
22558
22757
  if (isJson()) {
@@ -22576,9 +22775,9 @@ function registerPolicyCommands(program2) {
22576
22775
  console.log(`Safe-default bundle ${enabled ? source_default.yellow("enabled") : source_default.dim("disabled")}.`);
22577
22776
  });
22578
22777
  const rule = cmd.command("rule").description("Manage explicit policy rules");
22579
- rule.command("add").description("Create or update a rule").requiredOption(`--kind <kind>`, `Match kind (${KINDS2.join("|")})`).requiredOption("--value <value>", "Match value (a risk tag, level, or capability ref)").requiredOption(`--action <action>`, `Action (${ACTIONS.join("|")})`).option(`--scope <scope>`, `Principal scope (${SCOPES.join("|")})`, "any").option("--note <text>", "Optional human note").action(async (opts) => {
22778
+ rule.command("add").description("Create or update a rule").requiredOption(`--kind <kind>`, `Match kind (${KINDS2.join("|")})`).requiredOption("--value <value>", "Match value (a risk tag, level, or capability ref)").requiredOption(`--action <action>`, `Action (${ACTIONS2.join("|")})`).option(`--scope <scope>`, `Principal scope (${SCOPES.join("|")})`, "any").option("--note <text>", "Optional human note").action(async (opts) => {
22580
22779
  assertOneOf(opts.kind, KINDS2, "--kind");
22581
- assertOneOf(opts.action, ACTIONS, "--action");
22780
+ assertOneOf(opts.action, ACTIONS2, "--action");
22582
22781
  assertOneOf(opts.scope, SCOPES, "--scope");
22583
22782
  const ctx = getCtx();
22584
22783
  const res = await ctx.api.post("/api/admin/policy/rules", {
@@ -22609,6 +22808,7 @@ function registerPolicyCommands(program2) {
22609
22808
  import { readFileSync as readFileSync3 } from "node:fs";
22610
22809
  init_errors();
22611
22810
  var BASE = "/api/admin/agent-step/trust-roots";
22811
+ var FLEET_BASE = "/api/admin/agent-step/fleet";
22612
22812
  function resolveKeyMaterial(value) {
22613
22813
  if (value.startsWith("@")) {
22614
22814
  const path9 = value.slice(1);
@@ -22722,6 +22922,69 @@ function registerAgentStepCommands(program2) {
22722
22922
  console.log(source_default.green(`Rotated trust root '${tr.name}'`));
22723
22923
  printOne(tr);
22724
22924
  });
22925
+ const fleet = cmd.command("fleet").description("Pause / resume this tenant's agent_step fleet (#1041)");
22926
+ function printFleet(s) {
22927
+ const state = s.effective_paused ? source_default.red(`PAUSED (${s.effective_scope})`) : source_default.green("running");
22928
+ console.log(`agent_step fleet: ${source_default.bold(state)}`);
22929
+ if (s.effective_reason)
22930
+ console.log(` Reason: ${s.effective_reason}`);
22931
+ if (s.paused_by)
22932
+ console.log(` Paused by: ${s.paused_by}`);
22933
+ if (s.paused_at)
22934
+ console.log(` Paused at: ${s.paused_at}`);
22935
+ if (s.paused_workflows.length) {
22936
+ console.log(` Quarantined workflows: ${s.paused_workflows.join(", ")}`);
22937
+ }
22938
+ if (s.global_paused) {
22939
+ console.log(source_default.yellow(` NOTE: the PLATFORM-wide break-glass flag is set — every tenant is halted,
22940
+ ` + " and resuming this tenant will not restart work until it is cleared."));
22941
+ }
22942
+ }
22943
+ fleet.command("status").description("Show whether agent_step work is currently halted").action(async () => {
22944
+ const ctx = getCtx();
22945
+ const s = await ctx.api.get(FLEET_BASE);
22946
+ if (isJson()) {
22947
+ console.log(JSON.stringify(s, null, 2));
22948
+ return;
22949
+ }
22950
+ printFleet(s);
22951
+ });
22952
+ fleet.command("pause").description("Halt all agent_step runs for this tenant (in-flight steps drain)").requiredOption("-r, --reason <text>", "Why you are stopping the fleet — recorded in the audit log and shown on stopped runs").action(async (opts) => {
22953
+ const ctx = getCtx();
22954
+ const s = await ctx.api.put(FLEET_BASE, {
22955
+ paused: true,
22956
+ reason: opts.reason
22957
+ });
22958
+ if (isJson()) {
22959
+ console.log(JSON.stringify(s, null, 2));
22960
+ return;
22961
+ }
22962
+ console.log(source_default.yellow(`Fleet paused. No new agent_step will start; in-flight steps finish their
22963
+ ` + "current attempt and then stop instead of retrying."));
22964
+ printFleet(s);
22965
+ });
22966
+ fleet.command("resume").description("Allow agent_step runs for this tenant again").action(async () => {
22967
+ const ctx = getCtx();
22968
+ const s = await ctx.api.put(FLEET_BASE, { paused: false });
22969
+ if (isJson()) {
22970
+ console.log(JSON.stringify(s, null, 2));
22971
+ return;
22972
+ }
22973
+ console.log(source_default.green("Fleet resumed."));
22974
+ printFleet(s);
22975
+ });
22976
+ fleet.command("quarantine [workflows...]").description("Halt only the named workflows (pass none to clear the quarantine list)").action(async (workflows) => {
22977
+ const ctx = getCtx();
22978
+ const s = await ctx.api.put(FLEET_BASE, {
22979
+ paused_workflows: workflows ?? []
22980
+ });
22981
+ if (isJson()) {
22982
+ console.log(JSON.stringify(s, null, 2));
22983
+ return;
22984
+ }
22985
+ console.log(workflows?.length ? source_default.yellow(`Quarantined: ${workflows.join(", ")}`) : source_default.green("Quarantine cleared."));
22986
+ printFleet(s);
22987
+ });
22725
22988
  }
22726
22989
 
22727
22990
  // src/commands/authz.ts
@@ -24142,6 +24405,8 @@ registerNotificationCommands(program2);
24142
24405
  registerMessagingCommands(program2);
24143
24406
  registerClientCommands(program2);
24144
24407
  registerModelsCommand(program2);
24408
+ registerSpendCommand(program2);
24409
+ registerBudgetCommands(program2);
24145
24410
  registerSessionCommands(program2);
24146
24411
  registerMCPCommands(program2);
24147
24412
  registerPolicyCommands(program2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/martha-cli",
3
- "version": "0.58.1",
3
+ "version": "0.61.1",
4
4
  "description": "Terminal-first client for the Martha AI platform",
5
5
  "homepage": "https://docs.martha.nomadriver.co",
6
6
  "repository": {