@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +2 -2
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +10 -6
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +8 -2
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +15 -5
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/utils/output.d.ts +0 -1
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +0 -4
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts.map +1 -1
- package/dist/cli/voice.js +4 -1
- package/dist/cli/voice.js.map +1 -1
- package/dist/core/org-resolver.d.ts +4 -0
- package/dist/core/org-resolver.d.ts.map +1 -1
- package/dist/core/org-resolver.js +11 -0
- package/dist/core/org-resolver.js.map +1 -1
- package/dist/core/runtime.d.ts +9 -0
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +49 -0
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/scope.d.ts +0 -1
- package/dist/core/scope.d.ts.map +1 -1
- package/dist/core/scope.js.map +1 -1
- package/dist/mcp/registry.d.ts.map +1 -1
- package/dist/mcp/registry.js +15 -2
- package/dist/mcp/registry.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +2 -19
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.js +5 -5
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.js +1 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/report/export.d.ts.map +1 -1
- package/dist/report/export.js +2 -3
- package/dist/report/export.js.map +1 -1
- package/dist/report/generate.d.ts.map +1 -1
- package/dist/report/generate.js +3 -3
- package/dist/report/generate.js.map +1 -1
- package/dist/sdk/auth.d.ts +8 -3
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +13 -5
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +1 -0
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +11 -9
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +50 -67
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.js +1 -1
- package/dist/sdk/config-change.js.map +1 -1
- package/dist/sdk/conversations.js +1 -1
- package/dist/sdk/conversations.js.map +1 -1
- package/dist/sdk/functions.js +2 -2
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/jarvis-chat.js +2 -2
- package/dist/sdk/jarvis-chat.js.map +1 -1
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +3 -2
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts.map +1 -1
- package/dist/sdk/orgs.js +1 -6
- package/dist/sdk/orgs.js.map +1 -1
- package/dist/sdk/session.d.ts +2 -9
- package/dist/sdk/session.d.ts.map +1 -1
- package/dist/sdk/session.js +2 -27
- package/dist/sdk/session.js.map +1 -1
- package/dist/sdk/test-chat.d.ts.map +1 -1
- package/dist/sdk/test-chat.js +17 -6
- package/dist/sdk/test-chat.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/auth.ts +9 -6
- package/src/cli/eval.ts +6 -2
- package/src/cli/index.ts +2 -2
- package/src/cli/init.ts +14 -6
- package/src/cli/utils/output.ts +0 -4
- package/src/cli/voice.ts +3 -1
- package/src/core/org-resolver.ts +18 -0
- package/src/core/runtime.ts +46 -0
- package/src/core/scope.ts +0 -1
- package/src/mcp/registry.ts +17 -2
- package/src/mcp/server.ts +2 -19
- package/src/mcp/tool-meta.ts +5 -5
- package/src/mcp/tools.ts +1 -1
- package/src/report/export.ts +2 -1
- package/src/report/generate.tsx +3 -2
- package/src/sdk/auth.ts +15 -5
- package/src/sdk/chat.ts +1 -0
- package/src/sdk/client.ts +50 -72
- package/src/sdk/config-change.ts +1 -1
- package/src/sdk/conversations.ts +1 -1
- package/src/sdk/functions.ts +2 -2
- package/src/sdk/jarvis-chat.ts +2 -2
- package/src/sdk/kb.ts +3 -2
- package/src/sdk/orgs.ts +1 -4
- package/src/sdk/session.ts +2 -25
- package/src/sdk/test-chat.ts +16 -6
- package/test/cli/init.test.ts +14 -0
- package/test/core/org-resolver.test.ts +38 -2
- package/test/core/scope.test.ts +12 -0
- package/test/helpers/mcp-pair.ts +10 -1
- package/test/mcp/darwin-fixes.test.ts +18 -17
- package/test/mcp/registry.test.ts +11 -0
- package/test/mcp/shaping.test.ts +18 -0
- package/test/mcp/tools-snapshot.test.ts +4 -0
- package/test/sdk/auth.test.ts +11 -2
- package/test/sdk/client.test.ts +13 -0
- package/test/sdk/config-change.test.ts +1 -1
- package/test/sdk/kb-upload.test.ts +21 -0
- package/test/sdk/orgs.test.ts +18 -0
- package/test/sdk/renewal.test.ts +43 -64
- package/test/sdk/session.test.ts +1 -23
- package/test/sdk/skills.test.ts +20 -18
- package/test/sdk/test-chat.test.ts +29 -0
package/package.json
CHANGED
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()};
|
|
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()} (
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
85
|
-
maxP95Ms
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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).'));
|
package/src/cli/utils/output.ts
CHANGED
|
@@ -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) });
|
package/src/core/org-resolver.ts
CHANGED
|
@@ -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
|
|
package/src/core/runtime.ts
CHANGED
|
@@ -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
|
}
|
package/src/mcp/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
package/src/mcp/tool-meta.ts
CHANGED
|
@@ -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 ?? '
|
|
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))),
|
package/src/report/export.ts
CHANGED
|
@@ -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
|
-
|
|
49
|
+
writeReport(resolvedPath, markdown, '.md', Boolean(outputPath));
|
|
49
50
|
|
|
50
51
|
return {
|
|
51
52
|
file_path: resolvedPath,
|
package/src/report/generate.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
*
|
|
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
|
|
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
|
|
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) => {
|