@indemn/cli 1.7.4 → 1.7.5

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 (127) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.mcp.json +1 -1
  4. package/README.md +2 -2
  5. package/dist/cli/auth.d.ts.map +1 -1
  6. package/dist/cli/auth.js +10 -6
  7. package/dist/cli/auth.js.map +1 -1
  8. package/dist/cli/eval.d.ts.map +1 -1
  9. package/dist/cli/eval.js +8 -2
  10. package/dist/cli/eval.js.map +1 -1
  11. package/dist/cli/index.js +2 -2
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/init.d.ts +1 -1
  14. package/dist/cli/init.d.ts.map +1 -1
  15. package/dist/cli/init.js +15 -5
  16. package/dist/cli/init.js.map +1 -1
  17. package/dist/cli/utils/output.d.ts +0 -1
  18. package/dist/cli/utils/output.d.ts.map +1 -1
  19. package/dist/cli/utils/output.js +0 -4
  20. package/dist/cli/utils/output.js.map +1 -1
  21. package/dist/cli/voice.d.ts.map +1 -1
  22. package/dist/cli/voice.js +4 -1
  23. package/dist/cli/voice.js.map +1 -1
  24. package/dist/core/org-resolver.d.ts +4 -0
  25. package/dist/core/org-resolver.d.ts.map +1 -1
  26. package/dist/core/org-resolver.js +11 -0
  27. package/dist/core/org-resolver.js.map +1 -1
  28. package/dist/core/runtime.d.ts +9 -0
  29. package/dist/core/runtime.d.ts.map +1 -1
  30. package/dist/core/runtime.js +49 -0
  31. package/dist/core/runtime.js.map +1 -1
  32. package/dist/core/scope.d.ts +0 -1
  33. package/dist/core/scope.d.ts.map +1 -1
  34. package/dist/core/scope.js.map +1 -1
  35. package/dist/mcp/registry.d.ts.map +1 -1
  36. package/dist/mcp/registry.js +15 -2
  37. package/dist/mcp/registry.js.map +1 -1
  38. package/dist/mcp/server.d.ts +1 -1
  39. package/dist/mcp/server.d.ts.map +1 -1
  40. package/dist/mcp/server.js +2 -19
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/mcp/tool-meta.js +5 -5
  43. package/dist/mcp/tool-meta.js.map +1 -1
  44. package/dist/mcp/tools.js +1 -1
  45. package/dist/mcp/tools.js.map +1 -1
  46. package/dist/report/export.d.ts.map +1 -1
  47. package/dist/report/export.js +2 -3
  48. package/dist/report/export.js.map +1 -1
  49. package/dist/report/generate.d.ts.map +1 -1
  50. package/dist/report/generate.js +3 -3
  51. package/dist/report/generate.js.map +1 -1
  52. package/dist/sdk/auth.d.ts +8 -3
  53. package/dist/sdk/auth.d.ts.map +1 -1
  54. package/dist/sdk/auth.js +13 -5
  55. package/dist/sdk/auth.js.map +1 -1
  56. package/dist/sdk/chat.d.ts.map +1 -1
  57. package/dist/sdk/chat.js +1 -0
  58. package/dist/sdk/chat.js.map +1 -1
  59. package/dist/sdk/client.d.ts +11 -9
  60. package/dist/sdk/client.d.ts.map +1 -1
  61. package/dist/sdk/client.js +50 -67
  62. package/dist/sdk/client.js.map +1 -1
  63. package/dist/sdk/config-change.js +1 -1
  64. package/dist/sdk/config-change.js.map +1 -1
  65. package/dist/sdk/conversations.js +1 -1
  66. package/dist/sdk/conversations.js.map +1 -1
  67. package/dist/sdk/functions.js +2 -2
  68. package/dist/sdk/functions.js.map +1 -1
  69. package/dist/sdk/jarvis-chat.js +2 -2
  70. package/dist/sdk/jarvis-chat.js.map +1 -1
  71. package/dist/sdk/kb.d.ts.map +1 -1
  72. package/dist/sdk/kb.js +3 -2
  73. package/dist/sdk/kb.js.map +1 -1
  74. package/dist/sdk/orgs.d.ts.map +1 -1
  75. package/dist/sdk/orgs.js +1 -6
  76. package/dist/sdk/orgs.js.map +1 -1
  77. package/dist/sdk/session.d.ts +2 -9
  78. package/dist/sdk/session.d.ts.map +1 -1
  79. package/dist/sdk/session.js +2 -27
  80. package/dist/sdk/session.js.map +1 -1
  81. package/dist/sdk/test-chat.d.ts.map +1 -1
  82. package/dist/sdk/test-chat.js +17 -6
  83. package/dist/sdk/test-chat.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/cli/auth.ts +9 -6
  86. package/src/cli/eval.ts +6 -2
  87. package/src/cli/index.ts +2 -2
  88. package/src/cli/init.ts +14 -6
  89. package/src/cli/utils/output.ts +0 -4
  90. package/src/cli/voice.ts +3 -1
  91. package/src/core/org-resolver.ts +18 -0
  92. package/src/core/runtime.ts +46 -0
  93. package/src/core/scope.ts +0 -1
  94. package/src/mcp/registry.ts +17 -2
  95. package/src/mcp/server.ts +2 -19
  96. package/src/mcp/tool-meta.ts +5 -5
  97. package/src/mcp/tools.ts +1 -1
  98. package/src/report/export.ts +2 -1
  99. package/src/report/generate.tsx +3 -2
  100. package/src/sdk/auth.ts +15 -5
  101. package/src/sdk/chat.ts +1 -0
  102. package/src/sdk/client.ts +50 -72
  103. package/src/sdk/config-change.ts +1 -1
  104. package/src/sdk/conversations.ts +1 -1
  105. package/src/sdk/functions.ts +2 -2
  106. package/src/sdk/jarvis-chat.ts +2 -2
  107. package/src/sdk/kb.ts +3 -2
  108. package/src/sdk/orgs.ts +1 -4
  109. package/src/sdk/session.ts +2 -25
  110. package/src/sdk/test-chat.ts +16 -6
  111. package/test/cli/init.test.ts +14 -0
  112. package/test/core/org-resolver.test.ts +38 -2
  113. package/test/core/scope.test.ts +12 -0
  114. package/test/helpers/mcp-pair.ts +10 -1
  115. package/test/mcp/darwin-fixes.test.ts +18 -17
  116. package/test/mcp/registry.test.ts +11 -0
  117. package/test/mcp/shaping.test.ts +18 -0
  118. package/test/mcp/tools-snapshot.test.ts +4 -0
  119. package/test/sdk/auth.test.ts +11 -2
  120. package/test/sdk/client.test.ts +13 -0
  121. package/test/sdk/config-change.test.ts +1 -1
  122. package/test/sdk/kb-upload.test.ts +21 -0
  123. package/test/sdk/orgs.test.ts +18 -0
  124. package/test/sdk/renewal.test.ts +43 -64
  125. package/test/sdk/session.test.ts +1 -23
  126. package/test/sdk/skills.test.ts +20 -18
  127. package/test/sdk/test-chat.test.ts +29 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indemn/cli",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "description": "CLI, MCP server, and plugin for managing Indemn AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/sdk/index.js",
package/src/cli/auth.ts CHANGED
@@ -3,7 +3,7 @@ import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import * as readline from 'readline';
5
5
  import { Writable } from 'node:stream';
6
- import { login, logout, whoami, type WhoAmI } from '../sdk/auth.js';
6
+ import { customHosts, login, logout, whoami, type WhoAmI } from '../sdk/auth.js';
7
7
  import { findLinkFile } from '../core/org-resolver.js';
8
8
  import { getOrgFlag } from '../core/scope.js';
9
9
  import { SessionExpiredError } from '../core/runtime.js';
@@ -103,7 +103,7 @@ export function registerAuthCommands(program: Command): void {
103
103
  });
104
104
  spinner.succeed(`Logged in as ${result.email} on ${result.environment}`);
105
105
  if (result.expiresAt) {
106
- console.log(chalk.dim(` Session valid until ${new Date(result.expiresAt).toLocaleString()}; it renews itself.`));
106
+ console.log(chalk.dim(` Session valid until ${new Date(result.expiresAt).toLocaleString()}; run \`indemn login\` again after that.`));
107
107
  }
108
108
  if (result.orgs.length === 0) {
109
109
  console.log(chalk.yellow(' You are not a member of any organization yet. Ask an admin to add you.'));
@@ -164,16 +164,19 @@ export function registerAuthCommands(program: Command): void {
164
164
  if (info.email) console.log(` User: ${info.email}`);
165
165
  console.log(` Credential: ${describeCredential(info)}`);
166
166
  if (info.sessionExpiresAt) {
167
- console.log(` Session: valid until ${new Date(info.sessionExpiresAt).toLocaleString()} (renews itself)`);
167
+ console.log(` Session: valid until ${new Date(info.sessionExpiresAt).toLocaleString()} (then \`indemn login\`)`);
168
168
  }
169
169
  console.log(` Org here: ${describeOrgRule(info)}`);
170
170
  if (info.orgs.length) {
171
171
  console.log(` Orgs:`);
172
172
  for (const o of info.orgs) console.log(` ${o.id} ${o.name}`);
173
173
  }
174
- console.log(` Copilot: ${info.hosts.copilot}`);
175
- console.log(` Evaluations: ${info.hosts.evaluations}`);
176
- console.log(` Middleware: ${info.hosts.middleware}`);
174
+ // The servers only when one is not the platform's default: the usual ones are noise.
175
+ const custom = customHosts(info);
176
+ if (custom.length) {
177
+ console.log(` Custom hosts (not the ${info.environment} defaults):`);
178
+ for (const [label, url] of custom) console.log(` ${label.padEnd(12)} ${url}`);
179
+ }
177
180
  } catch (err) {
178
181
  printError(err);
179
182
  process.exit(1);
package/src/cli/eval.ts CHANGED
@@ -78,11 +78,15 @@ export function registerEvalCommands(program: Command): void {
78
78
 
79
79
  const items = (testSet as { items?: unknown[] }).items?.length ?? 0;
80
80
  const count = input.mode === 'transcript' ? 'real conversations' : `${input.limit ? Math.min(input.limit, items) : items} item(s)`;
81
+ const minPassRate = options.minPassRate !== undefined ? Number(options.minPassRate) : undefined;
82
+ const maxP95Ms = options.maxP95Ms !== undefined ? Number(options.maxP95Ms) : undefined;
83
+ if (minPassRate !== undefined && !(minPassRate >= 0 && minPassRate <= 1)) throw new CliError('--min-pass-rate is a fraction from 0 to 1 (0.9 means 90%).', EXIT.NEEDS_INPUT);
84
+ if (maxP95Ms !== undefined && !(maxP95Ms >= 0)) throw new CliError('--max-p95-ms is a number of milliseconds, 0 or more.', EXIT.NEEDS_INPUT);
81
85
  await confirmOrExit(`Run an evaluation of agent ${agentId} on ${count} with concurrency ${input.concurrency ?? 1}? It uses paid model calls and cannot be cancelled.`, Boolean(options.yes));
82
86
 
83
87
  const gate: GateOptions = {
84
- minPassRate: options.minPassRate !== undefined ? Number(options.minPassRate) : undefined,
85
- maxP95Ms: options.maxP95Ms !== undefined ? Number(options.maxP95Ms) : undefined,
88
+ minPassRate,
89
+ maxP95Ms,
86
90
  errorPolicy: options.errorPolicy === 'warn' ? 'warn' : 'block',
87
91
  failOnError: Boolean(options.failOnError),
88
92
  };
package/src/cli/index.ts CHANGED
@@ -21,7 +21,7 @@ import { registerBillingCommands } from './billing.js';
21
21
  import { registerCampaignCommands } from './campaigns.js';
22
22
  import { registerPlatformCommands } from './platform.js';
23
23
  import { registerInitCommand } from './init.js';
24
- import { askMfaInTerminal, askOrgInTerminal, installBanner } from './utils/output.js';
24
+ import { askOrgInTerminal, installBanner } from './utils/output.js';
25
25
  import { setAskers, setOrgFlag } from '../core/scope.js';
26
26
  import { cliVersion, isNonInteractive } from '../core/runtime.js';
27
27
 
@@ -71,7 +71,7 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
71
71
 
72
72
  // Ask in the terminal only when a person can answer; agents and CI get exit code 2 instead.
73
73
  if (fullCommandName(actionCommand) !== 'mcp serve' && !isNonInteractive()) {
74
- setAskers({ chooseOrg: askOrgInTerminal, mfaCode: askMfaInTerminal });
74
+ setAskers({ chooseOrg: askOrgInTerminal });
75
75
  }
76
76
 
77
77
  // Banner suppression: global flag, env var, command opts-out, or JSON output mode.
package/src/cli/init.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { cliVersion } from '../core/runtime.js';
2
3
  import chalk from 'chalk';
3
4
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
5
  import { dirname, join, resolve } from 'node:path';
@@ -8,7 +9,9 @@ import { IndemnClient } from '../sdk/client.js';
8
9
  import { findLinkFile } from '../core/org-resolver.js';
9
10
  import { printError, printSuccess } from './utils/output.js';
10
11
 
11
- export const MCP_SERVER_ENTRY = { command: 'npx', args: ['-y', '@indemn/cli', 'mcp', 'serve'] };
12
+ // Pinned to this CLI's own version: an unpinned npx entry runs whatever is newest on npm at each start.
13
+ const PACKAGE = `@indemn/cli@${cliVersion()}`;
14
+ export const MCP_SERVER_ENTRY = { command: 'npx', args: ['-y', PACKAGE, 'mcp', 'serve'] };
12
15
 
13
16
  /** Claude Code permission rules: read-only tools run without asking, anything that changes the platform asks every time. */
14
17
  export function permissionBlock(): { allow: string[]; ask: string[] } {
@@ -49,7 +52,7 @@ The \`indemn\` CLI and its MCP server manage Indemn AI agents. Run \`indemn whoa
49
52
  export interface InitReport {
50
53
  skills: string;
51
54
  rules: string;
52
- mcp: 'added' | 'kept' | 'skipped';
55
+ mcp: 'added' | 'updated' | 'kept' | 'skipped';
53
56
  permissions: 'added' | 'merged' | 'skipped';
54
57
  }
55
58
 
@@ -76,12 +79,17 @@ export function initProject(cwd: string, opts: { force?: boolean; mcp?: boolean;
76
79
  const path = join(cwd, '.mcp.json');
77
80
  const cfg = readJson(path);
78
81
  cfg.mcpServers = cfg.mcpServers ?? {};
79
- if (cfg.mcpServers.indemn) {
82
+ // An npx @indemn/cli entry (any version) moves to this version, so the server matches the skills just installed;
83
+ // only the package's version changes (an --org, env and the rest stay). Any other entry is the user's own and is kept.
84
+ const had = cfg.mcpServers.indemn;
85
+ const at = had?.command === 'npx' && Array.isArray(had.args) ? had.args.findIndex((a: unknown) => typeof a === 'string' && /^@indemn\/cli(@|$)/.test(a)) : -1;
86
+ if (had && (at < 0 || had.args[at] === PACKAGE)) {
80
87
  mcp = 'kept';
81
88
  } else {
82
- cfg.mcpServers.indemn = MCP_SERVER_ENTRY;
89
+ if (had) had.args[at] = PACKAGE;
90
+ else cfg.mcpServers.indemn = MCP_SERVER_ENTRY;
83
91
  writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n');
84
- mcp = 'added';
92
+ mcp = had ? 'updated' : 'added';
85
93
  }
86
94
  }
87
95
 
@@ -141,7 +149,7 @@ export function registerInitCommand(program: Command): void {
141
149
  printSuccess('Indemn CLI set up for Claude Code:');
142
150
  console.log(` Skills: ${r.skills}`);
143
151
  console.log(` Rules: ${r.rules}`);
144
- console.log(` MCP server: ${r.mcp === 'added' ? 'added to .mcp.json' : r.mcp === 'kept' ? 'already in .mcp.json (kept)' : 'skipped'}`);
152
+ console.log(` MCP server: ${{ added: 'added to .mcp.json', updated: `updated to ${cliVersion()} in .mcp.json`, kept: 'already in .mcp.json (kept)', skipped: 'skipped' }[r.mcp]}`);
145
153
  console.log(` Permissions: ${r.permissions === 'skipped' ? 'skipped' : `${r.permissions} in .claude/settings.json (reads allowed, changes ask)`}`);
146
154
  const login = IndemnClient.loadConfig();
147
155
  if (!login) console.log(chalk.yellow('Next: run `indemn login --env dev` (or production).'));
@@ -80,10 +80,6 @@ export async function askOrgInTerminal(choices: OrgChoice[]): Promise<string | u
80
80
  return choices.find((c) => c.id === answer)?.id;
81
81
  }
82
82
 
83
- export async function askMfaInTerminal(): Promise<string> {
84
- process.stderr.write(chalk.yellow('Your session needs renewing and your account uses MFA. A code was emailed to you.\n'));
85
- return askLine('MFA code: ');
86
- }
87
83
 
88
84
  export async function confirm(message: string): Promise<boolean> {
89
85
  const rl = readline.createInterface({
package/src/cli/voice.ts CHANGED
@@ -3,7 +3,7 @@ import chalk from 'chalk';
3
3
  import { writeFileSync } from 'node:fs';
4
4
  import { IndemnClient } from '../sdk/client.js';
5
5
  import { VoiceSDK, toE164 } from '../sdk/voice.js';
6
- import { CliError, EXIT } from '../core/runtime.js';
6
+ import { CliError, EXIT, isNonInteractive } from '../core/runtime.js';
7
7
  import { confirmOrExit } from './link.js';
8
8
  import { formatTable, printError, printJson, printSuccess } from './utils/output.js';
9
9
 
@@ -175,6 +175,8 @@ export function registerVoiceCommands(program: Command): void {
175
175
  .description('Buy an available number and attach it to a voice agent (costs money every month)')
176
176
  .action((agentId: string, number: string, options) => run(async () => {
177
177
  const phone = toE164(number, String(options.country).toUpperCase());
178
+ // As the MCP tool: no purchase without a person (an AI host, CI or a script cannot answer for one).
179
+ if (isNonInteractive()) throw new CliError('Buying a number costs money every month: run this yourself at a terminal.', EXIT.NEEDS_INPUT);
178
180
  if (options.yes && !options.confirmPurchase) throw new CliError('Buying a number costs money: pass --confirm-purchase with --yes, or run without --yes to be asked.', EXIT.NEEDS_INPUT);
179
181
  await confirmOrExit(`Buy ${phone} and attach it to agent ${agentId}? The org pays for it every month, and it cannot be undone from the CLI.`, Boolean(options.yes));
180
182
  const res = await new VoiceSDK(new IndemnClient()).buyNumber(agentId, number, { country: options.country, tollFree: Boolean(options.tollFree) });
@@ -72,6 +72,10 @@ export async function resolveOrg(input: {
72
72
  link: LinkFile | null;
73
73
  environment: Environment;
74
74
  memberships: () => Promise<OrgChoice[]>;
75
+ /** The orgs saved at `indemn login` (none for a key or a host's session): an explicit org is checked against them. */
76
+ known?: OrgChoice[];
77
+ /** The orgs as they are now, for an explicit org missing from `known` (one joined since login). */
78
+ refresh?: () => Promise<OrgChoice[]>;
75
79
  ask?: (choices: OrgChoice[]) => Promise<string | undefined>;
76
80
  }): Promise<ResolvedOrg> {
77
81
  const { link } = input;
@@ -91,6 +95,20 @@ export async function resolveOrg(input: {
91
95
  EXIT.NEEDS_INPUT,
92
96
  );
93
97
  }
98
+ // Checked against the orgs saved at login (no request), so a typo names them rather than a bare "Access denied".
99
+ // One missing from them is looked up again (an org joined since login). Without them (an API key, a host's
100
+ // session), or when the look-up fails, copilot's own check decides, as before.
101
+ const known = input.known ?? [];
102
+ if (known.length && !known.some((o) => o.id === explicit)) {
103
+ const orgs = input.refresh ? await input.refresh().catch(() => undefined) : known;
104
+ if (orgs && !orgs.some((o) => o.id === explicit)) {
105
+ throw new CliError(
106
+ `You are not a member of org ${explicit}` +
107
+ (orgs.length ? `. Your orgs: ${orgs.map((o) => `${o.name} (${o.id})`).join(', ')}.` : ', or of any organization on this platform.'),
108
+ EXIT.NEEDS_INPUT,
109
+ );
110
+ }
111
+ }
94
112
  return { id: explicit, source: input.flag ? 'flag' : 'env', link };
95
113
  }
96
114
 
@@ -1,3 +1,6 @@
1
+ import { realpathSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, join, sep } from 'node:path';
1
4
  import { createRequire } from 'node:module';
2
5
 
3
6
  /** Process exit codes. Scripts and agents branch on these. */
@@ -71,3 +74,46 @@ export function cliVersion(): string {
71
74
 
72
75
  /** How long a question to a person (through an MCP host) may stay open. The MCP default of 60 s is too short to read a diff. */
73
76
  export const PERSON_TIMEOUT_MS = 10 * 60_000;
77
+
78
+ /** Only Indemn's own hosts (https/wss on indemn.ai), or this machine for local development, ever get the session. */
79
+ export function indemnHost(url: string, name: string): string {
80
+ let u: URL;
81
+ try {
82
+ u = new URL(url);
83
+ } catch {
84
+ throw new CliError(`${name} is not a valid URL: ${url}`);
85
+ }
86
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
87
+ const indemn = (u.protocol === 'https:' || u.protocol === 'wss:') && (u.hostname === 'indemn.ai' || u.hostname.endsWith('.indemn.ai'));
88
+ if (local || indemn) return url;
89
+ throw new CliError(`${name} points to ${u.protocol}//${u.host}: the CLI sends your session only to Indemn's hosts (https://*.indemn.ai) or localhost.`);
90
+ }
91
+
92
+ // Where secrets live in the home folder: the CLI's own session and the usual credential folders.
93
+ const SECRET_DIRS = ['.indemn', '.ssh', '.aws', '.azure', '.gnupg', '.docker', '.kube', '.config'];
94
+
95
+ /** A file the CLI may send off the machine: never a dotfile (.env, .npmrc…), one in a secret folder above, or /proc. */
96
+ export function uploadable(filePath: string): string {
97
+ const real = realpathSync(filePath);
98
+ let home = homedir();
99
+ try { home = realpathSync(home); } catch { /* as given */ }
100
+ if (real.startsWith('/proc/') || basename(real).startsWith('.') || SECRET_DIRS.some((dir) => real.startsWith(join(home, dir) + sep))) {
101
+ throw new CliError(`Refusing to upload ${filePath}: dotfiles and secret folders (such as ~/.indemn, ~/.ssh, ~/.aws) are never sent.`);
102
+ }
103
+ return real;
104
+ }
105
+
106
+ /**
107
+ * Write a report. A path the caller chose must be a new file of the report's own type (never over an existing
108
+ * file); the default name in the current folder is replaced, as before.
109
+ */
110
+ export function writeReport(filePath: string, data: string | Uint8Array, ext: '.md' | '.pdf', chosen: boolean): void {
111
+ if (chosen && !filePath.toLowerCase().endsWith(ext)) throw new CliError(`This report is a ${ext} file: give an output path ending in ${ext}.`);
112
+ try {
113
+ writeFileSync(filePath, data, { flag: chosen ? 'wx' : 'w' });
114
+ } catch (err) {
115
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') throw new CliError(`${filePath} already exists; nothing was replaced. Choose another output path.`);
116
+ throw err;
117
+ }
118
+ }
119
+
package/src/core/scope.ts CHANGED
@@ -35,7 +35,6 @@ export interface FormField {
35
35
 
36
36
  export interface Askers {
37
37
  chooseOrg?: (choices: OrgChoice[]) => Promise<string | undefined>;
38
- mfaCode?: () => Promise<string>;
39
38
  /** Ask for several text fields at once; undefined when declined or nobody can answer. */
40
39
  form?: (message: string, fields: FormField[]) => Promise<Record<string, string> | undefined>;
41
40
  }
@@ -40,7 +40,14 @@ function fit(body: { context: ResultContext; data: unknown }): CallToolResult {
40
40
  const hint = 'Result was too large. Ask for less: use limit/page, a filter, or response_format "concise".';
41
41
  const list = Array.isArray(body.data) ? body.data : (body.data as { items?: unknown[] } | null)?.items;
42
42
  if (Array.isArray(list)) {
43
- const shaped = (keep: number) => ({ context: body.context, data: { items: list.slice(0, keep), returned: keep, total: list.length, truncated: true, hint } });
43
+ // A page keeps its page, limit, total, has_more and next: only its items are trimmed.
44
+ const page = Array.isArray(body.data) ? {} : (body.data as Record<string, unknown>);
45
+ // A trimmed page left items out: it is never "complete", and the way on starts where this page did, at a
46
+ // limit that fits (no item skipped; a few may come again).
47
+ const rest = (keep: number) => typeof page.page === 'number' && typeof page.limit === 'number'
48
+ ? { has_more: true, next: `Only ${keep} of this page's ${list.length} items fit: call again with limit ${Math.max(keep, 1)} and page ${Math.floor(((page.page - 1) * page.limit) / Math.max(keep, 1)) + 1}, then the pages after it.` }
49
+ : {};
50
+ const shaped = (keep: number) => ({ context: body.context, data: { total: list.length, ...page, items: list.slice(0, keep), returned: keep, truncated: true, ...rest(keep), hint } });
44
51
  let keep = list.length;
45
52
  while (keep > 0 && JSON.stringify(shaped(keep)).length > MAX_RESULT_CHARS) keep = Math.floor(keep * 0.7);
46
53
  return text(shaped(keep));
@@ -92,6 +99,12 @@ export function fitForm(text: string, maxLines = 8, maxLine = 140): string {
92
99
  return cut ? `${shown.join('\n')}\n(Shortened to fit; ask to see the full change before approving.)` : text;
93
100
  }
94
101
 
102
+ // Dry runs shown to the caller, by tool and arguments: on a host without approval forms, `confirm: true` is taken
103
+ // only for a change that was previewed first (the model cannot approve a change nobody was shown).
104
+ const previewed = new Set<string>();
105
+ const previewKey = (toolName: string, args: Args) =>
106
+ `${toolName}:${JSON.stringify(Object.keys(args).filter((k) => k !== 'confirm').sort().map((k) => [k, args[k]]))}`;
107
+
95
108
  export async function guardDestructive(
96
109
  server: McpServer,
97
110
  toolName: string,
@@ -120,7 +133,9 @@ export async function guardDestructive(
120
133
  return { proceed: false, result: text({ cancelled: true, action: toolName, would: preview, reason: 'The approval form could not be shown, so nothing was changed. Tell the person; they can ask again.' }) };
121
134
  }
122
135
  }
123
- if (args.confirm === true) return { proceed: true, byPerson: false };
136
+ const key = previewKey(toolName, args);
137
+ if (args.confirm === true && previewed.delete(key)) return { proceed: true, byPerson: false };
138
+ previewed.add(key);
124
139
  return {
125
140
  proceed: false,
126
141
  result: text({
package/src/mcp/server.ts CHANGED
@@ -3,13 +3,13 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { IndemnClient } from '../sdk/client.js';
4
4
  import { registerTools } from './tools.js';
5
5
  import { setAskers, type Askers } from '../core/scope.js';
6
- import { cliVersion, SessionExpiredError, PERSON_TIMEOUT_MS } from '../core/runtime.js';
6
+ import { cliVersion, PERSON_TIMEOUT_MS } from '../core/runtime.js';
7
7
 
8
8
  /**
9
9
  * Questions the CLI may need answered during a tool call, asked through the
10
10
  * host (Claude Code shows a dialog). Stdin carries the MCP protocol, so the
11
11
  * terminal is never used. Without elicitation: the org question becomes a
12
- * needs_input result, and an MFA renewal asks the person to log in again.
12
+ * needs_input result.
13
13
  */
14
14
  export function mcpAskers(server: McpServer): Askers {
15
15
  const canAsk = () => Boolean(server.server.getClientCapabilities()?.elicitation);
@@ -55,23 +55,6 @@ export function mcpAskers(server: McpServer): Askers {
55
55
  return undefined;
56
56
  }
57
57
  },
58
- mfaCode: async () => {
59
- if (!canAsk()) {
60
- throw new SessionExpiredError('The session needs renewing and your account uses MFA. Run `indemn login` in a terminal, then retry.');
61
- }
62
- const answer = await ask({
63
- message: 'Your Indemn session needs renewing. Enter the MFA code that was just emailed to you.',
64
- requestedSchema: {
65
- type: 'object',
66
- properties: { code: { type: 'string', title: 'MFA code', description: '6 digits', minLength: 6, maxLength: 6 } },
67
- required: ['code'],
68
- },
69
- });
70
- if (answer.action !== 'accept' || typeof answer.content?.code !== 'string') {
71
- throw new SessionExpiredError('MFA code not entered. Run `indemn login` in a terminal, then retry.');
72
- }
73
- return answer.content.code;
74
- },
75
58
  };
76
59
  }
77
60
 
@@ -76,10 +76,10 @@ export const TOOL_META: Record<string, ToolMeta> = {
76
76
 
77
77
  // Skills (the CLI still calls them functions)
78
78
  list_functions: { ...read('List skills'), paginate: true },
79
- create_function: write('Create skill'),
79
+ create_function: { ...write('Create skill'), guard: true, describe: (a) => `This will add skill "${a.name}" to agent ${a.agent_id}; the agent can use it at once.` },
80
80
  update_function: { ...write('Update skill', { idempotentHint: true }), guard: true, describe: (a) => `This will change skill ${a.function_id} of agent ${a.agent_id}.` },
81
81
  delete_function: { ...destroy('Delete skill'), guard: true, describe: (a) => `This will delete skill ${a.function_id} from agent ${a.agent_id}.` },
82
- test_function: write('Test skill', { openWorldHint: true }),
82
+ test_function: { ...write('Test skill', { openWorldHint: true }), guard: true, describe: (a) => `This will call the real endpoint of skill ${a.function_id} on agent ${a.agent_id}.` },
83
83
  export_functions: read('Export skills'),
84
84
  import_functions: {
85
85
  ...destroy('Import skills, replacing existing ones', { openWorldHint: true }),
@@ -107,9 +107,9 @@ export const TOOL_META: Record<string, ToolMeta> = {
107
107
  update_knowledge_base: write('Update knowledge base', { idempotentHint: true }),
108
108
  delete_knowledge_base: { ...destroy('Delete knowledge base'), guard: true, describe: (a) => `This will delete knowledge base ${a.kb_id} with all its sources and vectors.` },
109
109
  list_kb_data: read('List knowledge base entries'),
110
- add_kb_data: write('Add knowledge base entry'),
111
- scrape_url: write('Scrape a website into a knowledge base', { openWorldHint: true }),
112
- upload_kb_file: write('Upload file to knowledge base'),
110
+ add_kb_data: { ...write('Add knowledge base entry'), guard: true, describe: (a) => `This will add an entry to knowledge base ${a.kb_id}; agents using it answer from it at once.` },
111
+ scrape_url: { ...write('Scrape a website into a knowledge base', { openWorldHint: true }), guard: true, describe: (a) => `This will crawl ${a.url} into knowledge base ${a.kb_id}; agents using it answer from it once it is imported.` },
112
+ upload_kb_file: { ...write('Upload file to knowledge base'), guard: true, describe: (a) => `This will upload ${a.file_path} into knowledge base ${a.kb_id}; agents using it answer from it once it is imported.` },
113
113
  update_kb_data: {
114
114
  ...write('Update knowledge base entry', { idempotentHint: true }),
115
115
  guard: true,
package/src/mcp/tools.ts CHANGED
@@ -385,7 +385,7 @@ export function registerTools(server: McpServer, client: IndemnClient): Registry
385
385
  );
386
386
 
387
387
  // Lifecycle lives on the V2 platform; "unknown" when it cannot be read (treated as published: mayBeLive).
388
- const lifecycleOf = (id: string) => new AgentInsightsSDK(client).detail(id).then((d) => d.lifecycle ?? 'prototype', () => 'unknown');
388
+ const lifecycleOf = (id: string) => new AgentInsightsSDK(client).detail(id).then((d) => d.lifecycle ?? 'unknown', () => 'unknown');
389
389
  // A change to an agent that may be live asks first; the approval names the fields, never their values (skill auth).
390
390
  const liveChange = (what: string, skip: string[]) => ({
391
391
  guardWhen: async (a: Record<string, unknown>) => mayBeLive(await lifecycleOf(String(a.agent_id))),
@@ -1,6 +1,7 @@
1
1
  // Shared export logic used by both CLI commands and MCP tools
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import { writeReport } from '../core/runtime.js';
4
5
  import { IndemnClient } from '../sdk/client.js';
5
6
  import { EvalSDK } from '../sdk/eval.js';
6
7
  import { RubricsSDK } from '../sdk/rubrics.js';
@@ -45,7 +46,7 @@ export async function exportEvalMarkdown(
45
46
  const defaultFilename = `Indemn_Evaluation_${cleanName}_${cleanDate}.md`;
46
47
 
47
48
  const resolvedPath = path.resolve(outputPath || defaultFilename);
48
- fs.writeFileSync(resolvedPath, markdown);
49
+ writeReport(resolvedPath, markdown, '.md', Boolean(outputPath));
49
50
 
50
51
  return {
51
52
  file_path: resolvedPath,
@@ -1,5 +1,6 @@
1
1
  // PDF rendering driver — generates agent card and eval report PDFs
2
2
  import { renderToBuffer } from '@react-pdf/renderer';
3
+ import { writeReport } from '../core/runtime.js';
3
4
  import * as fs from 'fs';
4
5
  import * as path from 'path';
5
6
  import { AgentCardDocument } from './agent-card/document.js';
@@ -18,7 +19,7 @@ export async function generateAgentCardPDF(data: AgentCardData, outputPath?: str
18
19
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
20
  const buffer = await renderToBuffer(<AgentCardDocument data={data} /> as any);
20
21
  const resolvedPath = path.resolve(filename);
21
- fs.writeFileSync(resolvedPath, buffer);
22
+ writeReport(resolvedPath, buffer, '.pdf', Boolean(outputPath));
22
23
  return resolvedPath;
23
24
  }
24
25
 
@@ -28,7 +29,7 @@ export async function generateEvalReportPDF(data: EvalReportData, outputPath?: s
28
29
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
30
  const buffer = await renderToBuffer(<EvalReportDocument data={data} /> as any);
30
31
  const resolvedPath = path.resolve(filename);
31
- fs.writeFileSync(resolvedPath, buffer);
32
+ writeReport(resolvedPath, buffer, '.pdf', Boolean(outputPath));
32
33
  return resolvedPath;
33
34
  }
34
35
 
package/src/sdk/auth.ts CHANGED
@@ -46,15 +46,15 @@ export interface LoginResult {
46
46
  }
47
47
 
48
48
  /**
49
- * Log in and keep the copilot session token, the same one the dashboard uses.
50
- * No API key is created. The Firebase refresh token is kept so the session
51
- * can renew itself.
49
+ * Log in and keep the copilot session token, the same one the dashboard uses (24 hours).
50
+ * No API key is created, and the Firebase refresh token is not kept: nothing that
51
+ * outlives logout stays on disk, so access comes back only through `indemn login`.
52
52
  */
53
53
  export async function login(email: string, password: string, env: Environment = 'dev', opts?: {
54
54
  mfaCodePrompt?: () => Promise<string>;
55
55
  firebaseSignIn?: FirebaseSignIn;
56
56
  }): Promise<LoginResult> {
57
- const { idToken, refreshToken } = await (opts?.firebaseSignIn ?? firebaseSignIn)(email, password, env);
57
+ const { idToken } = await (opts?.firebaseSignIn ?? firebaseSignIn)(email, password, env);
58
58
 
59
59
  const unauth = new IndemnClient({ environment: env });
60
60
  const session = await exchangeIdToken(
@@ -68,7 +68,7 @@ export async function login(email: string, password: string, env: Environment =
68
68
  const res = await authed.copilotGet<{ userOrganizations?: Array<{ _id: string; name: string }> }>('/organization');
69
69
  const orgs = (res.userOrganizations ?? []).map((o) => ({ id: String(o._id), name: o.name }));
70
70
 
71
- const stored = toStoredSession(session.token, refreshToken);
71
+ const stored = toStoredSession(session.token);
72
72
  IndemnClient.clearConfig();
73
73
  IndemnClient.saveConfig({
74
74
  environment: env,
@@ -128,3 +128,13 @@ export function whoami(): WhoAmI | null {
128
128
  hosts: client.getHosts(),
129
129
  };
130
130
  }
131
+
132
+ /** The hosts that are not the environment's defaults (set by an env var or the saved config): all whoami lists. */
133
+ export function customHosts(info: { environment: Environment; hosts: EnvironmentHosts }): Array<[string, string]> {
134
+ const labels: Record<keyof EnvironmentHosts, string> = { copilot: 'Copilot', evaluations: 'Evaluations', middleware: 'Middleware', platform: 'V2 platform' };
135
+ const defaults = DEFAULT_HOSTS[info.environment];
136
+ return (Object.keys(labels) as Array<keyof EnvironmentHosts>)
137
+ .filter((key) => info.hosts[key] && info.hosts[key] !== defaults[key])
138
+ .map((key) => [labels[key], String(info.hosts[key])]);
139
+ }
140
+
package/src/sdk/chat.ts CHANGED
@@ -87,6 +87,7 @@ export class ChatSession extends EventEmitter {
87
87
  this.socket.on('connect_error', (err: Error) => {
88
88
  clearTimeout(timeout);
89
89
  reject(new Error(`Connection failed: ${err.message}`));
90
+ void this.disconnect({ end: false }); // no retries in the background, each opening another conversation
90
91
  });
91
92
 
92
93
  this.socket.on('disconnect', (reason: string) => {