@indemn/cli 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +9 -4
- package/.github/workflows/ci.yml +10 -0
- package/.github/workflows/publish.yml +2 -2
- package/.mcp.json +1 -4
- package/CONTRIBUTING.md +3 -1
- package/README.md +69 -176
- package/dist/cli/agents.d.ts.map +1 -1
- package/dist/cli/agents.js +226 -18
- package/dist/cli/agents.js.map +1 -1
- package/dist/cli/analytics.d.ts +3 -0
- package/dist/cli/analytics.d.ts.map +1 -0
- package/dist/cli/analytics.js +133 -0
- package/dist/cli/analytics.js.map +1 -0
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +62 -17
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/billing.d.ts +3 -0
- package/dist/cli/billing.d.ts.map +1 -0
- package/dist/cli/billing.js +23 -0
- package/dist/cli/billing.js.map +1 -0
- package/dist/cli/campaigns.d.ts +3 -0
- package/dist/cli/campaigns.d.ts.map +1 -0
- package/dist/cli/campaigns.js +34 -0
- package/dist/cli/campaigns.js.map +1 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +69 -102
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +71 -27
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/conversations.d.ts +3 -0
- package/dist/cli/conversations.d.ts.map +1 -0
- package/dist/cli/conversations.js +129 -0
- package/dist/cli/conversations.js.map +1 -0
- package/dist/cli/distribution.d.ts +3 -0
- package/dist/cli/distribution.d.ts.map +1 -0
- package/dist/cli/distribution.js +122 -0
- package/dist/cli/distribution.js.map +1 -0
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +315 -71
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/functions.d.ts.map +1 -1
- package/dist/cli/functions.js +109 -38
- package/dist/cli/functions.js.map +1 -1
- package/dist/cli/index.js +63 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +27 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +152 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/kb.d.ts.map +1 -1
- package/dist/cli/kb.js +141 -15
- package/dist/cli/kb.js.map +1 -1
- package/dist/cli/link.d.ts +21 -0
- package/dist/cli/link.d.ts.map +1 -0
- package/dist/cli/link.js +112 -0
- package/dist/cli/link.js.map +1 -0
- package/dist/cli/org.d.ts.map +1 -1
- package/dist/cli/org.js +79 -67
- package/dist/cli/org.js.map +1 -1
- package/dist/cli/platform.d.ts +3 -0
- package/dist/cli/platform.d.ts.map +1 -0
- package/dist/cli/platform.js +133 -0
- package/dist/cli/platform.js.map +1 -0
- package/dist/cli/tickets.d.ts +3 -0
- package/dist/cli/tickets.d.ts.map +1 -0
- package/dist/cli/tickets.js +125 -0
- package/dist/cli/tickets.js.map +1 -0
- package/dist/cli/utils/output.d.ts +15 -9
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +53 -46
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts +3 -0
- package/dist/cli/voice.d.ts.map +1 -0
- package/dist/cli/voice.js +206 -0
- package/dist/cli/voice.js.map +1 -0
- package/dist/core/diff.d.ts +16 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +57 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/org-resolver.d.ts +37 -0
- package/dist/core/org-resolver.d.ts.map +1 -0
- package/dist/core/org-resolver.js +73 -0
- package/dist/core/org-resolver.js.map +1 -0
- package/dist/core/runtime.d.ts +36 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +62 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/scope.d.ts +33 -0
- package/dist/core/scope.d.ts.map +1 -0
- package/dist/core/scope.js +37 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/mcp/registry.d.ts +67 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +195 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +87 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts +21 -0
- package/dist/mcp/tool-meta.d.ts.map +1 -0
- package/dist/mcp/tool-meta.js +203 -0
- package/dist/mcp/tool-meta.js.map +1 -0
- package/dist/mcp/tools.d.ts +2 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +1462 -215
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/agent-bundle.d.ts +99 -0
- package/dist/sdk/agent-bundle.d.ts.map +1 -0
- package/dist/sdk/agent-bundle.js +211 -0
- package/dist/sdk/agent-bundle.js.map +1 -0
- package/dist/sdk/agents.d.ts +54 -1
- package/dist/sdk/agents.d.ts.map +1 -1
- package/dist/sdk/agents.js +59 -5
- package/dist/sdk/agents.js.map +1 -1
- package/dist/sdk/analytics.d.ts +158 -0
- package/dist/sdk/analytics.d.ts.map +1 -0
- package/dist/sdk/analytics.js +185 -0
- package/dist/sdk/analytics.js.map +1 -0
- package/dist/sdk/auth.d.ts +34 -12
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +62 -79
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/billing.d.ts +25 -0
- package/dist/sdk/billing.d.ts.map +1 -0
- package/dist/sdk/billing.js +61 -0
- package/dist/sdk/billing.js.map +1 -0
- package/dist/sdk/campaigns.d.ts +35 -0
- package/dist/sdk/campaigns.d.ts.map +1 -0
- package/dist/sdk/campaigns.js +59 -0
- package/dist/sdk/campaigns.js.map +1 -0
- package/dist/sdk/chat.d.ts +25 -2
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +37 -10
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +93 -5
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +362 -100
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.d.ts +74 -0
- package/dist/sdk/config-change.d.ts.map +1 -0
- package/dist/sdk/config-change.js +117 -0
- package/dist/sdk/config-change.js.map +1 -0
- package/dist/sdk/config.d.ts.map +1 -1
- package/dist/sdk/config.js +2 -1
- package/dist/sdk/config.js.map +1 -1
- package/dist/sdk/conversations.d.ts +101 -0
- package/dist/sdk/conversations.d.ts.map +1 -0
- package/dist/sdk/conversations.js +155 -0
- package/dist/sdk/conversations.js.map +1 -0
- package/dist/sdk/credentials.d.ts +25 -0
- package/dist/sdk/credentials.d.ts.map +1 -0
- package/dist/sdk/credentials.js +44 -0
- package/dist/sdk/credentials.js.map +1 -0
- package/dist/sdk/distribution.d.ts +94 -0
- package/dist/sdk/distribution.d.ts.map +1 -0
- package/dist/sdk/distribution.js +100 -0
- package/dist/sdk/distribution.js.map +1 -0
- package/dist/sdk/eval.d.ts +81 -0
- package/dist/sdk/eval.d.ts.map +1 -1
- package/dist/sdk/eval.js +117 -0
- package/dist/sdk/eval.js.map +1 -1
- package/dist/sdk/functions.d.ts +14 -1
- package/dist/sdk/functions.d.ts.map +1 -1
- package/dist/sdk/functions.js +48 -5
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/jarvis-chat.d.ts +28 -0
- package/dist/sdk/jarvis-chat.d.ts.map +1 -0
- package/dist/sdk/jarvis-chat.js +117 -0
- package/dist/sdk/jarvis-chat.js.map +1 -0
- package/dist/sdk/kb.d.ts +69 -1
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +111 -24
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts +13 -0
- package/dist/sdk/orgs.d.ts.map +1 -0
- package/dist/sdk/orgs.js +59 -0
- package/dist/sdk/orgs.js.map +1 -0
- package/dist/sdk/platform.d.ts +62 -0
- package/dist/sdk/platform.d.ts.map +1 -0
- package/dist/sdk/platform.js +95 -0
- package/dist/sdk/platform.js.map +1 -0
- package/dist/sdk/session.d.ts +25 -0
- package/dist/sdk/session.d.ts.map +1 -0
- package/dist/sdk/session.js +69 -0
- package/dist/sdk/session.js.map +1 -0
- package/dist/sdk/skills.d.ts +46 -0
- package/dist/sdk/skills.d.ts.map +1 -0
- package/dist/sdk/skills.js +203 -0
- package/dist/sdk/skills.js.map +1 -0
- package/dist/sdk/test-chat.d.ts +52 -0
- package/dist/sdk/test-chat.d.ts.map +1 -0
- package/dist/sdk/test-chat.js +134 -0
- package/dist/sdk/test-chat.js.map +1 -0
- package/dist/sdk/tickets.d.ts +98 -0
- package/dist/sdk/tickets.d.ts.map +1 -0
- package/dist/sdk/tickets.js +91 -0
- package/dist/sdk/tickets.js.map +1 -0
- package/dist/sdk/types.d.ts +91 -6
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/voice.d.ts +147 -0
- package/dist/sdk/voice.d.ts.map +1 -0
- package/dist/sdk/voice.js +236 -0
- package/dist/sdk/voice.js.map +1 -0
- package/package.json +21 -4
- package/scripts/check-skills.mjs +25 -0
- package/skills/agent-builder/SKILL.md +89 -252
- package/skills/evaluations/SKILL.md +23 -234
- package/skills/evaluations/references/analysis.md +95 -0
- package/skills/evaluations/references/cli-reference.md +28 -0
- package/skills/evaluations/references/json-formats.md +100 -0
- package/src/cli/agents.ts +202 -22
- package/src/cli/analytics.ts +138 -0
- package/src/cli/auth.ts +56 -18
- package/src/cli/billing.ts +25 -0
- package/src/cli/campaigns.ts +33 -0
- package/src/cli/chat.ts +66 -115
- package/src/cli/config.ts +65 -31
- package/src/cli/conversations.ts +133 -0
- package/src/cli/distribution.ts +116 -0
- package/src/cli/eval.ts +254 -70
- package/src/cli/functions.ts +105 -39
- package/src/cli/index.ts +58 -55
- package/src/cli/init.ts +154 -0
- package/src/cli/kb.ts +120 -17
- package/src/cli/link.ts +117 -0
- package/src/cli/org.ts +81 -75
- package/src/cli/platform.ts +128 -0
- package/src/cli/tickets.ts +130 -0
- package/src/cli/utils/output.ts +51 -42
- package/src/cli/voice.ts +204 -0
- package/src/core/diff.ts +52 -0
- package/src/core/org-resolver.ts +113 -0
- package/src/core/runtime.ts +73 -0
- package/src/core/scope.ts +64 -0
- package/src/mcp/registry.ts +236 -0
- package/src/mcp/server.ts +84 -2
- package/src/mcp/tool-meta.ts +241 -0
- package/src/mcp/tools.ts +1529 -265
- package/src/sdk/agent-bundle.ts +245 -0
- package/src/sdk/agents.ts +74 -5
- package/src/sdk/analytics.ts +220 -0
- package/src/sdk/auth.ts +89 -109
- package/src/sdk/billing.ts +59 -0
- package/src/sdk/campaigns.ts +60 -0
- package/src/sdk/chat.ts +44 -10
- package/src/sdk/client.ts +373 -102
- package/src/sdk/config-change.ts +148 -0
- package/src/sdk/config.ts +2 -1
- package/src/sdk/conversations.ts +199 -0
- package/src/sdk/credentials.ts +53 -0
- package/src/sdk/distribution.ts +132 -0
- package/src/sdk/eval.ts +140 -0
- package/src/sdk/functions.ts +52 -6
- package/src/sdk/index.ts +2 -0
- package/src/sdk/jarvis-chat.ts +112 -0
- package/src/sdk/kb.ts +116 -29
- package/src/sdk/orgs.ts +62 -0
- package/src/sdk/platform.ts +115 -0
- package/src/sdk/session.ts +90 -0
- package/src/sdk/skills.ts +188 -0
- package/src/sdk/test-chat.ts +151 -0
- package/src/sdk/tickets.ts +120 -0
- package/src/sdk/types.ts +88 -6
- package/src/sdk/voice.ts +262 -0
- package/test/cli/entry.test.ts +34 -0
- package/test/cli/init.test.ts +66 -0
- package/test/cli/link.test.ts +57 -0
- package/test/core/org-resolver.test.ts +92 -0
- package/test/core/runtime.test.ts +30 -0
- package/test/core/scope.test.ts +93 -0
- package/test/harness.test.ts +24 -0
- package/test/helpers/config-home.ts +22 -0
- package/test/helpers/fake-middleware.ts +56 -0
- package/test/helpers/fetch-recorder.ts +75 -0
- package/test/helpers/jwt.ts +9 -0
- package/test/helpers/mcp-pair.ts +38 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
- package/test/mcp/askers.test.ts +60 -0
- package/test/mcp/elicit-timeout.test.ts +29 -0
- package/test/mcp/registry.test.ts +169 -0
- package/test/mcp/shaping.test.ts +41 -0
- package/test/mcp/tools-snapshot.test.ts +42 -0
- package/test/sdk/agents.test.ts +232 -0
- package/test/sdk/analytics.test.ts +89 -0
- package/test/sdk/auth.test.ts +70 -0
- package/test/sdk/billing.test.ts +47 -0
- package/test/sdk/campaigns.test.ts +43 -0
- package/test/sdk/client.test.ts +80 -0
- package/test/sdk/config-change.test.ts +142 -0
- package/test/sdk/conversations.test.ts +80 -0
- package/test/sdk/credentials.test.ts +33 -0
- package/test/sdk/distribution.test.ts +55 -0
- package/test/sdk/eval.test.ts +155 -0
- package/test/sdk/kb-upload.test.ts +50 -0
- package/test/sdk/kb.test.ts +95 -0
- package/test/sdk/orgs.test.ts +48 -0
- package/test/sdk/platform.test.ts +89 -0
- package/test/sdk/renewal.test.ts +115 -0
- package/test/sdk/session.test.ts +60 -0
- package/test/sdk/skills.test.ts +235 -0
- package/test/sdk/test-chat.test.ts +112 -0
- package/test/sdk/tickets.test.ts +42 -0
- package/test/sdk/voice.test.ts +147 -0
- package/vitest.config.ts +11 -0
package/src/sdk/types.ts
CHANGED
|
@@ -6,14 +6,52 @@ export interface EnvironmentHosts {
|
|
|
6
6
|
copilot: string;
|
|
7
7
|
evaluations: string;
|
|
8
8
|
middleware: string;
|
|
9
|
+
/** Darwin, the V2 platform API (Jarvis, V2 agents, role-checked V1 agent changes). */
|
|
10
|
+
platform?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The copilot session the CLI keeps after login. Same token the dashboard uses. */
|
|
14
|
+
export interface StoredSession {
|
|
15
|
+
/** copilot JWT, without the "JWT " prefix. */
|
|
16
|
+
token: string;
|
|
17
|
+
/** ISO time the token stops working, from its `exp` claim. */
|
|
18
|
+
expires_at?: string;
|
|
19
|
+
/** Firebase refresh token: lets the CLI get a new session without a password. */
|
|
20
|
+
refresh_token?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OrgSummary {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The copilot session the CLI keeps after login. Same token the dashboard uses. */
|
|
29
|
+
export interface StoredSession {
|
|
30
|
+
/** copilot JWT, without the "JWT " prefix. */
|
|
31
|
+
token: string;
|
|
32
|
+
/** ISO time the token stops working, from its `exp` claim. */
|
|
33
|
+
expires_at?: string;
|
|
34
|
+
/** Firebase refresh token: lets the CLI get a new session without a password. */
|
|
35
|
+
refresh_token?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OrgSummary {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
9
41
|
}
|
|
10
42
|
|
|
11
43
|
export interface IndemnConfig {
|
|
12
|
-
api_key: string;
|
|
13
|
-
org_id: string;
|
|
14
|
-
org_name?: string;
|
|
15
|
-
user_id: string;
|
|
16
44
|
environment: Environment;
|
|
45
|
+
user_id: string;
|
|
46
|
+
email?: string;
|
|
47
|
+
session?: StoredSession;
|
|
48
|
+
/** Orgs the user belonged to at login (refreshed by `indemn org list`). Never the org a command uses. */
|
|
49
|
+
orgs?: OrgSummary[];
|
|
50
|
+
/** Legacy: saved by logins before token-first auth. Honoured until the next login. */
|
|
51
|
+
api_key?: string;
|
|
52
|
+
/** Legacy default org from before per-action orgs. Ignored. */
|
|
53
|
+
org_id?: string;
|
|
54
|
+
org_name?: string;
|
|
17
55
|
hosts: Record<Environment, EnvironmentHosts>;
|
|
18
56
|
}
|
|
19
57
|
|
|
@@ -116,6 +154,11 @@ export interface FunctionParameter {
|
|
|
116
154
|
type: string;
|
|
117
155
|
description?: string;
|
|
118
156
|
is_required?: boolean;
|
|
157
|
+
/** Fixed choices (copilot calls them enums; shown to the customer as buttons). */
|
|
158
|
+
enums?: Array<{ title?: string; payload?: string; type?: string; url?: string }>;
|
|
159
|
+
isEnumsEnabled?: boolean;
|
|
160
|
+
question_title?: string;
|
|
161
|
+
descriptionOps?: unknown;
|
|
119
162
|
}
|
|
120
163
|
|
|
121
164
|
export interface CreateFunctionInput {
|
|
@@ -232,20 +275,32 @@ export interface CreateRubricInput {
|
|
|
232
275
|
}
|
|
233
276
|
|
|
234
277
|
export interface TestItem {
|
|
235
|
-
|
|
278
|
+
item_id?: string;
|
|
279
|
+
type: 'single_turn' | 'scenario' | 'voice_simulation' | 'transcript';
|
|
236
280
|
name: string;
|
|
281
|
+
/** single_turn: message; scenario: persona, initial_message, max_turns; voice_simulation adds caller fields (goal, phone, scripted_user_responses, tool_execution, ...). */
|
|
237
282
|
inputs: {
|
|
238
283
|
message?: string;
|
|
239
284
|
persona?: string;
|
|
240
285
|
initial_message?: string;
|
|
241
286
|
max_turns?: number;
|
|
287
|
+
[key: string]: unknown;
|
|
242
288
|
};
|
|
243
289
|
expected: {
|
|
244
290
|
success_criteria: string[];
|
|
245
291
|
expected_outcome?: string;
|
|
292
|
+
tool_present?: string[];
|
|
293
|
+
tool_absent?: string[];
|
|
294
|
+
handoff_to?: string;
|
|
295
|
+
assertions?: Array<Record<string, unknown>>;
|
|
296
|
+
knowledge_base?: Record<string, unknown>;
|
|
297
|
+
voice?: Record<string, unknown>;
|
|
298
|
+
[key: string]: unknown;
|
|
246
299
|
};
|
|
247
300
|
tags?: string[];
|
|
248
|
-
priority?:
|
|
301
|
+
priority?: string;
|
|
302
|
+
acceptance_id?: string;
|
|
303
|
+
section?: string;
|
|
249
304
|
}
|
|
250
305
|
|
|
251
306
|
export interface TestSet {
|
|
@@ -279,6 +334,11 @@ export interface EvaluationTriggerInput {
|
|
|
279
334
|
component_ids?: string[];
|
|
280
335
|
conversation_ids?: string[];
|
|
281
336
|
mode?: string;
|
|
337
|
+
/** Transcript mode only: conversations created from this ISO date (inclusive)... */
|
|
338
|
+
date_from?: string;
|
|
339
|
+
/** ...to this ISO date (exclusive). */
|
|
340
|
+
date_to?: string;
|
|
341
|
+
webhook_url?: string;
|
|
282
342
|
}
|
|
283
343
|
|
|
284
344
|
export interface EvaluationTriggerResponse {
|
|
@@ -300,6 +360,10 @@ export interface EvaluationRun {
|
|
|
300
360
|
completed?: number;
|
|
301
361
|
passed?: number;
|
|
302
362
|
failed?: number;
|
|
363
|
+
/** Infrastructure errors, not agent failures. */
|
|
364
|
+
errors?: number;
|
|
365
|
+
not_evaluated?: number;
|
|
366
|
+
latency?: { count?: number; avg_ms?: number; p50_ms?: number; p95_ms?: number; max_ms?: number };
|
|
303
367
|
concurrency?: number;
|
|
304
368
|
started_at?: string;
|
|
305
369
|
completed_at?: string;
|
|
@@ -354,3 +418,21 @@ export interface UserMessage {
|
|
|
354
418
|
session_id: string;
|
|
355
419
|
is_user_message_sent: boolean;
|
|
356
420
|
}
|
|
421
|
+
|
|
422
|
+
// Organization types
|
|
423
|
+
|
|
424
|
+
export interface OrgMember {
|
|
425
|
+
user_id: string;
|
|
426
|
+
email?: string;
|
|
427
|
+
name?: string;
|
|
428
|
+
role?: string;
|
|
429
|
+
status?: string;
|
|
430
|
+
projects: string[];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface Channel {
|
|
434
|
+
_id: string;
|
|
435
|
+
name: string;
|
|
436
|
+
type?: string;
|
|
437
|
+
description?: string;
|
|
438
|
+
}
|
package/src/sdk/voice.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import type { IndemnClient } from './client.js';
|
|
3
|
+
import { AgentsSDK } from './agents.js';
|
|
4
|
+
import { CliError, EXIT } from '../core/runtime.js';
|
|
5
|
+
|
|
6
|
+
export interface VoiceSettingsInput {
|
|
7
|
+
provider: string;
|
|
8
|
+
voice_id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
gender: string;
|
|
11
|
+
language: string;
|
|
12
|
+
countryCode: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
settings?: { speed?: number };
|
|
15
|
+
public_owner_id?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PhoneConfig {
|
|
19
|
+
_id?: string;
|
|
20
|
+
phoneNumber?: string;
|
|
21
|
+
friendlyName?: string;
|
|
22
|
+
modelSettings?: Record<string, unknown>;
|
|
23
|
+
is_active?: boolean;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A number offered for sale (Twilio's record, as voice-service returns it). */
|
|
28
|
+
export interface AvailableNumber {
|
|
29
|
+
phoneNumber?: string;
|
|
30
|
+
friendlyName?: string;
|
|
31
|
+
locality?: string;
|
|
32
|
+
region?: string;
|
|
33
|
+
postalCode?: string;
|
|
34
|
+
isoCountry?: string;
|
|
35
|
+
capabilities?: Record<string, boolean>;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SEARCH_COUNTRIES = ['US', 'CA', 'GB', 'AU'];
|
|
40
|
+
|
|
41
|
+
/** +E.164 from what a person typed: "(331) 204-4919" or "331 204 4919" in the US/Canada, or "+44 20 ...". */
|
|
42
|
+
export function toE164(input: string, country = 'US'): string {
|
|
43
|
+
const digits = input.replace(/\D/g, '');
|
|
44
|
+
if (input.trim().startsWith('+')) return `+${digits}`;
|
|
45
|
+
if ((country === 'US' || country === 'CA') && digits.length === 10) return `+1${digits}`;
|
|
46
|
+
return `+${digits}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A Twilio-shaped call id, as the dashboard's web call sends it. */
|
|
50
|
+
function callSid(): string {
|
|
51
|
+
return 'CA' + randomBytes(16).toString('hex').toUpperCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Voices, voice models, phone numbers and voice previews of agents.
|
|
56
|
+
* Several copilot voice routes do not check that the agent belongs to the
|
|
57
|
+
* caller's org, so every agent-level call here checks that first.
|
|
58
|
+
*/
|
|
59
|
+
export class VoiceSDK {
|
|
60
|
+
/**
|
|
61
|
+
* Numbers this session detached, by org and number, with their saved details. copilot's
|
|
62
|
+
* owned-numbers list can come back empty (its cached org id does not match the query), and a
|
|
63
|
+
* number just detached from an agent of the org is owned: re-attaching it never buys.
|
|
64
|
+
*/
|
|
65
|
+
private detached = new Map<string, PhoneConfig>();
|
|
66
|
+
|
|
67
|
+
constructor(private client: IndemnClient) {}
|
|
68
|
+
|
|
69
|
+
private async assertAgent(agentId: string): Promise<void> {
|
|
70
|
+
const agent = await new AgentsSDK(this.client).getAgent(agentId).catch(() => undefined);
|
|
71
|
+
if (!agent) throw new CliError(`Agent ${agentId} is not in the organization this command acts on.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Voices of a TTS provider (cartesia default, or elevenlabs), filterable. */
|
|
75
|
+
listVoices(opts: { provider?: string; language?: string; gender?: string; q?: string; limit?: number; cursor?: string } = {}): Promise<{ data?: unknown[]; has_more?: boolean; next_page?: unknown }> {
|
|
76
|
+
const q: Record<string, string> = { provider: opts.provider ?? 'cartesia', limit: String(opts.limit ?? 50) };
|
|
77
|
+
if (opts.language) q.language = opts.language;
|
|
78
|
+
if (opts.gender) q.gender = opts.gender;
|
|
79
|
+
if (opts.q) q.q = opts.q;
|
|
80
|
+
if (opts.cursor) q.starting_after = opts.cursor;
|
|
81
|
+
return this.client.copilotGet('/voices', q);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Allowed filter values (languages, genders, accents) of a provider. */
|
|
85
|
+
voiceFilters(provider: string): Promise<unknown> {
|
|
86
|
+
return this.client.copilotGet(`/voices/settings/${provider}`, { service: 'tts' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Speech sample of a voice (audio bytes). The platform only speaks Cartesia voices; for ElevenLabs the
|
|
91
|
+
* voice's own preview recording is used, as in the dashboard (it cannot say custom text).
|
|
92
|
+
*/
|
|
93
|
+
async playVoice(voiceId: string, text?: string, speed?: number, opts: { provider?: string; name?: string; language?: string } = {}): Promise<{ contentType: string; data: Buffer }> {
|
|
94
|
+
if (opts.provider === 'elevenlabs') {
|
|
95
|
+
const found = await this.listVoices({ provider: 'elevenlabs', q: opts.name ?? voiceId, limit: 50 }) as { data?: Array<{ voice_id?: string; preview_url?: string }> };
|
|
96
|
+
const url = found.data?.find((v) => v.voice_id === voiceId)?.preview_url;
|
|
97
|
+
if (!url) throw new CliError(`No preview recording found for ElevenLabs voice ${opts.name ?? voiceId}.`);
|
|
98
|
+
const res = await fetch(url);
|
|
99
|
+
if (!res.ok) throw new CliError(`Could not download the preview of ${opts.name ?? voiceId} (${res.status}).`);
|
|
100
|
+
return { contentType: res.headers.get('content-type') ?? 'audio/mpeg', data: Buffer.from(await res.arrayBuffer()) };
|
|
101
|
+
}
|
|
102
|
+
return this.client.copilotBinary('POST', '/voices/play', { voice_id: voiceId, ...(text ? { text } : {}), ...(speed ? { speed } : {}), ...(opts.language ? { language: opts.language } : {}) });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Speech-to-text or text-to-speech provider and model catalog. */
|
|
106
|
+
voiceModels(kind: 'stt' | 'tts'): Promise<unknown[]> {
|
|
107
|
+
return this.client.copilotGet('/organization/:org_id/ai-studio/voice-models', { kind });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Phone numbers attached to an agent, with their voice settings. */
|
|
111
|
+
async agentNumbers(agentId: string): Promise<PhoneConfig[]> {
|
|
112
|
+
await this.assertAgent(agentId);
|
|
113
|
+
return this.client.copilotGet<PhoneConfig[]>(`/organization/:org_id/ai-studio/bots/${agentId}/configurations/voice/numbers`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The agent's voice settings. Voice settings live on its phone number, so an agent without one has none. */
|
|
117
|
+
async getAgentVoice(agentId: string): Promise<Record<string, unknown>> {
|
|
118
|
+
const numbers = await this.agentNumbers(agentId);
|
|
119
|
+
if (!numbers.length) throw new CliError(`Agent ${agentId} has no phone number, so it has no voice settings yet. Attach one with \`indemn agents numbers attach\`.`, EXIT.NEEDS_INPUT);
|
|
120
|
+
return (numbers[0].modelSettings ?? {}) as Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Set the text-to-speech voice of an agent. voice-service replaces the saved voice settings whole, so a
|
|
125
|
+
* speed that is not given keeps the saved one (the dashboard always sends it).
|
|
126
|
+
*/
|
|
127
|
+
async setAgentVoice(agentId: string, input: VoiceSettingsInput & Record<string, unknown>): Promise<unknown> {
|
|
128
|
+
const current = await this.getAgentVoice(agentId) as { tts?: { settings?: { speed?: number } } }; // fails clearly when there is no number
|
|
129
|
+
const body = { ...input, countryCode: input.countryCode.toLowerCase(), settings: { ...(current.tts?.settings ?? {}), ...(input.settings ?? {}) } };
|
|
130
|
+
return this.client.copilotPost(`/organization/:org_id/ai-studio/bots/${agentId}/voices`, body);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Set the speech-to-text transcriber (provider, model, language, key terms) of an agent, as the dashboard does. */
|
|
134
|
+
async setAgentTranscriber(agentId: string, stt: { provider: string; model: string; language: string; keyterms?: string[] }): Promise<unknown> {
|
|
135
|
+
await this.getAgentVoice(agentId); // fails clearly when there is no number
|
|
136
|
+
const keyterms = (stt.keyterms ?? []).map((k) => k.trim()).filter(Boolean);
|
|
137
|
+
if (keyterms.length > 50 || keyterms.some((k) => k.length > 100)) throw new CliError('Up to 50 key terms of at most 100 characters each.', EXIT.NEEDS_INPUT);
|
|
138
|
+
return this.client.copilotPost(`/organization/:org_id/ai-studio/bots/${agentId}/voices`, {
|
|
139
|
+
stt: { provider: stt.provider, model: stt.model, language: stt.language, config: { keyterm: keyterms } },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Numbers the org owns (is_active: attached to some agent). */
|
|
144
|
+
orgNumbers(): Promise<PhoneConfig[]> {
|
|
145
|
+
return this.client.copilotGet('/organization/:org_id/voice/configurations/numbers');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Attach a number the org already owns. Never buys one: copilot would buy a
|
|
150
|
+
* Twilio number for an unknown number, which costs money, so unknown numbers
|
|
151
|
+
* are refused here. Re-attaching resets the number's voice settings.
|
|
152
|
+
*/
|
|
153
|
+
async attachNumber(agentId: string, phoneNumber: string, type = 'local'): Promise<unknown> {
|
|
154
|
+
const agent = await new AgentsSDK(this.client).getAgent(agentId).catch(() => undefined);
|
|
155
|
+
if (!agent) throw new CliError(`Agent ${agentId} is not in the organization this command acts on.`);
|
|
156
|
+
const channels = ((agent.channels as unknown[] | undefined) ?? []).map((c) => String(typeof c === 'string' ? c : (c as { type?: unknown }).type ?? '').toUpperCase());
|
|
157
|
+
if (!channels.includes('VOICE')) throw new CliError(`"${agent.name}" does not answer the phone (no VOICE channel), so a number would not reach it.`);
|
|
158
|
+
const existing = await this.agentNumbers(agentId);
|
|
159
|
+
if (existing.length) throw new CliError(`"${agent.name}" already has ${existing[0].phoneNumber}. Detach it first; an agent has one number.`);
|
|
160
|
+
const key = `${await this.client.currentOrgId()}:${phoneNumber}`;
|
|
161
|
+
const owned = (await this.orgNumbers()).find((n) => n.phoneNumber === phoneNumber) ?? this.detached.get(key);
|
|
162
|
+
if (!owned) {
|
|
163
|
+
throw new CliError(`${phoneNumber} is not a number this org owns. Buying numbers is not available from the CLI; buy it in the dashboard first.`);
|
|
164
|
+
}
|
|
165
|
+
if (owned.is_active) throw new CliError(`${phoneNumber} is already attached to an agent. Detach it there first.`);
|
|
166
|
+
// copilot overwrites the number's details with what it is sent: send the saved ones back.
|
|
167
|
+
const result = await this.client.copilotPost(`/organization/:org_id/ai-studio/bots/${agentId}/configurations/voice/numbers`, {
|
|
168
|
+
type: typeof owned.type === 'string' ? owned.type : type, phoneNumber,
|
|
169
|
+
...(owned.friendlyName ? { friendlyName: owned.friendlyName } : {}),
|
|
170
|
+
...(owned.additionalDetails ? { payload: owned.additionalDetails } : {}),
|
|
171
|
+
});
|
|
172
|
+
this.detached.delete(key);
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Numbers available to buy, as the dashboard's "Pick a new phone number" lists them (Twilio through
|
|
178
|
+
* voice-service). Free: nothing is bought. `contains` narrows to numbers containing those digits.
|
|
179
|
+
*/
|
|
180
|
+
async searchNumbers(opts: { areaCode?: string; contains?: string; country?: string; tollFree?: boolean; limit?: number } = {}): Promise<AvailableNumber[]> {
|
|
181
|
+
const country = (opts.country ?? 'US').toUpperCase();
|
|
182
|
+
if (!SEARCH_COUNTRIES.includes(country)) throw new CliError(`Numbers can be searched in ${SEARCH_COUNTRIES.join(', ')}.`, EXIT.NEEDS_INPUT);
|
|
183
|
+
const limit = Math.min(Math.max(opts.limit ?? 5, 1), 20);
|
|
184
|
+
const q: Record<string, string> = { countryCode: country, limit: String(limit) };
|
|
185
|
+
if (opts.areaCode) q.areaCode = opts.areaCode.replace(/\D/g, '').slice(0, 3);
|
|
186
|
+
if (opts.contains) q.pattern = opts.contains.replace(/\D/g, '');
|
|
187
|
+
const rows = await this.client.copilotGet<AvailableNumber[]>(`/organization/:org_id/voice/configurations/numbers/${opts.tollFree ? 'toll-free' : 'local'}`, q);
|
|
188
|
+
return (rows ?? []).slice(0, limit);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The agent's phone number with its location and a summary of its voice settings. */
|
|
192
|
+
async numberDetails(agentId: string): Promise<Record<string, unknown>> {
|
|
193
|
+
const [num] = await this.agentNumbers(agentId);
|
|
194
|
+
if (!num) throw new CliError(`Agent ${agentId} has no phone number. Find one with \`indemn agents numbers search\`.`, EXIT.NEEDS_INPUT);
|
|
195
|
+
const d = (num.additionalDetails ?? {}) as AvailableNumber;
|
|
196
|
+
const m = (num.modelSettings ?? {}) as { tts?: { provider?: string; name?: string; settings?: { speed?: number } }; stt?: { provider?: string; model?: string; language?: string } };
|
|
197
|
+
return {
|
|
198
|
+
phone_number: num.phoneNumber, name: num.friendlyName, type: num.type, country: num.isoCountry ?? d.isoCountry,
|
|
199
|
+
locality: d.locality, region: d.region, test_number: Boolean(num.isTestNumber),
|
|
200
|
+
voice: m.tts ? { provider: m.tts.provider, name: m.tts.name, speed: m.tts.settings?.speed } : undefined,
|
|
201
|
+
transcriber: m.stt ? { provider: m.stt.provider, model: m.stt.model, language: m.stt.language } : undefined,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Buy a number and attach it to a voice agent, as the dashboard's "Pick a new phone number" does:
|
|
207
|
+
* copilot buys it from Twilio through voice-service. The org pays for it every month and it cannot be
|
|
208
|
+
* undone from here, so callers must have a person's confirmation of this exact number. The number is
|
|
209
|
+
* looked up again first so the purchase uses Twilio's current record.
|
|
210
|
+
*/
|
|
211
|
+
async buyNumber(agentId: string, number: string, opts: { country?: string; tollFree?: boolean } = {}): Promise<{ phone_number: string; name?: string; locality?: string; region?: string; agent: string }> {
|
|
212
|
+
const country = (opts.country ?? 'US').toUpperCase();
|
|
213
|
+
const phoneNumber = toE164(number, country);
|
|
214
|
+
const agent = await new AgentsSDK(this.client).getAgent(agentId).catch(() => undefined);
|
|
215
|
+
if (!agent) throw new CliError(`Agent ${agentId} is not in the organization this command acts on.`);
|
|
216
|
+
const channels = ((agent.channels as unknown[] | undefined) ?? []).map((c) => String(typeof c === 'string' ? c : (c as { type?: unknown }).type ?? '').toUpperCase());
|
|
217
|
+
if (!channels.includes('VOICE')) throw new CliError(`"${agent.name}" does not answer the phone (no VOICE channel), so a number would not reach it. Nothing was bought.`);
|
|
218
|
+
const existing = await this.agentNumbers(agentId);
|
|
219
|
+
if (existing.length) throw new CliError(`"${agent.name}" already has ${existing[0].phoneNumber}. Nothing was bought.`);
|
|
220
|
+
if ((await this.orgNumbers()).some((o) => o.phoneNumber === phoneNumber)) {
|
|
221
|
+
throw new CliError(`The org already owns ${phoneNumber}; attach it with \`indemn agents numbers attach\` instead of buying it. Nothing was bought.`);
|
|
222
|
+
}
|
|
223
|
+
const digits = phoneNumber.replace(/\D/g, '');
|
|
224
|
+
const offered = await this.searchNumbers({ country, tollFree: opts.tollFree, contains: digits.slice(-7), limit: 20,
|
|
225
|
+
...((country === 'US' || country === 'CA') && !opts.tollFree ? { areaCode: digits.slice(1, 4) } : {}) });
|
|
226
|
+
const pick = offered.find((o) => o.phoneNumber === phoneNumber);
|
|
227
|
+
if (!pick) throw new CliError(`${phoneNumber} is no longer available. Search again with \`indemn agents numbers search\`. Nothing was bought.`);
|
|
228
|
+
await this.client.copilotPost(`/organization/:org_id/ai-studio/bots/${agentId}/configurations/voice/numbers`, {
|
|
229
|
+
type: opts.tollFree ? 'toll-free' : 'local', friendlyName: pick.friendlyName ?? phoneNumber, phoneNumber, payload: pick,
|
|
230
|
+
});
|
|
231
|
+
return { phone_number: phoneNumber, name: pick.friendlyName, locality: pick.locality, region: pick.region, agent: agent.name };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Detach a number from an agent; the org keeps owning it. */
|
|
235
|
+
async detachNumber(agentId: string, phoneNumber: string): Promise<unknown> {
|
|
236
|
+
const attached = await this.agentNumbers(agentId);
|
|
237
|
+
const number = attached.find((n) => n.phoneNumber === phoneNumber);
|
|
238
|
+
if (!number) throw new CliError(`${phoneNumber} is not attached to agent ${agentId}.`);
|
|
239
|
+
const result = await this.client.copilotRequestRaw('DELETE', `/organization/:org_id/ai-studio/bots/${agentId}/configurations/voice/numbers`, { phoneNumber });
|
|
240
|
+
// Kept so it can be attached to another agent (moved), whatever the owned-numbers list says.
|
|
241
|
+
this.detached.set(`${await this.client.currentOrgId()}:${phoneNumber}`, { ...number, is_active: false });
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A test voice call in the browser: a LiveKit room joined with the same
|
|
247
|
+
* attributes as the dashboard's web call, always marked as a test.
|
|
248
|
+
*/
|
|
249
|
+
async voicePreview(agentId: string): Promise<{ server_url: string; room_name: string; token: string; join_url: string }> {
|
|
250
|
+
// The call service finds the agent by its phone number: without one the agent never joins the room.
|
|
251
|
+
const numbers = await this.agentNumbers(agentId);
|
|
252
|
+
if (!numbers.length) throw new CliError(`Agent ${agentId} has no phone number, so it cannot take a test call. Attach one with \`indemn agents numbers attach\`.`, EXIT.NEEDS_INPUT);
|
|
253
|
+
const res = await this.client.copilotPost<{ token: string; server_url: string; room_name: string }>('/livekit/token', {
|
|
254
|
+
bot_id: agentId,
|
|
255
|
+
user_attributes: { 'sip.twilio.callSid': callSid(), isTestMode: 'true', ...(numbers[0].phoneNumber ? { 'sip.trunkPhoneNumber': numbers[0].phoneNumber } : {}) },
|
|
256
|
+
});
|
|
257
|
+
const join = new URL('https://meet.livekit.io/custom');
|
|
258
|
+
join.searchParams.set('liveKitUrl', res.server_url);
|
|
259
|
+
join.searchParams.set('token', res.token);
|
|
260
|
+
return { ...res, join_url: join.toString() };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const root = new URL('../..', import.meta.url).pathname;
|
|
8
|
+
|
|
9
|
+
function cli(...args: string[]) {
|
|
10
|
+
const env = { ...process.env, HOME: mkdtempSync(join(tmpdir(), 'indemn-entry-')), NODE_NO_WARNINGS: '1' };
|
|
11
|
+
delete env.CLAUDECODE;
|
|
12
|
+
return spawnSync(process.execPath, ['--import', 'tsx', join(root, 'src/cli/index.ts'), ...args], { cwd: root, env, encoding: 'utf8', timeout: 60_000 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('CLI entry point', () => {
|
|
16
|
+
it('starts and lists every command group', () => {
|
|
17
|
+
const res = cli('--help');
|
|
18
|
+
expect(res.stderr).toBe('');
|
|
19
|
+
expect(res.status).toBe(0);
|
|
20
|
+
for (const group of ['login', 'agents', 'skills', 'kb', 'config', 'chat', 'conversations', 'org', 'link', 'channels', 'voices', 'distributions', 'templates', 'analytics', 'audit', 'tickets', 'billing', 'campaigns', 'platform', 'jarvis', 'eval', 'init', 'mcp']) {
|
|
21
|
+
expect(res.stdout, group).toMatch(new RegExp(`\\n ${group}\\b`));
|
|
22
|
+
}
|
|
23
|
+
}, 60_000);
|
|
24
|
+
|
|
25
|
+
it('prints the package version', () => {
|
|
26
|
+
const res = cli('--version');
|
|
27
|
+
expect(res.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
28
|
+
}, 60_000);
|
|
29
|
+
|
|
30
|
+
it('has org config and agent voice subcommands attached to their groups', () => {
|
|
31
|
+
expect(cli('org', '--help').stdout).toMatch(/\n config\b/);
|
|
32
|
+
expect(cli('agents', '--help').stdout).toMatch(/\n voice\b/);
|
|
33
|
+
}, 60_000);
|
|
34
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { initProject, permissionBlock, MCP_SERVER_ENTRY } from '../../src/cli/init.js';
|
|
6
|
+
import { TOOL_META } from '../../src/mcp/tool-meta.js';
|
|
7
|
+
|
|
8
|
+
const packageRoot = new URL('../..', import.meta.url).pathname;
|
|
9
|
+
|
|
10
|
+
describe('indemn init', () => {
|
|
11
|
+
let dir: string;
|
|
12
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'indemn-init-')); });
|
|
13
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
14
|
+
|
|
15
|
+
it('installs skills and rules, adds the MCP server and a permission block', () => {
|
|
16
|
+
const r = initProject(dir, { packageRoot });
|
|
17
|
+
expect(r).toMatchObject({ mcp: 'added', permissions: 'added' });
|
|
18
|
+
expect(existsSync(join(dir, '.claude', 'skills', 'agent-builder', 'SKILL.md'))).toBe(true);
|
|
19
|
+
expect(existsSync(join(dir, '.claude', 'skills', 'evaluations', 'SKILL.md'))).toBe(true);
|
|
20
|
+
expect(readFileSync(join(dir, '.claude', 'rules', 'indemn.md'), 'utf8')).toContain('test conversation');
|
|
21
|
+
expect(JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf8'))).toEqual({ mcpServers: { indemn: MCP_SERVER_ENTRY } });
|
|
22
|
+
const perms = JSON.parse(readFileSync(join(dir, '.claude', 'settings.json'), 'utf8')).permissions;
|
|
23
|
+
expect(perms.allow).toContain('mcp__indemn__list_agents');
|
|
24
|
+
expect(perms.ask).toContain('mcp__indemn__delete_agent');
|
|
25
|
+
expect(perms.ask).toContain('mcp__indemn__run_evaluation');
|
|
26
|
+
expect(perms.allow).not.toContain('mcp__indemn__delete_agent');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('merges into existing files and never loosens a rule the user set', () => {
|
|
30
|
+
writeFileSync(join(dir, '.mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'x' } } }));
|
|
31
|
+
mkdirSync(join(dir, '.claude'));
|
|
32
|
+
writeFileSync(join(dir, '.claude', 'settings.json'), JSON.stringify({ model: 'opus', permissions: { deny: ['mcp__indemn__list_agents'], allow: ['Bash(ls)'] } }));
|
|
33
|
+
const r = initProject(dir, { packageRoot });
|
|
34
|
+
expect(r.permissions).toBe('merged');
|
|
35
|
+
expect(Object.keys(JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf8')).mcpServers)).toEqual(['other', 'indemn']);
|
|
36
|
+
const settings = JSON.parse(readFileSync(join(dir, '.claude', 'settings.json'), 'utf8'));
|
|
37
|
+
expect(settings.model).toBe('opus');
|
|
38
|
+
expect(settings.permissions.deny).toEqual(['mcp__indemn__list_agents']);
|
|
39
|
+
expect(settings.permissions.allow).toContain('Bash(ls)');
|
|
40
|
+
expect(settings.permissions.allow).not.toContain('mcp__indemn__list_agents');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('classifies every tool: guarded or destructive tools ask', () => {
|
|
44
|
+
const { allow, ask } = permissionBlock();
|
|
45
|
+
expect(allow.length + ask.length).toBe(Object.keys(TOOL_META).length);
|
|
46
|
+
for (const [name, meta] of Object.entries(TOOL_META)) {
|
|
47
|
+
if (meta.guard || meta.annotations.destructiveHint) expect(ask, name).toContain(`mcp__indemn__${name}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('versions and skills', () => {
|
|
53
|
+
it('uses one version everywhere', () => {
|
|
54
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
55
|
+
expect(JSON.parse(readFileSync(join(packageRoot, '.claude-plugin', 'plugin.json'), 'utf8')).version).toBe(pkg);
|
|
56
|
+
const market = JSON.parse(readFileSync(join(packageRoot, '.claude-plugin', 'marketplace.json'), 'utf8'));
|
|
57
|
+
expect(market.version).toBe(pkg);
|
|
58
|
+
expect(market.plugins.every((p: { version: string }) => p.version === pkg)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps every SKILL.md under 500 lines', () => {
|
|
62
|
+
for (const skill of ['agent-builder', 'evaluations']) {
|
|
63
|
+
expect(readFileSync(join(packageRoot, 'skills', skill, 'SKILL.md'), 'utf8').split('\n').length, skill).toBeLessThanOrEqual(500);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, symlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
5
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
6
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
7
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
8
|
+
import { confirmOrExit, writeLink } from '../../src/cli/link.js';
|
|
9
|
+
|
|
10
|
+
const A = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
11
|
+
const MINE = '111111111111111111111111';
|
|
12
|
+
const FOREIGN = '999999999999999999999999';
|
|
13
|
+
|
|
14
|
+
describe('indemn link', () => {
|
|
15
|
+
let home: ReturnType<typeof useTempHome>;
|
|
16
|
+
let project: string;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
cleanEnv();
|
|
19
|
+
home = useTempHome();
|
|
20
|
+
project = join(home.dir, 'project');
|
|
21
|
+
mkdirSync(project);
|
|
22
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: A, name: 'QA A' }] });
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => home.cleanup());
|
|
25
|
+
|
|
26
|
+
it('writes the link only after every agent is found in the org', async () => {
|
|
27
|
+
recordFetch([on('GET', `/bots/${MINE}`, { json: { success: true, data: [{ _id: MINE }] } })]);
|
|
28
|
+
const result = await writeLink({ dir: project, orgFlag: A, agents: [MINE] });
|
|
29
|
+
const saved = JSON.parse(readFileSync(result.path, 'utf8'));
|
|
30
|
+
expect(saved).toMatchObject({ org_id: A, org_name: 'QA A', environment: 'dev', agents: [MINE] });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('writes nothing when one agent is not in the org', async () => {
|
|
34
|
+
recordFetch([
|
|
35
|
+
on('GET', `/bots/${MINE}`, { json: { success: true, data: [{ _id: MINE }] } }),
|
|
36
|
+
on('GET', `/bots/${FOREIGN}`, { status: 404, text: 'not found' }),
|
|
37
|
+
]);
|
|
38
|
+
await expect(writeLink({ dir: project, orgFlag: A, agents: [MINE, FOREIGN] })).rejects.toThrow(/Agent 9+ is not reachable.*Nothing was linked/);
|
|
39
|
+
expect(existsSync(join(project, '.indemn', 'project.json'))).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('refuses to link the home folder, also when reached through a symlink', async () => {
|
|
43
|
+
recordFetch();
|
|
44
|
+
await expect(writeLink({ dir: home.dir, orgFlag: A, agents: [] })).rejects.toThrow(/home folder/);
|
|
45
|
+
const alias = join(home.dir, 'project', 'alias-to-home');
|
|
46
|
+
symlinkSync(home.dir, alias);
|
|
47
|
+
await expect(writeLink({ dir: alias, orgFlag: A, agents: [] })).rejects.toThrow(/home folder/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('org switch without --yes in a non-interactive shell exits 2', async () => {
|
|
51
|
+
vi.stubEnv('CLAUDECODE', '1');
|
|
52
|
+
const exit = vi.spyOn(process, 'exit').mockImplementation(((code: number) => { throw new Error(`exit ${code}`); }) as never);
|
|
53
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
54
|
+
await expect(confirmOrExit('Pin this folder?', false)).rejects.toThrow('exit 2');
|
|
55
|
+
expect(exit).toHaveBeenCalledWith(2);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { findLinkFile, resolveOrg, type LinkFile, type OrgChoice } from '../../src/core/org-resolver.js';
|
|
6
|
+
import { CliError, NeedsInputError } from '../../src/core/runtime.js';
|
|
7
|
+
|
|
8
|
+
const A: OrgChoice = { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Org A' };
|
|
9
|
+
const B: OrgChoice = { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', name: 'Org B' };
|
|
10
|
+
const link = (over: Partial<LinkFile> = {}): LinkFile => ({ org_id: A.id, environment: 'dev', agents: [], path: '/p/.indemn/project.json', ...over });
|
|
11
|
+
|
|
12
|
+
describe('resolveOrg precedence', () => {
|
|
13
|
+
const base = { environment: 'dev' as const, link: null };
|
|
14
|
+
|
|
15
|
+
it.each([
|
|
16
|
+
['flag', { flag: B.id }, B.id],
|
|
17
|
+
['env', { envOrg: B.id }, B.id],
|
|
18
|
+
])('%s decides without looking up memberships', async (source, extra, id) => {
|
|
19
|
+
const memberships = vi.fn();
|
|
20
|
+
await expect(resolveOrg({ ...base, ...extra, memberships })).resolves.toMatchObject({ id, source });
|
|
21
|
+
expect(memberships).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('a linked folder decides without looking up memberships', async () => {
|
|
25
|
+
const memberships = vi.fn();
|
|
26
|
+
await expect(resolveOrg({ ...base, link: link(), memberships })).resolves.toMatchObject({ id: A.id, source: 'link' });
|
|
27
|
+
expect(memberships).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uses the only membership without asking', async () => {
|
|
31
|
+
const ask = vi.fn();
|
|
32
|
+
await expect(resolveOrg({ ...base, memberships: async () => [A], ask })).resolves.toMatchObject({ id: A.id, source: 'single' });
|
|
33
|
+
expect(ask).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('asks when there are several', async () => {
|
|
37
|
+
const ask = vi.fn(async () => B.id);
|
|
38
|
+
await expect(resolveOrg({ ...base, memberships: async () => [A, B], ask })).resolves.toMatchObject({ id: B.id, source: 'asked' });
|
|
39
|
+
expect(ask).toHaveBeenCalledWith([A, B]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('needs input when there are several and nobody can be asked', async () => {
|
|
43
|
+
const err = await resolveOrg({ ...base, memberships: async () => [A, B] }).catch((e) => e);
|
|
44
|
+
expect(err).toBeInstanceOf(NeedsInputError);
|
|
45
|
+
expect(err.choices).toEqual([{ id: A.id, label: 'Org A' }, { id: B.id, label: 'Org B' }]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('fails clearly with no membership', async () => {
|
|
49
|
+
await expect(resolveOrg({ ...base, memberships: async () => [] })).rejects.toThrow(/not a member of any organization/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('refuses a flag that contradicts the linked folder, naming the file', async () => {
|
|
53
|
+
await expect(resolveOrg({ ...base, flag: B.id, link: link(), memberships: vi.fn() })).rejects.toThrow('/p/.indemn/project.json');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('refuses a link made on the other platform', async () => {
|
|
57
|
+
const err = await resolveOrg({ ...base, link: link({ environment: 'production' }), memberships: vi.fn() }).catch((e) => e);
|
|
58
|
+
expect(err).toBeInstanceOf(CliError);
|
|
59
|
+
expect(err.message).toMatch(/linked to production/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('findLinkFile', () => {
|
|
64
|
+
const dirs: string[] = [];
|
|
65
|
+
afterEach(() => dirs.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })));
|
|
66
|
+
|
|
67
|
+
function project(content: string): string {
|
|
68
|
+
const root = mkdtempSync(join(tmpdir(), 'indemn-link-'));
|
|
69
|
+
dirs.push(root);
|
|
70
|
+
mkdirSync(join(root, '.indemn'));
|
|
71
|
+
writeFileSync(join(root, '.indemn', 'project.json'), content);
|
|
72
|
+
const nested = join(root, 'a', 'b');
|
|
73
|
+
mkdirSync(nested, { recursive: true });
|
|
74
|
+
return nested;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('finds the file two levels up', () => {
|
|
78
|
+
const nested = project(JSON.stringify({ org_id: A.id, environment: 'dev', agents: ['x'] }));
|
|
79
|
+
expect(findLinkFile(nested)).toMatchObject({ org_id: A.id, environment: 'dev', agents: ['x'] });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('names a malformed file', () => {
|
|
83
|
+
const nested = project('{nope');
|
|
84
|
+
expect(() => findLinkFile(nested)).toThrow(/Cannot read .*project\.json/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns null when nothing is linked', () => {
|
|
88
|
+
const root = mkdtempSync(join(tmpdir(), 'indemn-nolink-'));
|
|
89
|
+
dirs.push(root);
|
|
90
|
+
expect(findLinkFile(root)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|