@aiaiai-pt/martha-cli 0.7.0 → 0.9.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.js +468 -35
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to `@aiaiai-pt/martha-cli`. Format: [Keep a Changelog](https
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.0] — 2026-06-10
8
+
9
+ ### Added — #535 Phase 1 onboarding bootstrap
10
+ - `martha init` can now provision a grant-bearing principal after auth, removing the "denied until hand-granted" wall a fresh caller hits on the tenant's shared default client. Interactive: it asks "how will you use Martha?" and grants the matching read set. Non-interactive flags: `--bootstrap --mode human|integration --intent rag|documents|workflows|custom --workflow <name> --function <name> --label <name> --yes`. Non-TTY requires `--yes`; `--json` is pure JSON.
11
+ - **human** mode adopts-or-creates *your own* client (`cli-user:{sub}`, not the shared default), grants the intent read set, and wires the profile to run as it (`default_client`).
12
+ - **integration** mode (admin only) mints a Keycloak service account + Martha client + grants and prints copy-paste `MARTHA_CLIENT_ID`/`MARTHA_CLIENT_SECRET` once for CI.
13
+ - Deterministic (no LLM), idempotent (adopt-don't-duplicate), least-privilege, and reports exactly what it granted. Unknown function/workflow names are skipped and reported.
14
+ - `workflows execute` now routes by caller type: service accounts hit `/api/service/.../execute`; humans hit `/test` with `selected_client = profile.default_client`, so the bootstrapped grants actually apply (a workflow's function nodes run as the client — #529).
15
+
16
+ ## [0.8.0] — 2026-06-09
17
+
18
+ ### Added — #522 source-scoped retry (CLI parity)
19
+ - `document-sync sources retry <id> [--status error] [--yes]` — re-drive a sync source's failed documents directly, mirroring the source-scoped retry already in the API and admin UI (the collection path is `documents retry --collection`). Same herd-free semantics: flips matching docs back to `pending` for the reconciler to re-drive at its capped rate. Non-TTY requires `--yes`; JSON mode emits the result; `--status pending` re-drives stuck-pending docs.
20
+
7
21
  ## [0.7.0] — 2026-06-09
8
22
 
9
23
  ### Added — #522 bulk-ingest observability + management
package/dist/index.js CHANGED
@@ -2158,6 +2158,9 @@ function extractDetail(body) {
2158
2158
  const nested = obj.detail;
2159
2159
  if (typeof nested.message === "string")
2160
2160
  return nested.message;
2161
+ if (nested.code === "mcp_oauth_authorization_required" && typeof nested.authorization_url === "string") {
2162
+ return "MCP OAuth authorization required";
2163
+ }
2161
2164
  }
2162
2165
  if (typeof obj.message === "string")
2163
2166
  return obj.message;
@@ -2178,30 +2181,32 @@ var init_errors = __esm(() => {
2178
2181
  };
2179
2182
  MarthaAPIError = class MarthaAPIError extends CLIError {
2180
2183
  status;
2181
- constructor(status, detail, exitCode, suggestion) {
2184
+ body;
2185
+ constructor(status, detail, exitCode, suggestion, body) {
2182
2186
  super(detail, exitCode, suggestion);
2183
2187
  this.status = status;
2188
+ this.body = body;
2184
2189
  this.name = "MarthaAPIError";
2185
2190
  }
2186
2191
  static fromResponse(status, body) {
2187
2192
  const detail = extractDetail(body);
2188
2193
  switch (status) {
2189
2194
  case 401:
2190
- return new MarthaAPIError(status, detail || "Authentication required", 2 /* Auth */, "Run `martha auth login` to authenticate.");
2195
+ return new MarthaAPIError(status, detail || "Authentication required", 2 /* Auth */, "Run `martha auth login` to authenticate.", body);
2191
2196
  case 403:
2192
- return new MarthaAPIError(status, detail || "Permission denied", 2 /* Auth */, "Check your permissions or switch profiles with `martha config use <profile>`.");
2197
+ return new MarthaAPIError(status, detail || "Permission denied", 2 /* Auth */, "Check your permissions or switch profiles with `martha config use <profile>`.", body);
2193
2198
  case 404:
2194
- return new MarthaAPIError(status, detail || "Resource not found", 3 /* NotFound */);
2199
+ return new MarthaAPIError(status, detail || "Resource not found", 3 /* NotFound */, undefined, body);
2195
2200
  case 409:
2196
- return new MarthaAPIError(status, detail || "Conflict", 5 /* Conflict */);
2201
+ return new MarthaAPIError(status, detail || "Conflict", 5 /* Conflict */, undefined, body);
2197
2202
  case 400:
2198
2203
  case 422:
2199
- return new MarthaAPIError(status, detail || "Validation error", 4 /* Validation */);
2204
+ return new MarthaAPIError(status, detail || "Validation error", 4 /* Validation */, undefined, body);
2200
2205
  default:
2201
2206
  if (status >= 500) {
2202
- return new MarthaAPIError(status, detail || `Server error (${status})`, 1 /* Error */, "Run `martha status` to check API health.");
2207
+ return new MarthaAPIError(status, detail || `Server error (${status})`, 1 /* Error */, "Run `martha status` to check API health.", body);
2203
2208
  }
2204
- return new MarthaAPIError(status, detail || `Unexpected error (${status})`, 1 /* Error */);
2209
+ return new MarthaAPIError(status, detail || `Unexpected error (${status})`, 1 /* Error */, undefined, body);
2205
2210
  }
2206
2211
  }
2207
2212
  };
@@ -10687,7 +10692,10 @@ init_errors();
10687
10692
  function createContext(opts) {
10688
10693
  const config = loadConfig(opts.configDir);
10689
10694
  const profileName = opts.profileOverride ?? process.env.MARTHA_PROFILE ?? config.current_profile;
10690
- const profile = getActiveProfile(config, profileName);
10695
+ const profile = {
10696
+ ...getActiveProfile(config, profileName),
10697
+ ...opts.apiUrlOverride ? { api_url: opts.apiUrlOverride } : {}
10698
+ };
10691
10699
  const tokenStore = new TokenStore(opts.configDir);
10692
10700
  const getAccessToken = async () => {
10693
10701
  const envToken = process.env.MARTHA_TOKEN;
@@ -12836,10 +12844,10 @@ function registerExecutionCommands(parentCmd, getCtx, isJson) {
12836
12844
  } catch {
12837
12845
  throw new CLIError("Invalid JSON in --inputs", 4 /* Validation */);
12838
12846
  }
12839
- let result = await ctx.api.post(`/api/admin/workflows/${encodeURIComponent(name)}/test`, {
12840
- user_inputs: userInputs,
12841
- acknowledge_side_effects: !!opts.yes
12842
- });
12847
+ const isServiceAccount = ctx.profile.auth_type === "client_credentials";
12848
+ const runAs = ctx.profile.default_client;
12849
+ const executeOnce = (ack) => isServiceAccount ? ctx.api.post(`/api/service/workflows/${encodeURIComponent(name)}/execute`, { user_inputs: userInputs, acknowledge_side_effects: true }) : ctx.api.post(`/api/admin/workflows/${encodeURIComponent(name)}/test`, { user_inputs: userInputs, acknowledge_side_effects: ack }, runAs ? { params: { selected_client: runAs } } : undefined);
12850
+ let result = await executeOnce(!!opts.yes);
12843
12851
  if (result.status === "warning_required" && !opts.yes) {
12844
12852
  const nodes = result.side_effect_nodes?.join(", ") ?? "unknown";
12845
12853
  console.error(source_default.yellow(`Warning: workflow has side-effect nodes: ${nodes}`));
@@ -12849,10 +12857,7 @@ function registerExecutionCommands(parentCmd, getCtx, isJson) {
12849
12857
  process.exitCode = 0;
12850
12858
  return;
12851
12859
  }
12852
- result = await ctx.api.post(`/api/admin/workflows/${encodeURIComponent(name)}/test`, {
12853
- user_inputs: userInputs,
12854
- acknowledge_side_effects: true
12855
- });
12860
+ result = await executeOnce(true);
12856
12861
  }
12857
12862
  const executionId = result.execution_id;
12858
12863
  if (!executionId) {
@@ -14350,6 +14355,32 @@ No google_drive sources found.`));
14350
14355
  }
14351
14356
  printIngestionStatus(sourceId, summary);
14352
14357
  });
14358
+ sources.command("retry <source_id>").description("Re-drive a source's failed documents. Bounded + idempotent — flips " + "them back to pending; the ingestion reconciler re-drives them at " + "its capped rate (no herd).").option("--status <status>", "Which docs to retry: error | pending", "error").option("--yes", "Skip confirmation (required in non-interactive mode)").action(async (sourceId, opts) => {
14359
+ if (!["error", "pending"].includes(opts.status)) {
14360
+ throw new CLIError("--status must be one of: error, pending", 4 /* Validation */);
14361
+ }
14362
+ if (!opts.yes) {
14363
+ if (!process.stdin.isTTY) {
14364
+ throw new CLIError("Cannot confirm in non-interactive mode. Use --yes to retry.", 1 /* Error */);
14365
+ }
14366
+ const ok = await confirm(`Retry ${opts.status} documents for source ${sourceId}?`);
14367
+ if (!ok) {
14368
+ if (isJson()) {
14369
+ console.log(JSON.stringify({ reset_count: 0, cancelled: true }));
14370
+ } else {
14371
+ console.log("Cancelled.");
14372
+ }
14373
+ return;
14374
+ }
14375
+ }
14376
+ const ctx = getCtx();
14377
+ const result = await ctx.api.post(`/api/admin/document-sync/sources/${encodeURIComponent(sourceId)}/retry-ingestion`, undefined, { params: { status: opts.status } });
14378
+ if (isJson()) {
14379
+ console.log(JSON.stringify(result, null, 2));
14380
+ return;
14381
+ }
14382
+ console.log(source_default.green(`Re-drove ${result.reset_count} ${opts.status} document(s) for ` + `source ${sourceId}. The ingestion reconciler will pick them up ` + `within ~1 min (rate-limited by the per-tenant quota).`));
14383
+ });
14353
14384
  }
14354
14385
 
14355
14386
  // src/commands/approvals.ts
@@ -15509,13 +15540,11 @@ init_errors();
15509
15540
  function registerConnectionCommands(program2) {
15510
15541
  const cmd = program2.command("connections").description("Manage Vault-backed integration connections (credentials live in Vault)");
15511
15542
  function getCtx() {
15512
- const ctx = createContext({
15543
+ return createContext({
15513
15544
  profileOverride: program2.opts().profile,
15545
+ apiUrlOverride: program2.opts().apiUrl,
15514
15546
  verbose: program2.opts().verbose
15515
15547
  });
15516
- if (program2.opts().apiUrl)
15517
- ctx.profile.api_url = program2.opts().apiUrl;
15518
- return ctx;
15519
15548
  }
15520
15549
  function isJson() {
15521
15550
  return !!program2.opts().json;
@@ -17361,6 +17390,181 @@ ${pages.length} pages`));
17361
17390
  });
17362
17391
  }
17363
17392
 
17393
+ // src/commands/mcp.ts
17394
+ init_errors();
17395
+ function registerMCPCommands(program2) {
17396
+ const cmd = program2.command("mcp").description("Discover and call MCP tools");
17397
+ function getCtx() {
17398
+ return createContext({
17399
+ profileOverride: program2.opts().profile,
17400
+ apiUrlOverride: program2.opts().apiUrl,
17401
+ verbose: program2.opts().verbose
17402
+ });
17403
+ }
17404
+ function isJson() {
17405
+ return !!program2.opts().json;
17406
+ }
17407
+ cmd.command("discover").description("Discover tools from an MCP server").option("--url <serverUrl>", "Direct MCP Streamable HTTP URL").option("--connection <connectionId>", "Martha connection UUID").option("--connection-ref <connectionRef>", "Martha connection reference").option("--force-refresh", "Bypass cached discovery").action(async (opts) => {
17408
+ const ctx = getCtx();
17409
+ const body = buildLocatorBody(opts);
17410
+ if (opts.forceRefresh)
17411
+ body.force_refresh = true;
17412
+ let result;
17413
+ try {
17414
+ result = await ctx.api.post("/api/admin/mcp/discover", body);
17415
+ } catch (err) {
17416
+ const authorizationUrl = mcpOAuthAuthorizationUrl(err);
17417
+ if (!authorizationUrl)
17418
+ throw err;
17419
+ const payload = {
17420
+ code: "mcp_oauth_authorization_required",
17421
+ authorization_url: authorizationUrl
17422
+ };
17423
+ if (isJson()) {
17424
+ console.log(JSON.stringify(payload, null, 2));
17425
+ } else {
17426
+ console.log(source_default.yellow("MCP OAuth authorization required"));
17427
+ console.log(authorizationUrl);
17428
+ }
17429
+ return;
17430
+ }
17431
+ if (isJson()) {
17432
+ console.log(JSON.stringify(result, null, 2));
17433
+ return;
17434
+ }
17435
+ console.log(source_default.bold(`MCP tools: ${result.server_url}`));
17436
+ for (const tool of result.tools) {
17437
+ console.log(` ${source_default.cyan(tool.name)} ${source_default.dim(tool.description ?? "")}`);
17438
+ }
17439
+ if (result.cached)
17440
+ console.log(source_default.dim(`
17441
+ (cache hit)`));
17442
+ });
17443
+ cmd.command("call <tool>").description("Call an MCP tool through a temporary Martha workflow").option("--url <serverUrl>", "Direct MCP Streamable HTTP URL").option("--connection <connectionId>", "Martha connection UUID").option("--connection-ref <connectionRef>", "Martha connection reference").option("--input <json>", "JSON argument object", "{}").option("--timeout <seconds>", "Wait timeout in seconds", "60").action(async (tool, opts) => {
17444
+ const ctx = getCtx();
17445
+ let input;
17446
+ try {
17447
+ input = JSON.parse(opts.input);
17448
+ } catch {
17449
+ throw new CLIError("Invalid JSON in --input", 4 /* Validation */);
17450
+ }
17451
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
17452
+ throw new CLIError("--input must be a JSON object", 4 /* Validation */);
17453
+ }
17454
+ const timeoutSec = Number.parseInt(opts.timeout, 10);
17455
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 1) {
17456
+ throw new CLIError("--timeout must be a positive integer", 4 /* Validation */);
17457
+ }
17458
+ const workflowName = temporaryWorkflowName(tool);
17459
+ const definition = buildTemporaryWorkflow(workflowName, tool, input, opts);
17460
+ try {
17461
+ await ctx.api.post("/api/admin/definitions/workflows", definition);
17462
+ let execution = await ctx.api.post(`/api/admin/workflows/${encodeURIComponent(workflowName)}/test`, { user_inputs: {}, acknowledge_side_effects: true });
17463
+ if (execution.status === "warning_required") {
17464
+ execution = await ctx.api.post(`/api/admin/workflows/${encodeURIComponent(workflowName)}/test`, { user_inputs: {}, acknowledge_side_effects: true });
17465
+ }
17466
+ if (!execution.execution_id) {
17467
+ throw new CLIError("No execution ID returned", 1 /* Error */);
17468
+ }
17469
+ const final = await pollExecution(ctx, execution.execution_id, timeoutSec * 1000);
17470
+ if (final.status !== "completed") {
17471
+ throw new CLIError(`MCP workflow ${final.status}: ${final.error_message ?? "unknown error"}`, 1 /* Error */);
17472
+ }
17473
+ const output = extractMCPOutput(final);
17474
+ if (isJson()) {
17475
+ console.log(JSON.stringify(output, null, 2));
17476
+ } else {
17477
+ console.log(JSON.stringify(output, null, 2));
17478
+ }
17479
+ } finally {
17480
+ await ctx.api.del(`/api/admin/definitions/workflows/${encodeURIComponent(workflowName)}?hard=true`).catch(() => {
17481
+ return;
17482
+ });
17483
+ }
17484
+ });
17485
+ }
17486
+ function mcpOAuthAuthorizationUrl(err) {
17487
+ if (!(err instanceof MarthaAPIError) || err.status !== 428)
17488
+ return null;
17489
+ const body = err.body;
17490
+ if (!body || typeof body !== "object")
17491
+ return null;
17492
+ const detail = body.detail;
17493
+ if (!detail || typeof detail !== "object")
17494
+ return null;
17495
+ const nested = detail;
17496
+ if (nested.code !== "mcp_oauth_authorization_required")
17497
+ return null;
17498
+ return typeof nested.authorization_url === "string" ? nested.authorization_url : null;
17499
+ }
17500
+ function buildLocatorBody(opts) {
17501
+ const body = {};
17502
+ if (opts.url)
17503
+ body.server_url = opts.url;
17504
+ if (opts.connection)
17505
+ body.connection_id = opts.connection;
17506
+ if (opts.connectionRef)
17507
+ body.connection_ref = opts.connectionRef;
17508
+ if (!body.server_url && !body.connection_id && !body.connection_ref) {
17509
+ throw new CLIError("Provide --url, --connection, or --connection-ref", 4 /* Validation */);
17510
+ }
17511
+ return body;
17512
+ }
17513
+ function temporaryWorkflowName(tool) {
17514
+ const safeTool = tool.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
17515
+ return `mcp_call_${safeTool || "tool"}_${Date.now().toString(36)}`;
17516
+ }
17517
+ function buildTemporaryWorkflow(name, tool, input, opts) {
17518
+ const mcpConfig = {
17519
+ tool_name: tool,
17520
+ input_mapping: input
17521
+ };
17522
+ if (opts.url)
17523
+ mcpConfig.server_url = opts.url;
17524
+ if (opts.connection)
17525
+ mcpConfig.connection_id = opts.connection;
17526
+ if (opts.connectionRef)
17527
+ mcpConfig.connection_ref = opts.connectionRef;
17528
+ return {
17529
+ name,
17530
+ description: "Temporary MCP tool invocation generated by martha mcp call",
17531
+ nodes: [
17532
+ { id: "start", type: "start", config: {} },
17533
+ { id: "mcp", type: "mcp_client", config: mcpConfig },
17534
+ { id: "end", type: "end", config: {} }
17535
+ ],
17536
+ edges: [
17537
+ { source: "start", target: "mcp" },
17538
+ { source: "mcp", target: "end" }
17539
+ ],
17540
+ timeout_seconds: 120
17541
+ };
17542
+ }
17543
+ async function pollExecution(ctx, executionId, timeoutMs) {
17544
+ const started = Date.now();
17545
+ let last;
17546
+ while (Date.now() - started < timeoutMs) {
17547
+ last = await ctx.api.get(`/api/admin/executions/${encodeURIComponent(executionId)}`);
17548
+ if (["completed", "failed", "cancelled", "timeout"].includes(last.status)) {
17549
+ return last;
17550
+ }
17551
+ await new Promise((resolve) => setTimeout(resolve, 1000));
17552
+ }
17553
+ throw new CLIError(`Timed out waiting for MCP workflow execution ${executionId}`, 1 /* Error */, last ? `Last status: ${last.status}` : undefined);
17554
+ }
17555
+ function extractMCPOutput(status) {
17556
+ const stepResults = status.step_results ?? {};
17557
+ const mcpStep = stepResults.mcp;
17558
+ if (!mcpStep) {
17559
+ throw new CLIError("MCP step result not found", 1 /* Error */);
17560
+ }
17561
+ const output = mcpStep.output;
17562
+ if (output && typeof output === "object" && "output" in output) {
17563
+ return output.output;
17564
+ }
17565
+ return output;
17566
+ }
17567
+
17364
17568
  // src/commands/init.ts
17365
17569
  import readline from "node:readline/promises";
17366
17570
  init_config();
@@ -17379,23 +17583,47 @@ var PRESETS = {
17379
17583
  keycloak_realm: "frank"
17380
17584
  }
17381
17585
  };
17586
+ var VALID_INTENTS = ["rag", "documents", "workflows", "custom"];
17587
+ var INTENT_HELP = {
17588
+ rag: "retrieve from collections (query, search, read)",
17589
+ documents: "browse + fetch documents",
17590
+ workflows: "execute workflows (grants the workflows + the rag read set)",
17591
+ custom: "pick exact functions / workflows yourself"
17592
+ };
17382
17593
  async function prompt2(rl, question, fallback) {
17383
17594
  const answer = await rl.question(` ${question} ${source_default.dim(`[${fallback}]`)} `);
17384
17595
  return answer.trim() || fallback;
17385
17596
  }
17597
+ function normalizeList(values) {
17598
+ if (!values)
17599
+ return [];
17600
+ return values.flatMap((v) => v.split(/[,\s]+/)).map((v) => v.trim()).filter(Boolean);
17601
+ }
17602
+ function parseIntents(raw) {
17603
+ const out = [];
17604
+ for (const v of raw) {
17605
+ if (!VALID_INTENTS.includes(v)) {
17606
+ throw new CLIError(`Unknown intent '${v}'. Valid: ${VALID_INTENTS.join(", ")}.`, 4 /* Validation */);
17607
+ }
17608
+ if (!out.includes(v))
17609
+ out.push(v);
17610
+ }
17611
+ return out;
17612
+ }
17386
17613
  async function initCommand(opts) {
17387
17614
  const presetKey = opts.preset ?? "cloud";
17388
17615
  const preset = PRESETS[presetKey];
17389
17616
  if (!preset) {
17390
17617
  throw new CLIError(`Unknown preset: ${presetKey}. Choose one of: ${Object.keys(PRESETS).join(", ")}.`, 4 /* Validation */);
17391
17618
  }
17619
+ const jsonMode = !!opts.json;
17392
17620
  const profileName = opts.name ?? presetKey;
17393
17621
  const config = loadConfig();
17394
17622
  if (config.profiles[profileName] && !opts.force) {
17395
- 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 */);
17623
+ throw new CLIError(`Profile ${source_default.cyan(profileName)} already exists. Re-run with --force to overwrite, or pass --name <name> to add a new one.`, 5 /* Conflict */);
17396
17624
  }
17397
- const interactive = !opts.noInteractive && process.stdin.isTTY && !opts.apiUrl && !opts.keycloakUrl && !opts.keycloakRealm;
17398
- let profile = {
17625
+ const interactive = !opts.noInteractive && !jsonMode && !!process.stdin.isTTY && !opts.apiUrl && !opts.keycloakUrl && !opts.keycloakRealm;
17626
+ const profile = {
17399
17627
  api_url: opts.apiUrl ?? preset.api_url,
17400
17628
  keycloak_url: opts.keycloakUrl ?? preset.keycloak_url,
17401
17629
  keycloak_realm: opts.keycloakRealm ?? preset.keycloak_realm,
@@ -17424,22 +17652,226 @@ Martha CLI — first-run setup
17424
17652
  config.profiles[profileName] = profile;
17425
17653
  config.current_profile = profileName;
17426
17654
  saveConfig(config);
17655
+ if (!jsonMode) {
17656
+ console.log();
17657
+ console.log(source_default.green(`Profile saved.
17658
+ `));
17659
+ console.log(` Profile: ${source_default.cyan(profileName)}`);
17660
+ console.log(` API URL: ${profile.api_url}`);
17661
+ console.log(` Keycloak: ${profile.keycloak_url}`);
17662
+ console.log(` Realm: ${profile.keycloak_realm}`);
17663
+ }
17664
+ const bootstrapResult = await maybeBootstrap(opts, profileName, interactive, jsonMode);
17665
+ if (jsonMode) {
17666
+ console.log(JSON.stringify({
17667
+ profile: { name: profileName, ...profile },
17668
+ bootstrap: bootstrapResult
17669
+ }));
17670
+ return;
17671
+ }
17672
+ if (!bootstrapResult) {
17673
+ console.log();
17674
+ console.log(source_default.dim(" Next:"));
17675
+ console.log(source_default.dim(" martha auth login # browser PKCE"));
17676
+ console.log(source_default.dim(" martha init --bootstrap --intent rag # provision access"));
17677
+ console.log(source_default.dim(" martha doctor # verify setup"));
17678
+ }
17679
+ }
17680
+ async function maybeBootstrap(opts, profileName, interactive, jsonMode) {
17681
+ if (!interactive) {
17682
+ if (!opts.bootstrap)
17683
+ return null;
17684
+ if (!opts.yes) {
17685
+ throw new CLIError("Refusing to bootstrap a principal non-interactively without --yes.", 4 /* Validation */, "Re-run with --yes (and --mode / --intent as needed).");
17686
+ }
17687
+ }
17688
+ let run = !!opts.bootstrap;
17689
+ if (interactive && !run) {
17690
+ run = await askYesNo(`
17691
+ Provision an access principal now so calls just work?`, true);
17692
+ }
17693
+ if (!run)
17694
+ return null;
17695
+ let mode;
17696
+ if (opts.mode) {
17697
+ if (opts.mode !== "human" && opts.mode !== "integration") {
17698
+ throw new CLIError(`Unknown --mode '${opts.mode}'. Use 'human' or 'integration'.`, 4 /* Validation */);
17699
+ }
17700
+ mode = opts.mode;
17701
+ } else if (interactive) {
17702
+ const choice = await askChoice(" How will you run Martha?", [
17703
+ { key: "1", label: "This machine (you)", value: "human" },
17704
+ {
17705
+ key: "2",
17706
+ label: "An integration / CI (service account)",
17707
+ value: "integration"
17708
+ }
17709
+ ], "1");
17710
+ mode = choice;
17711
+ } else {
17712
+ mode = "human";
17713
+ }
17714
+ let intents = parseIntents(normalizeList(opts.intent));
17715
+ if (intents.length === 0 && interactive) {
17716
+ intents = await askIntents();
17717
+ }
17718
+ let workflows = normalizeList(opts.workflow);
17719
+ let functions = normalizeList(opts.function);
17720
+ if (interactive && (intents.includes("workflows") || intents.includes("custom"))) {
17721
+ if (workflows.length === 0) {
17722
+ workflows = normalizeList([
17723
+ await askLine(" Workflow name(s) to grant (comma-separated, optional): ")
17724
+ ]);
17725
+ }
17726
+ }
17727
+ if (interactive && intents.includes("custom") && functions.length === 0) {
17728
+ functions = normalizeList([
17729
+ await askLine(" Extra function name(s) to grant (comma-separated, optional): ")
17730
+ ]);
17731
+ }
17732
+ if (intents.length === 0 && workflows.length === 0 && functions.length === 0) {
17733
+ throw new CLIError("Nothing to grant: declare at least one --intent (rag/documents/workflows/custom) or a --workflow/--function.", 4 /* Validation */);
17734
+ }
17735
+ const ctx = createContext({ profileOverride: profileName });
17736
+ if (opts.apiUrl)
17737
+ ctx.profile.api_url = opts.apiUrl;
17738
+ await ensureAuthenticated(ctx, interactive, jsonMode);
17739
+ if (!jsonMode) {
17740
+ console.log();
17741
+ console.log(source_default.dim(` Provisioning ${mode === "integration" ? "an integration service account" : "your access principal"}…`));
17742
+ }
17743
+ const body = {
17744
+ mode,
17745
+ intents,
17746
+ workflows,
17747
+ functions,
17748
+ ...opts.label ? { label: opts.label } : {}
17749
+ };
17750
+ const resp = await ctx.api.post("/api/admin/definitions/principals/bootstrap", body);
17751
+ if (mode === "human" && resp.client_key) {
17752
+ const cfg = loadConfig();
17753
+ if (cfg.profiles[profileName]) {
17754
+ cfg.profiles[profileName].default_client = resp.client_key;
17755
+ saveConfig(cfg);
17756
+ }
17757
+ }
17758
+ if (!jsonMode)
17759
+ printBootstrapResult(resp, profileName);
17760
+ return resp;
17761
+ }
17762
+ async function ensureAuthenticated(ctx, interactive, jsonMode) {
17763
+ try {
17764
+ await ctx.getTenantId();
17765
+ return;
17766
+ } catch {}
17767
+ if (!interactive || jsonMode) {
17768
+ throw new CLIError("Not authenticated.", 2 /* Auth */, "Run `martha auth login` first (or set MARTHA_TOKEN / MARTHA_CLIENT_ID+SECRET), then re-run with --bootstrap.");
17769
+ }
17427
17770
  console.log();
17428
- console.log(source_default.green(`Profile saved.
17771
+ const tokens = await loginPKCE({
17772
+ keycloakUrl: ctx.profile.keycloak_url,
17773
+ realm: ctx.profile.keycloak_realm,
17774
+ onAuthUrl: (url) => {
17775
+ console.log("Opening browser for login...");
17776
+ console.log(source_default.dim(`If the browser doesn't open, visit:
17429
17777
  `));
17430
- console.log(` Profile: ${source_default.cyan(profileName)}`);
17431
- console.log(` API URL: ${profile.api_url}`);
17432
- console.log(` Keycloak: ${profile.keycloak_url}`);
17433
- console.log(` Realm: ${profile.keycloak_realm}`);
17778
+ console.log(` ${source_default.dim(url)}
17779
+ `);
17780
+ }
17781
+ });
17782
+ ctx.tokenStore.saveTokens(ctx.profileName, tokens);
17783
+ console.log(source_default.green("Logged in") + (tokens.username ? ` as ${source_default.bold(tokens.username)}` : ""));
17784
+ await ctx.getTenantId();
17785
+ }
17786
+ function printBootstrapResult(resp, profileName) {
17434
17787
  console.log();
17435
- console.log(source_default.dim(" Next:"));
17436
- console.log(source_default.dim(" martha auth login # browser PKCE"));
17437
- console.log(source_default.dim(" martha auth login --service-account # CI/agent"));
17438
- console.log(source_default.dim(" martha doctor # verify setup"));
17788
+ const verb = resp.adopted ? "Using existing" : "Created";
17789
+ console.log(source_default.green(`${verb} client ${source_default.bold(resp.client_name)} (#${resp.client_id}).`));
17790
+ if (resp.granted_functions.length > 0) {
17791
+ console.log(` Granted functions: ${source_default.cyan(resp.granted_functions.join(", "))}`);
17792
+ }
17793
+ if (resp.granted_workflows.length > 0) {
17794
+ console.log(` Granted workflows: ${source_default.cyan(resp.granted_workflows.join(", "))}`);
17795
+ }
17796
+ if (resp.granted_functions.length === 0 && resp.granted_workflows.length === 0) {
17797
+ console.log(source_default.yellow(" No grants applied."));
17798
+ }
17799
+ for (const s of resp.skipped) {
17800
+ console.log(source_default.yellow(` Skipped ${s.name} — ${s.reason}`));
17801
+ }
17802
+ if (resp.service_account) {
17803
+ const sa = resp.service_account;
17804
+ console.log();
17805
+ console.log(source_default.bold(" Service-account credentials (shown once — store them now):"));
17806
+ console.log();
17807
+ console.log(` export MARTHA_CLIENT_ID=${sa.client_id}`);
17808
+ console.log(` export MARTHA_CLIENT_SECRET=${sa.client_secret}`);
17809
+ console.log();
17810
+ console.log(source_default.dim(` token endpoint: ${sa.token_endpoint}`));
17811
+ console.log(source_default.dim(" Then: martha auth login --service-account (in your CI environment)"));
17812
+ } else {
17813
+ console.log(source_default.dim(` Profile ${source_default.cyan(profileName)} will now run as this client.`));
17814
+ console.log();
17815
+ console.log(source_default.dim(" Next:"));
17816
+ console.log(source_default.dim(" martha workflows list"));
17817
+ console.log(source_default.dim(" martha workflows execute <name> --inputs '{}'"));
17818
+ }
17819
+ }
17820
+ async function askYesNo(message, fallbackYes) {
17821
+ const rl = readline.createInterface({
17822
+ input: process.stdin,
17823
+ output: process.stdout
17824
+ });
17825
+ try {
17826
+ const hint = fallbackYes ? "[Y/n]" : "[y/N]";
17827
+ const answer = (await rl.question(`${message} ${source_default.dim(hint)} `)).trim().toLowerCase();
17828
+ if (!answer)
17829
+ return fallbackYes;
17830
+ return answer === "y" || answer === "yes";
17831
+ } finally {
17832
+ rl.close();
17833
+ }
17834
+ }
17835
+ async function askLine(message) {
17836
+ const rl = readline.createInterface({
17837
+ input: process.stdin,
17838
+ output: process.stdout
17839
+ });
17840
+ try {
17841
+ return (await rl.question(message)).trim();
17842
+ } finally {
17843
+ rl.close();
17844
+ }
17845
+ }
17846
+ async function askChoice(message, choices, fallbackKey) {
17847
+ console.log(message);
17848
+ for (const c of choices)
17849
+ console.log(` ${c.key}) ${c.label}`);
17850
+ const ans = await askLine(` Choose ${source_default.dim(`[${fallbackKey}]`)} `);
17851
+ const key = ans || fallbackKey;
17852
+ const found = choices.find((c) => c.key === key || c.value === key);
17853
+ if (!found) {
17854
+ throw new CLIError(`Invalid choice '${key}'.`, 4 /* Validation */);
17855
+ }
17856
+ return found.value;
17857
+ }
17858
+ async function askIntents() {
17859
+ console.log(`
17860
+ How will you use Martha? (comma-separated)`);
17861
+ VALID_INTENTS.forEach((intent, i) => {
17862
+ console.log(` ${i + 1}) ${intent} — ${source_default.dim(INTENT_HELP[intent])}`);
17863
+ });
17864
+ const ans = await askLine(" Select [1] ");
17865
+ const raw = (ans || "1").split(/[,\s]+/).map((t) => t.trim()).filter(Boolean).map((t) => {
17866
+ const n = Number(t);
17867
+ return Number.isInteger(n) && n >= 1 && n <= VALID_INTENTS.length ? VALID_INTENTS[n - 1] : t;
17868
+ });
17869
+ return parseIntents(raw);
17439
17870
  }
17440
17871
  function registerInitCommand(program2) {
17441
- 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) => {
17442
- await initCommand(opts);
17872
+ program2.command("init").description("Create or update a profile, and optionally bootstrap an access principal").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").option("--bootstrap", "Provision an intent-scoped principal + grants after saving the profile").option("--mode <mode>", "Bootstrap mode: 'human' (your own client) or 'integration' (service account)").option("--intent <intents...>", "Usage buckets to grant: rag, documents, workflows, custom").option("--workflow <names...>", "Workflow name(s) to grant (workflows/custom intent)").option("--function <names...>", "Extra function name(s) to grant (custom intent)").option("--label <name>", "Integration mode: label for the SA client id").option("--yes", "Acknowledge non-interactive bootstrap side effects").action(async (opts, command) => {
17873
+ const globals = command.parent?.opts() ?? {};
17874
+ await initCommand({ ...opts, json: !!globals.json });
17443
17875
  });
17444
17876
  }
17445
17877
 
@@ -17779,6 +18211,7 @@ registerMessagingCommands(program2);
17779
18211
  registerClientCommands(program2);
17780
18212
  registerModelsCommand(program2);
17781
18213
  registerSessionCommands(program2);
18214
+ registerMCPCommands(program2);
17782
18215
  registerInitCommand(program2);
17783
18216
  registerDoctorCommand(program2);
17784
18217
  registerSkillCommand(program2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/martha-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Terminal-first client for the Martha AI platform",
5
5
  "homepage": "https://docs.martha.nomadriver.co",
6
6
  "repository": {