@aiaiai-pt/martha-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
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
+ ### Added — #407 connections command (service-account Drive auth)
8
+ - `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`.
9
+ - 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.
10
+ - **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).
11
+ - OAuth2 connections are rejected with a clear error (they need interactive browser consent — use the admin UI).
12
+
13
+ ### Added — #372 PR5 inbound Drive folder sync
14
+ - `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.
15
+
5
16
  ## [0.5.0] — 2026-05-20
6
17
 
7
18
  ### Added — #372 PR4 collection-hierarchy CLI parity
package/dist/index.js CHANGED
@@ -13765,6 +13765,7 @@ ${items.length} collections`));
13765
13765
  console.log(` Status: ${col.is_active !== false ? source_default.green("active") : source_default.dim("inactive")}`);
13766
13766
  console.log(` Documents: ${col.document_count ?? 0}`);
13767
13767
  console.log(` Storage: ${col.storage_backend ?? "-"}`);
13768
+ console.log(` Drive: ${col.drive_folder_id ? source_default.cyan(col.drive_folder_id) : source_default.dim("not linked")}`);
13768
13769
  if (col.total_size_bytes != null) {
13769
13770
  console.log(` Size: ${formatBytes(col.total_size_bytes)}`);
13770
13771
  }
@@ -14137,6 +14138,139 @@ ${source_default.bold(`Sources (${result.sources.length} chunks):`)}`);
14137
14138
  });
14138
14139
  }
14139
14140
 
14141
+ // src/commands/document-sync.ts
14142
+ init_errors();
14143
+ var VALID_MODES = ["polling", "evented", "manual"];
14144
+ function registerDocumentSyncCommands(program2) {
14145
+ const cmd = program2.command("document-sync").description("Manage durable document sync sources (Google Drive, etc.)");
14146
+ function getCtx() {
14147
+ const ctx = createContext({
14148
+ profileOverride: program2.opts().profile,
14149
+ verbose: program2.opts().verbose
14150
+ });
14151
+ if (program2.opts().apiUrl)
14152
+ ctx.profile.api_url = program2.opts().apiUrl;
14153
+ return ctx;
14154
+ }
14155
+ function isJson() {
14156
+ return !!program2.opts().json;
14157
+ }
14158
+ function printSummary(name, s) {
14159
+ const tag = s.dry_run ? source_default.yellow(" (dry-run)") : "";
14160
+ console.log(source_default.bold(`
14161
+ ${name}${tag}`));
14162
+ console.log(source_default.dim("-".repeat(40)));
14163
+ console.log(` Folders walked : ${s.folders_walked}`);
14164
+ console.log(` Linked : ${source_default.cyan(String(s.linked))}`);
14165
+ console.log(` Created : ${source_default.green(String(s.created))}`);
14166
+ console.log(` Skipped : ${source_default.dim(String(s.skipped))}`);
14167
+ }
14168
+ 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) => {
14169
+ const ctx = getCtx();
14170
+ const dryRun = !!opts.dryRun;
14171
+ if (!opts.source && !opts.all) {
14172
+ throw new CLIError("Specify --source <id> or --all", 4 /* Validation */, "Use `martha document-sync reconcile-tree --source <id>` or `--all`.");
14173
+ }
14174
+ if (opts.source && opts.all) {
14175
+ throw new CLIError("--source and --all are mutually exclusive", 4 /* Validation */);
14176
+ }
14177
+ const params = { dry_run: String(dryRun) };
14178
+ let sources2;
14179
+ if (opts.all) {
14180
+ const all = await ctx.api.get("/api/admin/document-sync/sources", { params: { provider: "google_drive" } });
14181
+ sources2 = all;
14182
+ } else {
14183
+ sources2 = [
14184
+ { id: opts.source, name: opts.source, provider: "google_drive" }
14185
+ ];
14186
+ }
14187
+ const results = [];
14188
+ for (const src of sources2) {
14189
+ const summary = await ctx.api.post(`/api/admin/document-sync/sources/${encodeURIComponent(src.id)}/reconcile-tree`, undefined, { params });
14190
+ results.push(summary);
14191
+ if (!isJson())
14192
+ printSummary(src.name, summary);
14193
+ }
14194
+ if (isJson()) {
14195
+ console.log(JSON.stringify({ dry_run: dryRun, results }, null, 2));
14196
+ return;
14197
+ }
14198
+ if (results.length === 0) {
14199
+ console.log(source_default.dim(`
14200
+ No google_drive sources found.`));
14201
+ }
14202
+ });
14203
+ const sources = cmd.command("sources").description("Create, list, and run document sync sources");
14204
+ sources.command("list").description("List document sync sources for the tenant").option("--provider <name>", "Filter by provider (e.g. s3_compatible_folder)").action(async (opts) => {
14205
+ const ctx = getCtx();
14206
+ const params = {};
14207
+ if (opts.provider)
14208
+ params.provider = opts.provider;
14209
+ const rows = await ctx.api.get("/api/admin/document-sync/sources", { params });
14210
+ if (isJson()) {
14211
+ console.log(JSON.stringify(rows, null, 2));
14212
+ return;
14213
+ }
14214
+ if (rows.length === 0) {
14215
+ console.log(source_default.dim("No document sync sources found."));
14216
+ return;
14217
+ }
14218
+ for (const s of rows) {
14219
+ console.log(` ${source_default.cyan((s.provider ?? "?").padEnd(22))} ${s.name} ` + source_default.dim(`(id=${s.id}, profile=${s.provider_profile ?? "-"}, ` + `mode=${s.mode ?? "-"}, status=${s.status ?? "-"})`));
14220
+ }
14221
+ });
14222
+ sources.command("create").description("Create a document sync source. For s3_compatible_folder pass " + "--bucket (+ --endpoint-url for custom_s3) and a --connection holding " + "the S3 credentials.").requiredOption("--provider <name>", "Provider: s3_compatible_folder | google_drive").requiredOption("--name <name>", "Source name (unique per tenant)").requiredOption("--collection <id>", "Target collection id (objects ingest here)").option("--connection <id>", "Connection id holding the source credentials (required for a " + "customer-owned s3 bucket)").option("--profile <name>", "s3 profile: custom_s3 | cloudflare_r2").option("--bucket <name>", "s3 bucket name (s3_compatible_folder)").option("--endpoint-url <url>", "s3 endpoint URL (required for custom_s3 / a customer bucket)").option("--prefix <prefix>", "s3 key prefix to sync (optional)").option("--mode <mode>", `Sync mode: ${VALID_MODES.join(" | ")}`, "manual").action(async (opts) => {
14223
+ if (!VALID_MODES.includes(opts.mode)) {
14224
+ throw new CLIError(`--mode must be one of: ${VALID_MODES.join(", ")}`, 4 /* Validation */);
14225
+ }
14226
+ const body = {
14227
+ provider: opts.provider,
14228
+ name: opts.name,
14229
+ target_collection_id: opts.collection,
14230
+ mode: opts.mode
14231
+ };
14232
+ if (opts.connection)
14233
+ body.connection_id = opts.connection;
14234
+ if (opts.provider === "s3_compatible_folder") {
14235
+ if (!opts.bucket) {
14236
+ throw new CLIError("--bucket is required for s3_compatible_folder.", 4 /* Validation */);
14237
+ }
14238
+ const profile = opts.profile ?? "custom_s3";
14239
+ if (profile === "custom_s3" && !opts.endpointUrl) {
14240
+ throw new CLIError("--endpoint-url is required for the custom_s3 profile.", 4 /* Validation */);
14241
+ }
14242
+ if (opts.connection && !opts.endpointUrl) {
14243
+ throw new CLIError("--endpoint-url is required for a customer-owned bucket source " + "(one with --connection).", 4 /* Validation */);
14244
+ }
14245
+ body.provider_profile = profile;
14246
+ const root = { bucket: opts.bucket };
14247
+ if (opts.endpointUrl)
14248
+ root.endpoint_url = opts.endpointUrl;
14249
+ if (opts.prefix)
14250
+ root.prefix = opts.prefix;
14251
+ body.root_locator = root;
14252
+ }
14253
+ const ctx = getCtx();
14254
+ const resp = await ctx.api.post("/api/admin/document-sync/sources", body);
14255
+ if (isJson()) {
14256
+ console.log(JSON.stringify(resp, null, 2));
14257
+ return;
14258
+ }
14259
+ console.log(source_default.green(`Created ${opts.provider} source '${opts.name}' ` + `(id=${resp.id}, mode=${resp.mode ?? opts.mode}). ` + `Run it with: martha document-sync sources reconcile ${resp.id}`));
14260
+ });
14261
+ for (const op of ["run", "reconcile"]) {
14262
+ sources.command(`${op} <source_id>`).description(op === "reconcile" ? "Reconcile a source (full diff: ingest new/changed, soft-delete gone)" : "Run a one-off sync for a source").action(async (sourceId) => {
14263
+ const ctx = getCtx();
14264
+ const resp = await ctx.api.post(`/api/admin/document-sync/sources/${encodeURIComponent(sourceId)}/${op}`, undefined);
14265
+ if (isJson()) {
14266
+ console.log(JSON.stringify(resp, null, 2));
14267
+ return;
14268
+ }
14269
+ console.log(source_default.green(`Started ${op} for source ${sourceId} ` + `(workflow=${resp.workflow_id}, status=${resp.status})`));
14270
+ });
14271
+ }
14272
+ }
14273
+
14140
14274
  // src/commands/approvals.ts
14141
14275
  init_errors();
14142
14276
  var truncate = (s, n) => s.length > n ? s.slice(0, n - 1) + "…" : s;
@@ -15289,6 +15423,194 @@ function renderTable(columns, items, opts) {
15289
15423
  }
15290
15424
  }
15291
15425
 
15426
+ // src/commands/connections.ts
15427
+ init_errors();
15428
+ function registerConnectionCommands(program2) {
15429
+ const cmd = program2.command("connections").description("Manage Vault-backed integration connections (credentials live in Vault)");
15430
+ function getCtx() {
15431
+ const ctx = createContext({
15432
+ profileOverride: program2.opts().profile,
15433
+ verbose: program2.opts().verbose
15434
+ });
15435
+ if (program2.opts().apiUrl)
15436
+ ctx.profile.api_url = program2.opts().apiUrl;
15437
+ return ctx;
15438
+ }
15439
+ function isJson() {
15440
+ return !!program2.opts().json;
15441
+ }
15442
+ 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) => {
15443
+ const ctx = getCtx();
15444
+ const query = new URLSearchParams;
15445
+ if (opts.integration)
15446
+ query.set("integration_name", opts.integration);
15447
+ if (opts.scope)
15448
+ query.set("scope", opts.scope);
15449
+ const suffix = query.toString() ? `?${query.toString()}` : "";
15450
+ const connections = await ctx.api.get(`/api/admin/connections${suffix}`);
15451
+ if (isJson()) {
15452
+ console.log(JSON.stringify(connections, null, 2));
15453
+ return;
15454
+ }
15455
+ if (connections.length === 0) {
15456
+ console.log(source_default.dim("No connections configured."));
15457
+ return;
15458
+ }
15459
+ console.log(source_default.bold(`
15460
+ Connections`));
15461
+ console.log(source_default.dim("-".repeat(60)));
15462
+ for (const c of connections) {
15463
+ const dflt = c.is_default ? source_default.green(" [default]") : "";
15464
+ 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)}`);
15465
+ }
15466
+ console.log();
15467
+ });
15468
+ 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 | aws_access_key " + `(s3: --credential-value '{"access_key_id":"…","secret_access_key":"…"}')`, "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) => {
15469
+ if (opts.authType === "oauth2") {
15470
+ 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.");
15471
+ }
15472
+ const credentialValue = await resolveCredentialValue(opts.credentialValue);
15473
+ if (!credentialValue) {
15474
+ throw new CLIError("--credential-value is required (use '-' for stdin or '@path' for a file).", 4 /* Validation */);
15475
+ }
15476
+ if (opts.authType === "service_account") {
15477
+ validateServiceAccountKey(credentialValue);
15478
+ }
15479
+ let config;
15480
+ if (opts.config) {
15481
+ let parsed;
15482
+ try {
15483
+ parsed = JSON.parse(opts.config);
15484
+ } catch {
15485
+ throw new CLIError("--config must be valid JSON.", 4 /* Validation */);
15486
+ }
15487
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
15488
+ throw new CLIError("--config must be a JSON object.", 4 /* Validation */);
15489
+ }
15490
+ config = parsed;
15491
+ }
15492
+ if (opts.scope === "client" && !opts.scopeRef) {
15493
+ throw new CLIError("--scope-ref is required when --scope is 'client'.", 4 /* Validation */);
15494
+ }
15495
+ const ctx = getCtx();
15496
+ const resp = await ctx.api.post("/api/admin/connections", {
15497
+ integration_name: opts.integration,
15498
+ name: opts.name,
15499
+ auth_type: opts.authType,
15500
+ credential_value: credentialValue,
15501
+ config,
15502
+ scope: opts.scope,
15503
+ scope_ref: opts.scope === "client" ? opts.scopeRef : undefined,
15504
+ is_default: !opts.notDefault
15505
+ });
15506
+ if (isJson()) {
15507
+ console.log(JSON.stringify(resp, null, 2));
15508
+ return;
15509
+ }
15510
+ console.log(source_default.green(`Created ${opts.integration} connection '${opts.name}' ` + `(id=${resp.id}, auth=${resp.auth_type}, status=${resp.status})`));
15511
+ });
15512
+ 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) => {
15513
+ const body = {};
15514
+ if (opts.name)
15515
+ body.name = opts.name;
15516
+ if (opts.default)
15517
+ body.is_default = true;
15518
+ if (opts.notDefault)
15519
+ body.is_default = false;
15520
+ if (opts.config) {
15521
+ let parsed;
15522
+ try {
15523
+ parsed = JSON.parse(opts.config);
15524
+ } catch {
15525
+ throw new CLIError("--config must be valid JSON.", 4 /* Validation */);
15526
+ }
15527
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
15528
+ throw new CLIError("--config must be a JSON object.", 4 /* Validation */);
15529
+ }
15530
+ body.config = parsed;
15531
+ }
15532
+ const credentialValue = await resolveCredentialValue(opts.credentialValue);
15533
+ if (credentialValue)
15534
+ body.credential_value = credentialValue;
15535
+ if (Object.keys(body).length === 0) {
15536
+ throw new CLIError("Nothing to update — pass --credential-value, --config, --name, or --default/--not-default.", 4 /* Validation */);
15537
+ }
15538
+ const ctx = getCtx();
15539
+ const resp = await ctx.api.put(`/api/admin/connections/${connectionId}`, body);
15540
+ if (isJson()) {
15541
+ console.log(JSON.stringify(resp, null, 2));
15542
+ return;
15543
+ }
15544
+ const rotated = body.credential_value ? " (credential rotated)" : "";
15545
+ console.log(source_default.green(`Updated connection ${resp.id}${rotated}`));
15546
+ });
15547
+ cmd.command("test <connection_id>").description("Test a connection's credentials against the integration").action(async (connectionId) => {
15548
+ const ctx = getCtx();
15549
+ const result = await ctx.api.post(`/api/admin/connections/${connectionId}/test`, {});
15550
+ if (isJson()) {
15551
+ console.log(JSON.stringify(result, null, 2));
15552
+ return;
15553
+ }
15554
+ if (result.ok) {
15555
+ console.log(source_default.green("OK: connection test passed"));
15556
+ } else {
15557
+ console.log(source_default.red(`FAILED: ${result.error ?? "unknown error"}`));
15558
+ process.exitCode = 1;
15559
+ }
15560
+ });
15561
+ 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) => {
15562
+ if (!opts.yes) {
15563
+ throw new CLIError(`Pass --yes to confirm deletion of connection ${connectionId}.`, 4 /* Validation */, `Example: martha connections delete ${connectionId} --yes`);
15564
+ }
15565
+ const ctx = getCtx();
15566
+ await ctx.api.del(`/api/admin/connections/${connectionId}`);
15567
+ if (isJson()) {
15568
+ console.log(JSON.stringify({ deleted: connectionId }, null, 2));
15569
+ return;
15570
+ }
15571
+ console.log(source_default.green(`Deleted connection ${connectionId}`));
15572
+ });
15573
+ }
15574
+ async function resolveCredentialValue(value) {
15575
+ if (!value)
15576
+ return;
15577
+ if (value === "-")
15578
+ return readStdin();
15579
+ if (value.startsWith("@")) {
15580
+ const fs8 = await import("node:fs/promises");
15581
+ return (await fs8.readFile(value.slice(1), "utf-8")).trim();
15582
+ }
15583
+ return value;
15584
+ }
15585
+ function validateServiceAccountKey(raw) {
15586
+ let parsed;
15587
+ try {
15588
+ parsed = JSON.parse(raw);
15589
+ } catch {
15590
+ throw new CLIError("service_account --credential-value must be valid JSON (the SA key file).", 4 /* Validation */);
15591
+ }
15592
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
15593
+ throw new CLIError("service_account key must be a JSON object.", 4 /* Validation */);
15594
+ }
15595
+ const key = parsed;
15596
+ if (key.type !== "service_account") {
15597
+ 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.");
15598
+ }
15599
+ if (typeof key.client_email !== "string" || !key.client_email.trim()) {
15600
+ throw new CLIError("service_account key is missing client_email.", 4 /* Validation */);
15601
+ }
15602
+ }
15603
+ async function readStdin() {
15604
+ if (process.stdin.isTTY) {
15605
+ 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.");
15606
+ }
15607
+ let out = "";
15608
+ process.stdin.setEncoding("utf-8");
15609
+ for await (const chunk of process.stdin)
15610
+ out += chunk;
15611
+ return out.trim();
15612
+ }
15613
+
15292
15614
  // src/commands/notifications.ts
15293
15615
  var CHANNEL_IDS = new Set(["resend", "slack_webhook", "webhook"]);
15294
15616
  function registerNotificationCommands(program2) {
@@ -15369,7 +15691,7 @@ Notification connections`));
15369
15691
  }
15370
15692
  let credentialValue = opts.credentialValue;
15371
15693
  if (credentialValue === "-") {
15372
- credentialValue = await readStdin();
15694
+ credentialValue = await readStdin2();
15373
15695
  } else if (credentialValue?.startsWith("@")) {
15374
15696
  const fs8 = await import("node:fs/promises");
15375
15697
  credentialValue = await fs8.readFile(credentialValue.slice(1), "utf-8");
@@ -15409,7 +15731,7 @@ Notification connections`));
15409
15731
  console.log(source_default.green(`Deleted connection ${connectionId}`));
15410
15732
  });
15411
15733
  }
15412
- async function readStdin() {
15734
+ async function readStdin2() {
15413
15735
  if (process.stdin.isTTY) {
15414
15736
  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
15737
  }
@@ -17364,11 +17686,13 @@ registerDefinitionCommands(program2, agentsConfig);
17364
17686
  registerDefinitionsApply(program2);
17365
17687
  registerDefinitionsExport(program2);
17366
17688
  registerDocumentCommands(program2);
17689
+ registerDocumentSyncCommands(program2);
17367
17690
  registerWikiCommands(program2);
17368
17691
  registerApprovalCommands(program2);
17369
17692
  registerTaskCommands(program2);
17370
17693
  registerTeamCommands(program2);
17371
17694
  registerIntegrationCommands(program2);
17695
+ registerConnectionCommands(program2);
17372
17696
  registerNotificationCommands(program2);
17373
17697
  registerMessagingCommands(program2);
17374
17698
  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.6.0",
4
4
  "description": "Terminal-first client for the Martha AI platform",
5
5
  "homepage": "https://docs.martha.nomadriver.co",
6
6
  "repository": {
@@ -484,38 +484,51 @@ martha tasks poll --json
484
484
 
485
485
  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
486
 
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
487
  ```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
488
+ martha connections list [--integration <name>] [--scope tenant|client|system]
489
+ martha connections create --integration <name> --name <name> --auth-type <type> \
490
+ --credential-value <value|@file|-> [--config '<json>'] \
491
+ [--scope tenant|client|system] [--scope-ref <ref>] [--not-default]
492
+ martha connections update <connection-id> [--credential-value <value|@file|->] \
493
+ [--config '<json>'] [--name <name>] [--default|--not-default]
494
+ martha connections test <connection-id>
495
+ martha connections delete <connection-id> --yes
496
+ ```
493
497
 
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
498
+ **Rotating a service-account key:** `martha connections update <id> --credential-value @new-sa.json`
499
+ replaces the SA key in Vault in place (same connection id, sources stay attached)
500
+ and drops the cached SA token so the new key takes effect immediately.
497
501
 
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"
502
+ `--credential-value` accepts a literal, `@path` (read a file), or `-` (read stdin).
503
+ `--config` is a non-secret JSON object stored in Postgres (never Vault).
504
+ **OAuth2 connections cannot be created from the CLI** — they need an interactive
505
+ browser consent flow; use the admin UI under Integrations → Connections.
506
+
507
+ ```bash
508
+ # Google Drive via service account (#407) — reads enterprise Shared Drives
509
+ # without CASA. credential_value is the SA JSON key; config carries the
510
+ # optional impersonation subject (domain-wide delegation) + scopes.
511
+ martha connections create \
512
+ --integration google_drive --name "SOMENGIL Drive" \
513
+ --auth-type service_account \
514
+ --credential-value @/path/to/sa-key.json \
515
+ --config '{"subject":"owner@corp.com","scopes":["https://www.googleapis.com/auth/drive.readonly"]}'
516
+ # Then add the SA's client_email as a member (Viewer) of the Shared Drive.
509
517
 
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
518
+ # Linear connection (full config dict in JSON, stored as one Vault entry)
519
+ martha connections create --integration linear --name production \
520
+ --auth-type api_key \
521
+ --credential-value '{"api_key":"lin_api_xxx","team_id":"uuid-here"}'
513
522
 
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>"
523
+ # Test a connection (calls the adapter's health check with merged config)
524
+ martha connections test <connection-id>
517
525
  ```
518
526
 
527
+ `martha connections create` mirrors `POST /api/admin/connections`. For
528
+ service_account it validates the key shape (`type`, `client_email`) before
529
+ submit. List available tracker adapters + their `config_schema` via
530
+ `curl -s -H "Authorization: Bearer $(martha auth token)" "$MARTHA_API_URL/api/admin/trackers" | jq`.
531
+
519
532
  **Tracker connection auto-provisioning:** When you create a connection where `integration_name` matches a tracker (`linear`/`github`/`gitlab`), the backend automatically provisions:
520
533
  1. A `webhook_definition` (returns one-time `webhook_url` + `webhook_secret` in the create response)
521
534
  2. Three trigger definitions (managed by `tracker:{type}`):
@@ -611,6 +624,24 @@ A failed enrich step does NOT fail the document — it falls back to keyword-onl
611
624
 
612
625
  ---
613
626
 
627
+ ## Document Sync (durable Drive/folder sync)
628
+
629
+ 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).
630
+
631
+ ```bash
632
+ # Backfill / repair the collection tree from a Drive source's folder hierarchy.
633
+ # Stamps drive_folder_id on existing collections matching (parent, name) and
634
+ # creates sub-collections for unmapped Drive folders. Idempotent.
635
+ martha document-sync reconcile-tree --source <source-id> [--dry-run]
636
+ martha document-sync reconcile-tree --all [--dry-run] # every google_drive source
637
+ ```
638
+
639
+ `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.
640
+
641
+ Cross-source moves are rejected: a collection can't move between sync sources. Reorganize the folder in Drive directly and inbound sync mirrors it.
642
+
643
+ ---
644
+
614
645
  ## Approvals (human-in-the-loop)
615
646
 
616
647
  Approvals are pause-points inside workflows. The `approval_gate` workflow node creates an `ApprovalCase`; downstream nodes wait until a human resolves it.