@aiaiai-pt/martha-cli 0.5.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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
+ ## [Unreleased]
6
+
7
+ ### Changed — 0.5.1 CLI onboarding feedback
8
+ - `martha init` now defaults to the local preset; the hosted Martha cloud profile remains available through explicit `--preset cloud` and uses `keycloak.frank.nomadriver.co`.
9
+ - CLI version output now reads `martha-cli/package.json` instead of a second hardcoded literal.
10
+ - `martha agents create` exposes `--system-prompt` as the primary inline flag and keeps `--prompt` as a compatibility alias.
11
+ - CLI docs and bundled agent skill now explain explicit customer profile setup, `system_prompt`, and the normal chat-client agent grant-as-tool path.
12
+
13
+ ### Added — #407 connections command (service-account Drive auth)
14
+ - `martha connections create|list|update|test|delete` — manage Vault-backed integration connections from the CLI (previously admin-UI / raw-curl only). `create` mirrors `POST /api/admin/connections`; `update` mirrors `PUT`.
15
+ - Service-account Google Drive: `martha connections create --integration google_drive --auth-type service_account --credential-value @sa-key.json --config '{"subject":"...","scopes":[...]}'` reads enterprise Shared Drives without CASA (#407). The SA key shape (`type`, `client_email`) is validated before submit; `--credential-value` accepts `@file` / `-` (stdin) / literal.
16
+ - **SA key rotation** (#407 key-rotation story): `martha connections update <id> --credential-value @new-sa.json` rotates the key in Vault in place and drops the cached SA token so the new key takes effect immediately (no ~1h staleness window).
17
+ - OAuth2 connections are rejected with a clear error (they need interactive browser consent — use the admin UI).
18
+
19
+ ### Added — #372 PR5 inbound Drive folder sync
20
+ - `document-sync reconcile-tree --source <id> | --all [--dry-run]` — backfills the collection tree from a Google Drive source's folder hierarchy. Stamps `drive_folder_id` on existing collections matching `(parent, name)` and creates sub-collections for unmapped Drive folders. Runs server-side against the source's live OAuth token; idempotent on re-run. `--dry-run` previews link/create/skip counts without writing.
21
+
5
22
  ## [0.5.0] — 2026-05-20
6
23
 
7
24
  ### Added — #372 PR4 collection-hierarchy CLI parity
@@ -17,7 +34,7 @@ All notable changes to `@aiaiai-pt/martha-cli`. Format: [Keep a Changelog](https
17
34
  First-run UX for third-party developers and agent runtimes.
18
35
 
19
36
  ### Added
20
- - `martha init` — interactive wizard that writes a profile to `~/.martha/config.yaml`. Two presets: `cloud` (martha.nomadriver.co + auth.nomadriver.co) and `local` (localhost:8080 + 8180). Idempotent with `--force`; rejects overwrites without it.
37
+ - `martha init` — interactive wizard that writes a profile to `~/.martha/config.yaml`. Two presets: `cloud` (martha.nomadriver.co + keycloak.frank.nomadriver.co) and `local` (localhost:8080 + 8180). Idempotent with `--force`; rejects overwrites without it.
21
38
  - `martha doctor` — five-check diagnostic: API health, API/CLI version skew (via the new `GET /api/version` endpoint), Keycloak OIDC discovery, stored token validity, authenticated request. Exits non-zero only on FAIL, not WARN.
22
39
  - `martha skill` — prints the bundled `SKILL.md` to stdout. Agent runtimes that don't read filesystems by convention can now seed prompt context with `npx -y @aiaiai-pt/martha-cli@0.3.0 skill | head -200`.
23
40
 
package/README.md CHANGED
@@ -15,7 +15,7 @@ npx -y @aiaiai-pt/martha-cli@latest --help
15
15
  Pin a version when calling from CI or agent runtimes:
16
16
 
17
17
  ```bash
18
- npx -y @aiaiai-pt/martha-cli@0.2.0 agents list --json
18
+ npx -y @aiaiai-pt/martha-cli@0.5.1 agents list --json
19
19
  ```
20
20
 
21
21
  ### Install globally
@@ -31,9 +31,12 @@ Requires Node.js >= 22.
31
31
 
32
32
  ```bash
33
33
  # 1. Configure a profile — writes ~/.martha/config.yaml
34
- martha init # interactive (defaults to cloud preset)
35
- martha init --preset local # localhost dev stack
36
- martha init --no-interactive --name staging --api-url https://... # scripted
34
+ martha init # interactive, local preset by default
35
+ martha init --preset cloud # martha.nomadriver.co + keycloak.frank.nomadriver.co
36
+ martha init --no-interactive --name acme \
37
+ --api-url https://martha.acme.example \
38
+ --keycloak-url https://auth.acme.example \
39
+ --keycloak-realm acme # scripted customer profile
37
40
 
38
41
  # 2. Authenticate
39
42
  martha auth login # browser flow (PKCE)
@@ -56,15 +59,19 @@ Full command reference: `martha --help` (or any subcommand `--help`).
56
59
  Profiles live at `~/.martha/config.yaml`. Each profile defines:
57
60
 
58
61
  ```yaml
59
- current_profile: default
62
+ current_profile: local
60
63
  profiles:
61
- default:
62
- api_url: https://martha.nomadriver.co
63
- keycloak_url: https://auth.nomadriver.co
64
+ local:
65
+ api_url: http://localhost:8080
66
+ keycloak_url: http://localhost:8180
64
67
  keycloak_realm: frank
65
68
  auth_type: oidc
66
69
  ```
67
70
 
71
+ Install does not create or mutate `~/.martha/config.yaml`. Run `martha init`
72
+ or set `MARTHA_API_URL`, `MARTHA_KEYCLOAK_URL`, and
73
+ `MARTHA_KEYCLOAK_REALM` explicitly for your deployment.
74
+
68
75
  Override per-command:
69
76
 
70
77
  ```bash
@@ -102,12 +109,32 @@ martha skill
102
109
  npx -y @aiaiai-pt/martha-cli@latest skill | head -200
103
110
  ```
104
111
 
112
+ Agent YAML should use `system_prompt` for the durable agent instruction. To
113
+ make an agent callable from a chat client, grant the agent to that client; the
114
+ chat model will call it as a tool:
115
+
116
+ ```yaml
117
+ kind: Agent
118
+ name: support-bot
119
+ system_prompt: You help customers resolve support tickets.
120
+ llm_config:
121
+ provider: anthropic
122
+ model: claude-sonnet-4-6
123
+ loop_config:
124
+ max_iterations: 10
125
+ ```
126
+
127
+ ```bash
128
+ martha definitions apply -f support-bot.yaml --yes
129
+ martha clients grant web-chat agent support-bot
130
+ ```
131
+
105
132
  ## Stability
106
133
 
107
134
  `0.x` releases may change CLI flags or JSON output between minor versions. Pin a version in CI/agent contexts:
108
135
 
109
136
  ```bash
110
- npx -y @aiaiai-pt/martha-cli@0.3.0 ...
137
+ npx -y @aiaiai-pt/martha-cli@0.5.1 ...
111
138
  ```
112
139
 
113
140
  `1.0.0` will commit to a stable contract — see the [tracking issue](https://github.com/westeuropeco/martha/issues/292).
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;
@@ -11043,7 +11025,31 @@ import { createInterface as createInterface2 } from "node:readline";
11043
11025
  init_errors();
11044
11026
 
11045
11027
  // src/version.ts
11046
- var CLI_VERSION = "0.3.0";
11028
+ import fs5 from "node:fs";
11029
+ import path4 from "node:path";
11030
+ import { fileURLToPath } from "node:url";
11031
+ function readPackageVersion() {
11032
+ let dir = path4.dirname(fileURLToPath(import.meta.url));
11033
+ for (let i = 0;i < 5; i += 1) {
11034
+ const packagePath = path4.join(dir, "package.json");
11035
+ if (fs5.existsSync(packagePath)) {
11036
+ try {
11037
+ const parsed = JSON.parse(fs5.readFileSync(packagePath, "utf-8"));
11038
+ if (parsed.name === "@aiaiai-pt/martha-cli" && parsed.version) {
11039
+ return parsed.version;
11040
+ }
11041
+ } catch {
11042
+ return "0.0.0-dev";
11043
+ }
11044
+ }
11045
+ const parent = path4.dirname(dir);
11046
+ if (parent === dir)
11047
+ break;
11048
+ dir = parent;
11049
+ }
11050
+ return "0.0.0-dev";
11051
+ }
11052
+ var CLI_VERSION = readPackageVersion();
11047
11053
 
11048
11054
  // src/commands/sessions.ts
11049
11055
  function relativeTime(iso) {
@@ -11105,7 +11111,8 @@ function printSessionTable(sessions) {
11105
11111
  { header: "LAST ACTIVE", accessor: (r) => r.active },
11106
11112
  { header: "CLIENT", accessor: (r) => r.client }
11107
11113
  ];
11108
- const stripAnsi = (s) => s.replace(/\x1B\[[0-9;]*m/g, "");
11114
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
11115
+ const stripAnsi = (s) => s.replace(ansiPattern, "");
11109
11116
  const widths = cols.map((col) => Math.max(col.header.length, ...rows.map((r) => stripAnsi(col.accessor(r)).length)));
11110
11117
  const header = cols.map((col, i) => col.header.padEnd(widths[i])).join(" ");
11111
11118
  console.log(source_default.bold(header));
@@ -11879,7 +11886,7 @@ function registerConfigCommand(program2) {
11879
11886
  config.current_profile = name;
11880
11887
  }
11881
11888
  saveConfig(config);
11882
- if (!!program2.opts().json) {
11889
+ if (program2.opts().json) {
11883
11890
  console.log(JSON.stringify({
11884
11891
  name,
11885
11892
  profile,
@@ -11923,7 +11930,7 @@ function registerConfigCommand(program2) {
11923
11930
  config.current_profile = remaining[0] ?? "default";
11924
11931
  }
11925
11932
  saveConfig(config);
11926
- if (!!program2.opts().json) {
11933
+ if (program2.opts().json) {
11927
11934
  console.log(JSON.stringify({ name, deleted: true }));
11928
11935
  return;
11929
11936
  }
@@ -11932,8 +11939,8 @@ function registerConfigCommand(program2) {
11932
11939
  }
11933
11940
 
11934
11941
  // src/commands/definitions-apply.ts
11935
- import fs5 from "node:fs";
11936
- import path4 from "node:path";
11942
+ import fs6 from "node:fs";
11943
+ import path5 from "node:path";
11937
11944
  init_dist();
11938
11945
  init_errors();
11939
11946
  var VALID_KINDS = new Set(["Function", "Workflow", "Agent"]);
@@ -11968,20 +11975,20 @@ var SERVER_GENERATED_FIELDS = new Set([
11968
11975
  ]);
11969
11976
  var AGENT_MANAGED_FIELDS = new Set(["functions", "workflows"]);
11970
11977
  function loadDefinitions(inputPath) {
11971
- const resolved = path4.resolve(inputPath);
11972
- if (!fs5.existsSync(resolved)) {
11978
+ const resolved = path5.resolve(inputPath);
11979
+ if (!fs6.existsSync(resolved)) {
11973
11980
  throw new CLIError(`Path not found: ${inputPath}`, 1 /* Error */);
11974
11981
  }
11975
- const stat = fs5.statSync(resolved);
11982
+ const stat = fs6.statSync(resolved);
11976
11983
  if (stat.isDirectory()) {
11977
11984
  return loadDirectory(resolved);
11978
11985
  }
11979
11986
  return loadFile(resolved);
11980
11987
  }
11981
11988
  function loadDirectory(dirPath) {
11982
- const entries = fs5.readdirSync(dirPath).sort();
11989
+ const entries = fs6.readdirSync(dirPath).sort();
11983
11990
  const validExts = new Set([".yaml", ".yml", ".json"]);
11984
- const files = entries.filter((e) => validExts.has(path4.extname(e).toLowerCase())).map((e) => path4.join(dirPath, e));
11991
+ const files = entries.filter((e) => validExts.has(path5.extname(e).toLowerCase())).map((e) => path5.join(dirPath, e));
11985
11992
  if (files.length === 0) {
11986
11993
  throw new CLIError(`No YAML or JSON files found in ${dirPath}`, 4 /* Validation */);
11987
11994
  }
@@ -11992,8 +11999,8 @@ function loadDirectory(dirPath) {
11992
11999
  return results;
11993
12000
  }
11994
12001
  function loadFile(filePath) {
11995
- const content = fs5.readFileSync(filePath, "utf-8");
11996
- const ext = path4.extname(filePath).toLowerCase();
12002
+ const content = fs6.readFileSync(filePath, "utf-8");
12003
+ const ext = path5.extname(filePath).toLowerCase();
11997
12004
  if (ext === ".json") {
11998
12005
  const parsed = parseJsonFile(content, filePath);
11999
12006
  return [toLocalDefinition(parsed, filePath)];
@@ -12415,7 +12422,7 @@ Examples:
12415
12422
  }
12416
12423
 
12417
12424
  // src/commands/definitions-export.ts
12418
- import fs6 from "node:fs";
12425
+ import fs7 from "node:fs";
12419
12426
  init_errors();
12420
12427
  async function exportDefinitions(ctx, opts, isJsonMode) {
12421
12428
  const params = {};
@@ -12431,7 +12438,7 @@ async function exportDefinitions(ctx, opts, isJsonMode) {
12431
12438
  const text = await res.text();
12432
12439
  if (opts.output) {
12433
12440
  try {
12434
- fs6.writeFileSync(opts.output, text);
12441
+ fs7.writeFileSync(opts.output, text);
12435
12442
  } catch (err) {
12436
12443
  throw new CLIError(`Failed to write ${opts.output}: ${err instanceof Error ? err.message : String(err)}`, 1 /* Error */);
12437
12444
  }
@@ -13029,6 +13036,7 @@ Usage: martha workflows execute ${name} --inputs '${JSON.stringify(Object.fromEn
13029
13036
  label: n.label || "-",
13030
13037
  connections: (outgoing.get(n.id) ?? []).join(", ") || "-"
13031
13038
  }));
13039
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
13032
13040
  const cols = [
13033
13041
  { header: "ID", accessor: (r) => r.id, raw: (r) => r.id },
13034
13042
  { header: "TYPE", accessor: (r) => r.type, raw: (r) => r.type },
@@ -13036,7 +13044,7 @@ Usage: martha workflows execute ${name} --inputs '${JSON.stringify(Object.fromEn
13036
13044
  {
13037
13045
  header: "CONNECTIONS",
13038
13046
  accessor: (r) => r.connections,
13039
- raw: (r) => (outgoing.get(r.id) ?? []).map((t) => t.replace(/\x1B\[[0-9;]*m/g, "")).join(", ") || "-"
13047
+ raw: (r) => (outgoing.get(r.id) ?? []).map((t) => t.replace(ansiPattern, "")).join(", ") || "-"
13040
13048
  }
13041
13049
  ];
13042
13050
  const widths = cols.map((col) => Math.max(col.header.length, ...rows.map((r) => col.raw(r).length)));
@@ -13154,8 +13162,8 @@ function registerProjectionCommands(parentCmd, getCtx, isJson) {
13154
13162
  } catch {}
13155
13163
  }
13156
13164
  if (opts.output) {
13157
- const fs7 = await import("node:fs/promises");
13158
- await fs7.writeFile(opts.output, toWrite, "utf-8");
13165
+ const fs8 = await import("node:fs/promises");
13166
+ await fs8.writeFile(opts.output, toWrite, "utf-8");
13159
13167
  if (!isJson()) {
13160
13168
  console.error(source_default.dim(`Wrote ${format} projection of '${name}' to ${opts.output}`));
13161
13169
  }
@@ -13318,16 +13326,20 @@ var agentsConfig = {
13318
13326
  },
13319
13327
  normalizeBody: normalizeAgentBody,
13320
13328
  extraCreateOptions: (cmd) => {
13321
- cmd.option("--name <name>", "Agent name").option("--type <type>", "Agent type (cloud or external)", "cloud").option("--model <model>", "LLM model (e.g. anthropic/claude-sonnet-4-6)").option("--provider <provider>", "LLM provider (anthropic, openai)").option("--prompt <text>", "System prompt").option("--description <text>", "Agent description").option("--temperature <n>", "Temperature (0-1)").option("--max-tokens <n>", "Max output tokens").option("--tags <tags>", "Capability domains (comma-separated)").option("--local-tools <tools>", "Local tools (comma-separated)").option("--auth <method>", "Auth method for external agents: service-account or api-key");
13329
+ cmd.option("--name <name>", "Agent name").option("--type <type>", "Agent type (cloud or external)", "cloud").option("--model <model>", "LLM model (e.g. anthropic/claude-sonnet-4-6)").option("--provider <provider>", "LLM provider (anthropic, openai)").option("--system-prompt <text>", "System prompt").option("--prompt <text>", "Deprecated alias for --system-prompt").option("--description <text>", "Agent description").option("--temperature <n>", "Temperature (0-1)").option("--max-tokens <n>", "Max output tokens").option("--tags <tags>", "Capability domains (comma-separated)").option("--local-tools <tools>", "Local tools (comma-separated)").option("--auth <method>", "Auth method for external agents: service-account or api-key");
13322
13330
  },
13323
13331
  buildInlineBody: (opts) => {
13324
13332
  if (!opts.name)
13325
13333
  return null;
13334
+ if (opts.systemPrompt && opts.prompt) {
13335
+ throw new CLIError("Use only one of --system-prompt or --prompt.", 4 /* Validation */, "--prompt is a backwards-compatible alias; prefer --system-prompt.");
13336
+ }
13326
13337
  const body = { name: opts.name };
13327
13338
  if (opts.description)
13328
13339
  body.description = opts.description;
13329
- if (opts.prompt)
13330
- body.system_prompt = opts.prompt;
13340
+ const systemPrompt = opts.systemPrompt ?? opts.prompt;
13341
+ if (systemPrompt)
13342
+ body.system_prompt = systemPrompt;
13331
13343
  if (opts.type)
13332
13344
  body.agent_type = opts.type;
13333
13345
  if (opts.model)
@@ -13519,7 +13531,7 @@ Usage:
13519
13531
  };
13520
13532
 
13521
13533
  // src/commands/documents.ts
13522
- import fs7 from "node:fs";
13534
+ import fs8 from "node:fs";
13523
13535
  init_errors();
13524
13536
  var TERMINAL_STATUSES2 = new Set(["ready", "error"]);
13525
13537
  var SPINNER_FRAMES2 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -13820,7 +13832,7 @@ ${items.length} collections`));
13820
13832
  });
13821
13833
  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) => {
13822
13834
  const ctx = getCtx();
13823
- if (!fs7.existsSync(filePath)) {
13835
+ if (!fs8.existsSync(filePath)) {
13824
13836
  throw new CLIError(`File not found: ${filePath}`, 4 /* Validation */);
13825
13837
  }
13826
13838
  const result = await ctx.api.upload(`/api/admin/collections/${encodeURIComponent(collectionId)}/documents`, filePath);
@@ -14137,6 +14149,69 @@ ${source_default.bold(`Sources (${result.sources.length} chunks):`)}`);
14137
14149
  });
14138
14150
  }
14139
14151
 
14152
+ // src/commands/document-sync.ts
14153
+ init_errors();
14154
+ function registerDocumentSyncCommands(program2) {
14155
+ const cmd = program2.command("document-sync").description("Manage durable document sync sources (Google Drive, etc.)");
14156
+ function getCtx() {
14157
+ const ctx = createContext({
14158
+ profileOverride: program2.opts().profile,
14159
+ verbose: program2.opts().verbose
14160
+ });
14161
+ if (program2.opts().apiUrl)
14162
+ ctx.profile.api_url = program2.opts().apiUrl;
14163
+ return ctx;
14164
+ }
14165
+ function isJson() {
14166
+ return !!program2.opts().json;
14167
+ }
14168
+ function printSummary(name, s) {
14169
+ const tag = s.dry_run ? source_default.yellow(" (dry-run)") : "";
14170
+ console.log(source_default.bold(`
14171
+ ${name}${tag}`));
14172
+ console.log(source_default.dim("-".repeat(40)));
14173
+ console.log(` Folders walked : ${s.folders_walked}`);
14174
+ console.log(` Linked : ${source_default.cyan(String(s.linked))}`);
14175
+ console.log(` Created : ${source_default.green(String(s.created))}`);
14176
+ console.log(` Skipped : ${source_default.dim(String(s.skipped))}`);
14177
+ }
14178
+ cmd.command("reconcile-tree").description("Walk a Google Drive source's folder tree and mirror it into the " + "collection hierarchy (stamps drive_folder_id, creates sub-collections). " + "Idempotent.").option("--source <id>", "Reconcile a single sync source by id").option("--all", "Reconcile every google_drive source for the tenant").option("--dry-run", "Preview what would change without writing", false).action(async (opts) => {
14179
+ const ctx = getCtx();
14180
+ const dryRun = !!opts.dryRun;
14181
+ if (!opts.source && !opts.all) {
14182
+ throw new CLIError("Specify --source <id> or --all", 4 /* Validation */, "Use `martha document-sync reconcile-tree --source <id>` or `--all`.");
14183
+ }
14184
+ if (opts.source && opts.all) {
14185
+ throw new CLIError("--source and --all are mutually exclusive", 4 /* Validation */);
14186
+ }
14187
+ const params = { dry_run: String(dryRun) };
14188
+ let sources;
14189
+ if (opts.all) {
14190
+ const all = await ctx.api.get("/api/admin/document-sync/sources", { params: { provider: "google_drive" } });
14191
+ sources = all;
14192
+ } else {
14193
+ sources = [
14194
+ { id: opts.source, name: opts.source, provider: "google_drive" }
14195
+ ];
14196
+ }
14197
+ const results = [];
14198
+ for (const src of sources) {
14199
+ const summary = await ctx.api.post(`/api/admin/document-sync/sources/${encodeURIComponent(src.id)}/reconcile-tree`, undefined, { params });
14200
+ results.push(summary);
14201
+ if (!isJson())
14202
+ printSummary(src.name, summary);
14203
+ }
14204
+ if (isJson()) {
14205
+ console.log(JSON.stringify({ dry_run: dryRun, results }, null, 2));
14206
+ return;
14207
+ }
14208
+ if (results.length === 0) {
14209
+ console.log(source_default.dim(`
14210
+ No google_drive sources found.`));
14211
+ }
14212
+ });
14213
+ }
14214
+
14140
14215
  // src/commands/approvals.ts
14141
14216
  init_errors();
14142
14217
  var truncate = (s, n) => s.length > n ? s.slice(0, n - 1) + "…" : s;
@@ -15203,8 +15278,8 @@ ${data.length} spec(s)`));
15203
15278
  Resources:
15204
15279
  `));
15205
15280
  for (const r of resources) {
15206
- const path5 = r.path || `/${r.name}`;
15207
- console.log(` ${source_default.cyan(r.label || r.name)}` + source_default.dim(` → ${path5}`));
15281
+ const path6 = r.path || `/${r.name}`;
15282
+ console.log(` ${source_default.cyan(r.label || r.name)}` + source_default.dim(` → ${path6}`));
15208
15283
  }
15209
15284
  }
15210
15285
  try {
@@ -15224,14 +15299,14 @@ Functions:
15224
15299
  console.log(source_default.dim(`
15225
15300
  Proxy: martha integrations proxy ${name} GET /<path>`));
15226
15301
  });
15227
- cmd.command("proxy <name> <method> <path>").description("Send a request through the plugin proxy").option("--data <json>", "JSON request body").option("--query <params>", "Query params as key=val&key=val").action(async (name, method, path5, opts) => {
15302
+ cmd.command("proxy <name> <method> <path>").description("Send a request through the plugin proxy").option("--data <json>", "JSON request body").option("--query <params>", "Query params as key=val&key=val").action(async (name, method, path6, opts) => {
15228
15303
  const ctx = getCtx();
15229
15304
  const upperMethod = method.toUpperCase();
15230
15305
  const allowed = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
15231
15306
  if (!allowed.has(upperMethod)) {
15232
15307
  throw new CLIError(`Invalid method: ${method}`, 4 /* Validation */, "Allowed: GET, POST, PUT, PATCH, DELETE");
15233
15308
  }
15234
- const cleanPath = path5.startsWith("/") ? path5.slice(1) : path5;
15309
+ const cleanPath = path6.startsWith("/") ? path6.slice(1) : path6;
15235
15310
  const proxyUrl = `/api/admin/plugins/${encodeURIComponent(name)}/${cleanPath}`;
15236
15311
  const params = {};
15237
15312
  if (opts.query) {
@@ -15289,6 +15364,194 @@ function renderTable(columns, items, opts) {
15289
15364
  }
15290
15365
  }
15291
15366
 
15367
+ // src/commands/connections.ts
15368
+ init_errors();
15369
+ function registerConnectionCommands(program2) {
15370
+ const cmd = program2.command("connections").description("Manage Vault-backed integration connections (credentials live in Vault)");
15371
+ function getCtx() {
15372
+ const ctx = createContext({
15373
+ profileOverride: program2.opts().profile,
15374
+ verbose: program2.opts().verbose
15375
+ });
15376
+ if (program2.opts().apiUrl)
15377
+ ctx.profile.api_url = program2.opts().apiUrl;
15378
+ return ctx;
15379
+ }
15380
+ function isJson() {
15381
+ return !!program2.opts().json;
15382
+ }
15383
+ cmd.command("list").description("List connections for the current tenant").option("--integration <name>", "Filter by integration name").option("--scope <scope>", "Filter by scope (tenant|client|system)").action(async (opts) => {
15384
+ const ctx = getCtx();
15385
+ const query = new URLSearchParams;
15386
+ if (opts.integration)
15387
+ query.set("integration_name", opts.integration);
15388
+ if (opts.scope)
15389
+ query.set("scope", opts.scope);
15390
+ const suffix = query.toString() ? `?${query.toString()}` : "";
15391
+ const connections = await ctx.api.get(`/api/admin/connections${suffix}`);
15392
+ if (isJson()) {
15393
+ console.log(JSON.stringify(connections, null, 2));
15394
+ return;
15395
+ }
15396
+ if (connections.length === 0) {
15397
+ console.log(source_default.dim("No connections configured."));
15398
+ return;
15399
+ }
15400
+ console.log(source_default.bold(`
15401
+ Connections`));
15402
+ console.log(source_default.dim("-".repeat(60)));
15403
+ for (const c of connections) {
15404
+ const dflt = c.is_default ? source_default.green(" [default]") : "";
15405
+ console.log(` ${source_default.cyan(c.integration_name.padEnd(18))} ${c.name}${dflt} ` + `${source_default.dim(c.auth_type)} (${c.status}) ${source_default.dim(c.id)}`);
15406
+ }
15407
+ console.log();
15408
+ });
15409
+ cmd.command("create").description("Create a connection. For service_account, --credential-value is the SA " + "JSON key (use '@path' to read a file or '-' for stdin) and --config " + `carries non-secret settings, e.g. '{"subject":"u@corp.com","scopes":["https://www.googleapis.com/auth/drive.readonly"]}'. ` + "OAuth2 connections must be created in the admin UI (browser consent).").requiredOption("--integration <name>", "Integration name (e.g. google_drive)").requiredOption("--name <name>", "Connection name (unique per integration)").option("--auth-type <type>", "Auth type: api_key | bearer | basic | service_account", "api_key").option("--credential-value <value>", "Secret material. For service_account: the SA JSON key. " + "Use '-' to read stdin, '@path' to read a file.").option("--config <json>", "Non-secret config JSON object (stored in Postgres, not Vault)").option("--scope <scope>", "Connection scope (tenant|client|system)", "tenant").option("--scope-ref <ref>", "Scope reference (required for client scope)").option("--not-default", "Do not mark as default for this integration").action(async (opts) => {
15410
+ if (opts.authType === "oauth2") {
15411
+ throw new CLIError("OAuth2 connections cannot be created from the CLI — they require " + "an interactive browser consent flow.", 4 /* Validation */, "Create OAuth2 connections in the admin UI under Integrations → Connections.");
15412
+ }
15413
+ const credentialValue = await resolveCredentialValue(opts.credentialValue);
15414
+ if (!credentialValue) {
15415
+ throw new CLIError("--credential-value is required (use '-' for stdin or '@path' for a file).", 4 /* Validation */);
15416
+ }
15417
+ if (opts.authType === "service_account") {
15418
+ validateServiceAccountKey(credentialValue);
15419
+ }
15420
+ let config;
15421
+ if (opts.config) {
15422
+ let parsed;
15423
+ try {
15424
+ parsed = JSON.parse(opts.config);
15425
+ } catch {
15426
+ throw new CLIError("--config must be valid JSON.", 4 /* Validation */);
15427
+ }
15428
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
15429
+ throw new CLIError("--config must be a JSON object.", 4 /* Validation */);
15430
+ }
15431
+ config = parsed;
15432
+ }
15433
+ if (opts.scope === "client" && !opts.scopeRef) {
15434
+ throw new CLIError("--scope-ref is required when --scope is 'client'.", 4 /* Validation */);
15435
+ }
15436
+ const ctx = getCtx();
15437
+ const resp = await ctx.api.post("/api/admin/connections", {
15438
+ integration_name: opts.integration,
15439
+ name: opts.name,
15440
+ auth_type: opts.authType,
15441
+ credential_value: credentialValue,
15442
+ config,
15443
+ scope: opts.scope,
15444
+ scope_ref: opts.scope === "client" ? opts.scopeRef : undefined,
15445
+ is_default: !opts.notDefault
15446
+ });
15447
+ if (isJson()) {
15448
+ console.log(JSON.stringify(resp, null, 2));
15449
+ return;
15450
+ }
15451
+ console.log(source_default.green(`Created ${opts.integration} connection '${opts.name}' ` + `(id=${resp.id}, auth=${resp.auth_type}, status=${resp.status})`));
15452
+ });
15453
+ cmd.command("update <connection_id>").description("Update a connection — rotate the credential, rename, or change config/default. " + "For a service_account, --credential-value is the NEW SA JSON key; rotation " + "drops the cached SA token so the new key takes effect immediately.").option("--credential-value <value>", "New secret material. '@path' reads a file, '-' reads stdin.").option("--config <json>", "Replace the non-secret config JSON object.").option("--name <name>", "Rename the connection.").option("--default", "Mark as the default connection for this integration.").option("--not-default", "Unmark as default.").action(async (connectionId, opts) => {
15454
+ const body = {};
15455
+ if (opts.name)
15456
+ body.name = opts.name;
15457
+ if (opts.default)
15458
+ body.is_default = true;
15459
+ if (opts.notDefault)
15460
+ body.is_default = false;
15461
+ if (opts.config) {
15462
+ let parsed;
15463
+ try {
15464
+ parsed = JSON.parse(opts.config);
15465
+ } catch {
15466
+ throw new CLIError("--config must be valid JSON.", 4 /* Validation */);
15467
+ }
15468
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
15469
+ throw new CLIError("--config must be a JSON object.", 4 /* Validation */);
15470
+ }
15471
+ body.config = parsed;
15472
+ }
15473
+ const credentialValue = await resolveCredentialValue(opts.credentialValue);
15474
+ if (credentialValue)
15475
+ body.credential_value = credentialValue;
15476
+ if (Object.keys(body).length === 0) {
15477
+ throw new CLIError("Nothing to update — pass --credential-value, --config, --name, or --default/--not-default.", 4 /* Validation */);
15478
+ }
15479
+ const ctx = getCtx();
15480
+ const resp = await ctx.api.put(`/api/admin/connections/${connectionId}`, body);
15481
+ if (isJson()) {
15482
+ console.log(JSON.stringify(resp, null, 2));
15483
+ return;
15484
+ }
15485
+ const rotated = body.credential_value ? " (credential rotated)" : "";
15486
+ console.log(source_default.green(`Updated connection ${resp.id}${rotated}`));
15487
+ });
15488
+ cmd.command("test <connection_id>").description("Test a connection's credentials against the integration").action(async (connectionId) => {
15489
+ const ctx = getCtx();
15490
+ const result = await ctx.api.post(`/api/admin/connections/${connectionId}/test`, {});
15491
+ if (isJson()) {
15492
+ console.log(JSON.stringify(result, null, 2));
15493
+ return;
15494
+ }
15495
+ if (result.ok) {
15496
+ console.log(source_default.green("OK: connection test passed"));
15497
+ } else {
15498
+ console.log(source_default.red(`FAILED: ${result.error ?? "unknown error"}`));
15499
+ process.exitCode = 1;
15500
+ }
15501
+ });
15502
+ cmd.command("delete <connection_id>").description("Delete a connection (and its Vault credential). Requires --yes; " + "interactive prompts are intentionally NOT supported (CI would hang).").option("--yes", "Confirm deletion. Required.").action(async (connectionId, opts) => {
15503
+ if (!opts.yes) {
15504
+ throw new CLIError(`Pass --yes to confirm deletion of connection ${connectionId}.`, 4 /* Validation */, `Example: martha connections delete ${connectionId} --yes`);
15505
+ }
15506
+ const ctx = getCtx();
15507
+ await ctx.api.del(`/api/admin/connections/${connectionId}`);
15508
+ if (isJson()) {
15509
+ console.log(JSON.stringify({ deleted: connectionId }, null, 2));
15510
+ return;
15511
+ }
15512
+ console.log(source_default.green(`Deleted connection ${connectionId}`));
15513
+ });
15514
+ }
15515
+ async function resolveCredentialValue(value) {
15516
+ if (!value)
15517
+ return;
15518
+ if (value === "-")
15519
+ return readStdin();
15520
+ if (value.startsWith("@")) {
15521
+ const fs9 = await import("node:fs/promises");
15522
+ return (await fs9.readFile(value.slice(1), "utf-8")).trim();
15523
+ }
15524
+ return value;
15525
+ }
15526
+ function validateServiceAccountKey(raw) {
15527
+ let parsed;
15528
+ try {
15529
+ parsed = JSON.parse(raw);
15530
+ } catch {
15531
+ throw new CLIError("service_account --credential-value must be valid JSON (the SA key file).", 4 /* Validation */);
15532
+ }
15533
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
15534
+ throw new CLIError("service_account key must be a JSON object.", 4 /* Validation */);
15535
+ }
15536
+ const key = parsed;
15537
+ if (key.type !== "service_account") {
15538
+ throw new CLIError('This is not a service account key (expected "type": "service_account").', 4 /* Validation */, "Download the key from GCP → IAM → Service Accounts → Keys → JSON.");
15539
+ }
15540
+ if (typeof key.client_email !== "string" || !key.client_email.trim()) {
15541
+ throw new CLIError("service_account key is missing client_email.", 4 /* Validation */);
15542
+ }
15543
+ }
15544
+ async function readStdin() {
15545
+ if (process.stdin.isTTY) {
15546
+ throw new CLIError("--credential-value '-' requires piped stdin, but stdin is a terminal.", 4 /* Validation */, "Pipe the key in: `cat sa.json | martha connections create ... --credential-value -`, or use '@path' to read from a file.");
15547
+ }
15548
+ let out = "";
15549
+ process.stdin.setEncoding("utf-8");
15550
+ for await (const chunk of process.stdin)
15551
+ out += chunk;
15552
+ return out.trim();
15553
+ }
15554
+
15292
15555
  // src/commands/notifications.ts
15293
15556
  var CHANNEL_IDS = new Set(["resend", "slack_webhook", "webhook"]);
15294
15557
  function registerNotificationCommands(program2) {
@@ -15369,10 +15632,10 @@ Notification connections`));
15369
15632
  }
15370
15633
  let credentialValue = opts.credentialValue;
15371
15634
  if (credentialValue === "-") {
15372
- credentialValue = await readStdin();
15635
+ credentialValue = await readStdin2();
15373
15636
  } else if (credentialValue?.startsWith("@")) {
15374
- const fs8 = await import("node:fs/promises");
15375
- credentialValue = await fs8.readFile(credentialValue.slice(1), "utf-8");
15637
+ const fs9 = await import("node:fs/promises");
15638
+ credentialValue = await fs9.readFile(credentialValue.slice(1), "utf-8");
15376
15639
  }
15377
15640
  if (!credentialValue) {
15378
15641
  throw new Error("--credential-value is required (use '-' for stdin or '@path' for file).");
@@ -15409,7 +15672,7 @@ Notification connections`));
15409
15672
  console.log(source_default.green(`Deleted connection ${connectionId}`));
15410
15673
  });
15411
15674
  }
15412
- async function readStdin() {
15675
+ async function readStdin2() {
15413
15676
  if (process.stdin.isTTY) {
15414
15677
  throw new Error("--credential-value '-' requires piped stdin, but stdin is a terminal. " + 'Either pipe JSON in: `echo \'{"...": "..."}\' | martha ...`, ' + "or use '@path' to read from a file.");
15415
15678
  }
@@ -15422,11 +15685,11 @@ async function readStdin() {
15422
15685
 
15423
15686
  // src/commands/messaging.ts
15424
15687
  init_errors();
15425
- async function messagingFetch(baseUrl, path5, opts) {
15426
- if (/^(https?:)?\/\//i.test(path5)) {
15688
+ async function messagingFetch(baseUrl, path6, opts) {
15689
+ if (/^(https?:)?\/\//i.test(path6)) {
15427
15690
  throw new CLIError("Absolute URL paths are not allowed", 1 /* Error */);
15428
15691
  }
15429
- const url = new URL(path5, baseUrl);
15692
+ const url = new URL(path6, baseUrl);
15430
15693
  const headers = {
15431
15694
  "Content-Type": "application/json"
15432
15695
  };
@@ -16716,7 +16979,7 @@ ${models.length} models`));
16716
16979
  }
16717
16980
 
16718
16981
  // src/commands/wiki.ts
16719
- import fs8 from "node:fs";
16982
+ import fs9 from "node:fs";
16720
16983
  init_errors();
16721
16984
  function registerWikiCommands(program2) {
16722
16985
  const cmd = program2.command("wiki").description("Manage tenant wiki pages, settings, schema, recompile (#245 D5.4)");
@@ -16763,14 +17026,14 @@ function registerWikiCommands(program2) {
16763
17026
  console.log(source_default.dim(`
16764
17027
  ${pages.length} pages`));
16765
17028
  });
16766
- cmd.command("get <path>").description("Fetch the raw markdown body of a wiki page").option("--out <file>", "Write body to file instead of stdout").action(async (path5, opts) => {
17029
+ cmd.command("get <path>").description("Fetch the raw markdown body of a wiki page").option("--out <file>", "Write body to file instead of stdout").action(async (path6, opts) => {
16767
17030
  const ctx = getCtx();
16768
- const safe = path5.split("/").map(encodeURIComponent).join("/");
17031
+ const safe = path6.split("/").map(encodeURIComponent).join("/");
16769
17032
  const resp = await ctx.api.getRaw(`/api/wiki/pages/${safe}`, {
16770
17033
  headers: { Accept: "text/markdown,*/*" }
16771
17034
  });
16772
17035
  if (resp.status === 404) {
16773
- throw new CLIError(`page not found: ${path5}`, 3 /* NotFound */);
17036
+ throw new CLIError(`page not found: ${path6}`, 3 /* NotFound */);
16774
17037
  }
16775
17038
  if (!resp.ok) {
16776
17039
  const detail = await resp.text();
@@ -16778,16 +17041,16 @@ ${pages.length} pages`));
16778
17041
  }
16779
17042
  const body = await resp.text();
16780
17043
  if (opts.out) {
16781
- fs8.writeFileSync(opts.out, body);
17044
+ fs9.writeFileSync(opts.out, body);
16782
17045
  if (!isJson()) {
16783
17046
  console.log(source_default.dim(`wrote ${body.length} bytes to ${opts.out}`));
16784
17047
  } else {
16785
- console.log(JSON.stringify({ path: path5, bytes: body.length, file: opts.out }));
17048
+ console.log(JSON.stringify({ path: path6, bytes: body.length, file: opts.out }));
16786
17049
  }
16787
17050
  return;
16788
17051
  }
16789
17052
  if (isJson()) {
16790
- console.log(JSON.stringify({ path: path5, body, etag: resp.headers.get("ETag") }));
17053
+ console.log(JSON.stringify({ path: path6, body, etag: resp.headers.get("ETag") }));
16791
17054
  } else {
16792
17055
  process.stdout.write(body);
16793
17056
  }
@@ -16882,10 +17145,10 @@ ${pages.length} pages`));
16882
17145
  }
16883
17146
  if (opts.compilePromptOverrideFile) {
16884
17147
  const file = String(opts.compilePromptOverrideFile);
16885
- if (!fs8.existsSync(file)) {
17148
+ if (!fs9.existsSync(file)) {
16886
17149
  throw new CLIError(`override file not found: ${file}`, 3 /* NotFound */);
16887
17150
  }
16888
- const content = fs8.readFileSync(file, "utf8");
17151
+ const content = fs9.readFileSync(file, "utf8");
16889
17152
  const bytes = Buffer.byteLength(content, "utf8");
16890
17153
  if (bytes > 16 * 1024) {
16891
17154
  throw new CLIError(`compile prompt override exceeds 16 KB (${bytes} bytes)`, 4 /* Validation */);
@@ -16909,7 +17172,7 @@ ${pages.length} pages`));
16909
17172
  const ctx = getCtx();
16910
17173
  const resp = await ctx.api.get("/api/wiki/schema");
16911
17174
  if (opts.out) {
16912
- fs8.writeFileSync(opts.out, resp.body);
17175
+ fs9.writeFileSync(opts.out, resp.body);
16913
17176
  if (!isJson()) {
16914
17177
  console.log(source_default.dim(`wrote ${resp.body.length} bytes to ${opts.out}`));
16915
17178
  } else {
@@ -16925,10 +17188,10 @@ ${pages.length} pages`));
16925
17188
  });
16926
17189
  schemaCmd.command("set").description("Replace the tenant wiki schema with the contents of <file>").requiredOption("--file <file>", "Path to schema markdown file").action(async (opts) => {
16927
17190
  const ctx = getCtx();
16928
- if (!fs8.existsSync(opts.file)) {
17191
+ if (!fs9.existsSync(opts.file)) {
16929
17192
  throw new CLIError(`schema file not found: ${opts.file}`, 3 /* NotFound */);
16930
17193
  }
16931
- const body = fs8.readFileSync(opts.file, "utf8");
17194
+ const body = fs9.readFileSync(opts.file, "utf8");
16932
17195
  const bytes = Buffer.byteLength(body, "utf8");
16933
17196
  if (bytes > 64 * 1024) {
16934
17197
  throw new CLIError(`schema body exceeds 64 KB (${bytes} bytes)`, 4 /* Validation */);
@@ -16966,7 +17229,7 @@ var PRESETS = {
16966
17229
  cloud: {
16967
17230
  name: "cloud",
16968
17231
  api_url: "https://martha.nomadriver.co",
16969
- keycloak_url: "https://auth.nomadriver.co",
17232
+ keycloak_url: "https://keycloak.frank.nomadriver.co",
16970
17233
  keycloak_realm: "frank"
16971
17234
  },
16972
17235
  local: {
@@ -16981,7 +17244,7 @@ async function prompt2(rl, question, fallback) {
16981
17244
  return answer.trim() || fallback;
16982
17245
  }
16983
17246
  async function initCommand(opts) {
16984
- const presetKey = opts.preset ?? "cloud";
17247
+ const presetKey = opts.preset ?? "local";
16985
17248
  const preset = PRESETS[presetKey];
16986
17249
  if (!preset) {
16987
17250
  throw new CLIError(`Unknown preset: ${presetKey}. Choose one of: ${Object.keys(PRESETS).join(", ")}.`, 4 /* Validation */);
@@ -16992,7 +17255,7 @@ async function initCommand(opts) {
16992
17255
  throw new CLIError(`Profile ${source_default.cyan(profileName)} already exists. Re-run with --force to overwrite, or pass --profile <name> to add a new one.`, 5 /* Conflict */);
16993
17256
  }
16994
17257
  const interactive = !opts.noInteractive && process.stdin.isTTY && !opts.apiUrl && !opts.keycloakUrl && !opts.keycloakRealm;
16995
- let profile = {
17258
+ const profile = {
16996
17259
  api_url: opts.apiUrl ?? preset.api_url,
16997
17260
  keycloak_url: opts.keycloakUrl ?? preset.keycloak_url,
16998
17261
  keycloak_realm: opts.keycloakRealm ?? preset.keycloak_realm,
@@ -17024,6 +17287,7 @@ Martha CLI — first-run setup
17024
17287
  console.log();
17025
17288
  console.log(source_default.green(`Profile saved.
17026
17289
  `));
17290
+ console.log(` Preset: ${source_default.cyan(preset.name)}`);
17027
17291
  console.log(` Profile: ${source_default.cyan(profileName)}`);
17028
17292
  console.log(` API URL: ${profile.api_url}`);
17029
17293
  console.log(` Keycloak: ${profile.keycloak_url}`);
@@ -17035,7 +17299,7 @@ Martha CLI — first-run setup
17035
17299
  console.log(source_default.dim(" martha doctor # verify setup"));
17036
17300
  }
17037
17301
  function registerInitCommand(program2) {
17038
- program2.command("init").description("Create or update a profile in ~/.martha/config.yaml").option("--preset <name>", "Preset: cloud or local", "cloud").option("--name <name>", "Profile name (defaults to preset name)").option("--force", "Overwrite an existing profile").option("--api-url <url>", "Override the API URL").option("--keycloak-url <url>", "Override the Keycloak URL").option("--keycloak-realm <realm>", "Override the Keycloak realm").option("--no-interactive", "Skip prompts; use defaults / overrides only").action(async (opts) => {
17302
+ program2.command("init").description("Create or update a profile in ~/.martha/config.yaml").option("--preset <name>", "Preset: local or cloud", "local").option("--name <name>", "Profile name (defaults to preset name)").option("--force", "Overwrite an existing profile").option("--api-url <url>", "Override the API URL").option("--keycloak-url <url>", "Override the Keycloak URL").option("--keycloak-realm <realm>", "Override the Keycloak realm").option("--no-interactive", "Skip prompts; use defaults / overrides only").action(async (opts) => {
17039
17303
  await initCommand(opts);
17040
17304
  });
17041
17305
  }
@@ -17283,19 +17547,19 @@ function registerDoctorCommand(program2) {
17283
17547
 
17284
17548
  // src/commands/skill.ts
17285
17549
  init_errors();
17286
- import fs9 from "node:fs";
17287
- import path5 from "node:path";
17288
- import { fileURLToPath } from "node:url";
17550
+ import fs10 from "node:fs";
17551
+ import path6 from "node:path";
17552
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
17289
17553
  function locateSkill() {
17290
- const here = path5.dirname(fileURLToPath(import.meta.url));
17554
+ const here = path6.dirname(fileURLToPath2(import.meta.url));
17291
17555
  const candidates = [
17292
- path5.join(here, "skills", "martha-cli", "SKILL.md"),
17293
- path5.join(here, "..", "skills", "martha-cli", "SKILL.md"),
17294
- path5.join(here, "..", "..", "..", "skills", "martha-cli", "SKILL.md"),
17295
- path5.join(here, "..", "..", "skills", "martha-cli", "SKILL.md")
17556
+ path6.join(here, "skills", "martha-cli", "SKILL.md"),
17557
+ path6.join(here, "..", "skills", "martha-cli", "SKILL.md"),
17558
+ path6.join(here, "..", "..", "..", "skills", "martha-cli", "SKILL.md"),
17559
+ path6.join(here, "..", "..", "skills", "martha-cli", "SKILL.md")
17296
17560
  ];
17297
17561
  for (const p of candidates) {
17298
- if (fs9.existsSync(p))
17562
+ if (fs10.existsSync(p))
17299
17563
  return p;
17300
17564
  }
17301
17565
  return null;
@@ -17305,7 +17569,7 @@ async function skillCommand() {
17305
17569
  if (!skillPath) {
17306
17570
  throw new CLIError("SKILL.md not found in the installed package. Reinstall via `npm i -g @aiaiai-pt/martha-cli` or `npx -y @aiaiai-pt/martha-cli@latest skill`.", 1 /* Error */);
17307
17571
  }
17308
- const body = fs9.readFileSync(skillPath, "utf-8");
17572
+ const body = fs10.readFileSync(skillPath, "utf-8");
17309
17573
  process.stdout.write(body);
17310
17574
  }
17311
17575
  function registerSkillCommand(program2) {
@@ -17364,11 +17628,13 @@ registerDefinitionCommands(program2, agentsConfig);
17364
17628
  registerDefinitionsApply(program2);
17365
17629
  registerDefinitionsExport(program2);
17366
17630
  registerDocumentCommands(program2);
17631
+ registerDocumentSyncCommands(program2);
17367
17632
  registerWikiCommands(program2);
17368
17633
  registerApprovalCommands(program2);
17369
17634
  registerTaskCommands(program2);
17370
17635
  registerTeamCommands(program2);
17371
17636
  registerIntegrationCommands(program2);
17637
+ registerConnectionCommands(program2);
17372
17638
  registerNotificationCommands(program2);
17373
17639
  registerMessagingCommands(program2);
17374
17640
  registerClientCommands(program2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/martha-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Terminal-first client for the Martha AI platform",
5
5
  "homepage": "https://docs.martha.nomadriver.co",
6
6
  "repository": {
@@ -15,7 +15,7 @@ Martha has a few distinct primitives that get confused in everyday talk. This is
15
15
  |---|---|---|
16
16
  | **Tenant** | Opaque data isolation boundary (`tenant_id` string). All queries filter by this. Set from the JWT, never from request body. | Every entity is tenant-scoped. You don't pass it explicitly to the CLI — it comes from your token. |
17
17
  | **Client** | A chat-API consumer (web app, SMS sender, voice line). Has `keycloak_client_id`, `system_prompts`, allowlists. **Not a tenant.** | Use when you're configuring how a frontend or messaging channel talks to Martha. |
18
- | **Agent** | An `AgentDefinition` row: prompt + LLM config + loop config + tool grants. Cloud or external. | Cloud agents are run by Martha (Temporal). External agents are remote harnesses that authenticate and execute Martha tasks. |
18
+ | **Agent** | An `AgentDefinition` row: `system_prompt` + LLM config + loop config + tool grants. Cloud or external. | Cloud agents are run by Martha (Temporal). External agents are remote harnesses that authenticate and execute Martha tasks. |
19
19
  | **Team** | A named group of agents with a routing strategy (`round_robin`, `manual`, `external`). | Use to spread work across many similar agents (e.g. 5 ork instances doing code review). |
20
20
  | **Task** | A unit of work with goal, priority, lifecycle (`open`/`claimed`/`running`/`completed`/`failed`/`cancelled`/`stale`/`poisoned`). Optionally linked to a tracker issue. | Use to queue async work for agents. Humans or agents can create them. |
21
21
  | **Function** | An HTTP endpoint or platform Python callable that an agent can invoke as a tool. Stored as `FunctionDefinition`. | Define once, grant to many agents. |
@@ -35,7 +35,7 @@ Everything below operates on these primitives. When in doubt, run `martha status
35
35
  | Flag | Purpose |
36
36
  |---|---|
37
37
  | `--json` | Machine-readable JSON output. **Always set this when piping to `jq` or parsing.** Without it, output is human-formatted and may include color codes. |
38
- | `--profile <name>` | Use a named profile from `~/.martha/profiles/`. Default profile is `default`. |
38
+ | `--profile <name>` | Use a named profile from `~/.martha/config.yaml`. |
39
39
  | `--api-url <url>` | Override `MARTHA_API_URL`. Useful for hitting staging/prod from the same shell. |
40
40
  | `--verbose` | DEBUG-level logging on stderr. Shows HTTP requests, retry attempts, token expiry decisions. |
41
41
  | `--quiet` | Suppress informational output. Errors still print. |
@@ -47,11 +47,28 @@ Everything below operates on these primitives. When in doubt, run `martha status
47
47
 
48
48
  ## Authentication
49
49
 
50
+ Install does not pre-provision a global profile. Configure one explicitly before
51
+ running commands:
52
+
53
+ ```bash
54
+ # Local development stack
55
+ martha init --no-interactive --preset local
56
+
57
+ # Customer, staging, or private-cloud tenant
58
+ martha init --no-interactive --name acme \
59
+ --api-url https://martha.acme.example \
60
+ --keycloak-url https://auth.acme.example \
61
+ --keycloak-realm acme
62
+ ```
63
+
64
+ Use `martha init --preset cloud` only when you intentionally want the hosted
65
+ Martha cloud profile (`martha.nomadriver.co` + `keycloak.frank.nomadriver.co`).
66
+
50
67
  The CLI resolves credentials in this priority order. The first non-empty wins:
51
68
 
52
69
  1. `MARTHA_TOKEN` — raw JWT, bypasses all login flows. Highest priority.
53
70
  2. `MARTHA_CLIENT_ID` + `MARTHA_CLIENT_SECRET` — OAuth2 client credentials, auto-refreshes.
54
- 3. Profile-stored token from prior `martha auth login` (cached at `~/.martha/profiles/<name>.json`).
71
+ 3. Profile-stored token from prior `martha auth login` (cached under `~/.martha/` for the active profile).
55
72
  4. Browser-based OIDC (interactive only, won't fire in non-TTY).
56
73
 
57
74
  ```bash
@@ -130,9 +147,22 @@ agent_type: cloud
130
147
  auth_method: service_account # Slice 3B: provisions Keycloak SA on create
131
148
  system_prompt: "You write friendly morning briefings."
132
149
  llm_config: { provider: anthropic, model: claude-sonnet-4-5-20250929 }
133
- loop_config: { enabled: true, max_iterations: 5 }
150
+ loop_config: { max_iterations: 5 }
134
151
  ```
135
152
 
153
+ Agent field notes:
154
+
155
+ - `system_prompt` is the durable instruction for the agent.
156
+ - Grant an agent to a chat client with
157
+ `martha clients grant <client> agent <agent>` to make it callable as a tool
158
+ from that client.
159
+ - `runs_as_chat` is an advanced top-level chat-takeover override, not normal
160
+ provisioning. Use it only when exactly one chat client should replace its own
161
+ `system_prompt`, `llm_config`, and tools with one agent.
162
+ - `loop_config.enabled` is legacy compatibility. New YAML should not use it.
163
+ - Workflow `llm` nodes use `config.prompt` for a one-step task prompt; that is
164
+ separate from an agent `system_prompt`.
165
+
136
166
  `--dry-run` prints what would change without writing. `--yes` skips the confirmation when applying to a non-empty tenant.
137
167
 
138
168
  ### Export (back up or migrate)
@@ -241,11 +271,11 @@ Default `execute_on` mapping: `llm` → `local`; `function` / `wait` / `transfor
241
271
  ```bash
242
272
  martha agents list [--inactive] [--limit 50]
243
273
  martha agents get <name> # Includes auth_method, status, llm_config, granted functions/workflows
244
- martha agents create --name <n> --model <m> --prompt "system prompt" \
274
+ martha agents create --name <n> --model <m> --system-prompt "system prompt" \
245
275
  [--description "..."] [--temperature 0.7] [--max-tokens 4096] \
246
276
  [--type cloud|external] [--auth service-account|api-key] \
247
277
  [--tags code-review,python] [--local-tools filesystem,git]
248
- martha agents update <name> [--model <m>] [--prompt "..."] [--description "..."]
278
+ martha agents update <name> -f agent.yaml
249
279
  martha agents delete <name> [--hard] [--yes]
250
280
  martha agents versions <name>
251
281
  martha agents rollback <name> <version>
@@ -484,38 +514,51 @@ martha tasks poll --json
484
514
 
485
515
  A **Connection** is a stored credential record for an integration. Auth values live in HashiCorp Vault keyed by `(scope, scope_id, service_name)`. The DB only has metadata. Tracker connections include adapter-specific config (team_id, repository, project_id) stored as a flat dict in Vault — `resolve_connection_config()` is the single merge point for tracker adapter calls.
486
516
 
487
- The CLI doesn't yet have a top-level `connections` subcommand — manage them via the admin UI at `/settings` (Trackers tab) or via the API:
488
-
489
517
  ```bash
490
- # List connections (uses your token's tenant_id scope)
491
- curl -s -H "Authorization: Bearer $(martha auth token)" \
492
- "$MARTHA_API_URL/api/admin/connections" | jq
518
+ martha connections list [--integration <name>] [--scope tenant|client|system]
519
+ martha connections create --integration <name> --name <name> --auth-type <type> \
520
+ --credential-value <value|@file|-> [--config '<json>'] \
521
+ [--scope tenant|client|system] [--scope-ref <ref>] [--not-default]
522
+ martha connections update <connection-id> [--credential-value <value|@file|->] \
523
+ [--config '<json>'] [--name <name>] [--default|--not-default]
524
+ martha connections test <connection-id>
525
+ martha connections delete <connection-id> --yes
526
+ ```
527
+
528
+ **Rotating a service-account key:** `martha connections update <id> --credential-value @new-sa.json`
529
+ replaces the SA key in Vault in place (same connection id, sources stay attached)
530
+ and drops the cached SA token so the new key takes effect immediately.
493
531
 
494
- # List available tracker adapters + their config_schema
495
- curl -s -H "Authorization: Bearer $(martha auth token)" \
496
- "$MARTHA_API_URL/api/admin/trackers" | jq
532
+ `--credential-value` accepts a literal, `@path` (read a file), or `-` (read stdin).
533
+ `--config` is a non-secret JSON object stored in Postgres (never Vault).
534
+ **OAuth2 connections cannot be created from the CLI** — they need an interactive
535
+ browser consent flow; use the admin UI under Integrations → Connections.
497
536
 
498
- # Create a Linear connection (full config dict in JSON, stored as one Vault entry)
499
- curl -s -X POST -H "Authorization: Bearer $(martha auth token)" \
500
- -H "Content-Type: application/json" \
501
- -d '{
502
- "integration_name": "linear",
503
- "name": "production",
504
- "auth_type": "api_key",
505
- "credential_value": "{\"api_key\":\"lin_api_xxx\",\"team_id\":\"uuid-here\"}",
506
- "is_default": true
507
- }' \
508
- "$MARTHA_API_URL/api/admin/connections"
537
+ ```bash
538
+ # Google Drive via service account (#407) reads enterprise Shared Drives
539
+ # without CASA. credential_value is the SA JSON key; config carries the
540
+ # optional impersonation subject (domain-wide delegation) + scopes.
541
+ martha connections create \
542
+ --integration google_drive --name "SOMENGIL Drive" \
543
+ --auth-type service_account \
544
+ --credential-value @/path/to/sa-key.json \
545
+ --config '{"subject":"owner@corp.com","scopes":["https://www.googleapis.com/auth/drive.readonly"]}'
546
+ # Then add the SA's client_email as a member (Viewer) of the Shared Drive.
509
547
 
510
- # Test a connection (calls adapter.test_connection() with merged config)
511
- curl -s -X POST -H "Authorization: Bearer $(martha auth token)" \
512
- "$MARTHA_API_URL/api/admin/connections/<connection-id>/test" | jq
548
+ # Linear connection (full config dict in JSON, stored as one Vault entry)
549
+ martha connections create --integration linear --name production \
550
+ --auth-type api_key \
551
+ --credential-value '{"api_key":"lin_api_xxx","team_id":"uuid-here"}'
513
552
 
514
- # Delete (also removes from Vault and deprovisions tracker triggers if applicable)
515
- curl -s -X DELETE -H "Authorization: Bearer $(martha auth token)" \
516
- "$MARTHA_API_URL/api/admin/connections/<connection-id>"
553
+ # Test a connection (calls the adapter's health check with merged config)
554
+ martha connections test <connection-id>
517
555
  ```
518
556
 
557
+ `martha connections create` mirrors `POST /api/admin/connections`. For
558
+ service_account it validates the key shape (`type`, `client_email`) before
559
+ submit. List available tracker adapters + their `config_schema` via
560
+ `curl -s -H "Authorization: Bearer $(martha auth token)" "$MARTHA_API_URL/api/admin/trackers" | jq`.
561
+
519
562
  **Tracker connection auto-provisioning:** When you create a connection where `integration_name` matches a tracker (`linear`/`github`/`gitlab`), the backend automatically provisions:
520
563
  1. A `webhook_definition` (returns one-time `webhook_url` + `webhook_secret` in the create response)
521
564
  2. Three trigger definitions (managed by `tracker:{type}`):
@@ -611,6 +654,24 @@ A failed enrich step does NOT fail the document — it falls back to keyword-onl
611
654
 
612
655
  ---
613
656
 
657
+ ## Document Sync (durable Drive/folder sync)
658
+
659
+ Durable sync sources mirror an external provider (Google Drive today) into Martha collections. Folder structure changes in Drive — move, rename, delete — propagate to the collection tree inbound (#372 PR5).
660
+
661
+ ```bash
662
+ # Backfill / repair the collection tree from a Drive source's folder hierarchy.
663
+ # Stamps drive_folder_id on existing collections matching (parent, name) and
664
+ # creates sub-collections for unmapped Drive folders. Idempotent.
665
+ martha document-sync reconcile-tree --source <source-id> [--dry-run]
666
+ martha document-sync reconcile-tree --all [--dry-run] # every google_drive source
667
+ ```
668
+
669
+ `reconcile-tree` is the one-shot backfill that maps a source connected before folder-sync existed (or repairs drift). It runs server-side against the source's live OAuth token, so the source must be connected and validatable. `--dry-run` prints the would-link / would-create / skipped counts without writing. Re-running is safe — already-linked folders are skipped.
670
+
671
+ Cross-source moves are rejected: a collection can't move between sync sources. Reorganize the folder in Drive directly and inbound sync mirrors it.
672
+
673
+ ---
674
+
614
675
  ## Approvals (human-in-the-loop)
615
676
 
616
677
  Approvals are pause-points inside workflows. The `approval_gate` workflow node creates an `ApprovalCase`; downstream nodes wait until a human resolves it.
@@ -804,7 +865,7 @@ martha teams add-member refactoring-team ork-1
804
865
  # Agent side
805
866
  export MARTHA_CLIENT_ID=<from above>
806
867
  export MARTHA_CLIENT_SECRET=<from above>
807
- export MARTHA_API_URL=https://martha.nomadriver.co
868
+ export MARTHA_API_URL=https://martha.acme.example
808
869
  martha auth login --service-account
809
870
  martha tasks poll --json # Should return open team-scoped tasks
810
871
  ```