@indemn/cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +9 -4
  3. package/.github/workflows/ci.yml +10 -0
  4. package/.github/workflows/publish.yml +2 -2
  5. package/.mcp.json +1 -4
  6. package/CONTRIBUTING.md +3 -1
  7. package/README.md +69 -176
  8. package/dist/cli/agents.d.ts.map +1 -1
  9. package/dist/cli/agents.js +226 -18
  10. package/dist/cli/agents.js.map +1 -1
  11. package/dist/cli/analytics.d.ts +3 -0
  12. package/dist/cli/analytics.d.ts.map +1 -0
  13. package/dist/cli/analytics.js +133 -0
  14. package/dist/cli/analytics.js.map +1 -0
  15. package/dist/cli/auth.d.ts.map +1 -1
  16. package/dist/cli/auth.js +62 -17
  17. package/dist/cli/auth.js.map +1 -1
  18. package/dist/cli/billing.d.ts +3 -0
  19. package/dist/cli/billing.d.ts.map +1 -0
  20. package/dist/cli/billing.js +23 -0
  21. package/dist/cli/billing.js.map +1 -0
  22. package/dist/cli/campaigns.d.ts +3 -0
  23. package/dist/cli/campaigns.d.ts.map +1 -0
  24. package/dist/cli/campaigns.js +34 -0
  25. package/dist/cli/campaigns.js.map +1 -0
  26. package/dist/cli/chat.d.ts.map +1 -1
  27. package/dist/cli/chat.js +69 -102
  28. package/dist/cli/chat.js.map +1 -1
  29. package/dist/cli/config.d.ts.map +1 -1
  30. package/dist/cli/config.js +71 -27
  31. package/dist/cli/config.js.map +1 -1
  32. package/dist/cli/conversations.d.ts +3 -0
  33. package/dist/cli/conversations.d.ts.map +1 -0
  34. package/dist/cli/conversations.js +129 -0
  35. package/dist/cli/conversations.js.map +1 -0
  36. package/dist/cli/distribution.d.ts +3 -0
  37. package/dist/cli/distribution.d.ts.map +1 -0
  38. package/dist/cli/distribution.js +122 -0
  39. package/dist/cli/distribution.js.map +1 -0
  40. package/dist/cli/eval.d.ts.map +1 -1
  41. package/dist/cli/eval.js +315 -71
  42. package/dist/cli/eval.js.map +1 -1
  43. package/dist/cli/functions.d.ts.map +1 -1
  44. package/dist/cli/functions.js +109 -38
  45. package/dist/cli/functions.js.map +1 -1
  46. package/dist/cli/index.js +63 -54
  47. package/dist/cli/index.js.map +1 -1
  48. package/dist/cli/init.d.ts +27 -0
  49. package/dist/cli/init.d.ts.map +1 -0
  50. package/dist/cli/init.js +152 -0
  51. package/dist/cli/init.js.map +1 -0
  52. package/dist/cli/kb.d.ts.map +1 -1
  53. package/dist/cli/kb.js +141 -15
  54. package/dist/cli/kb.js.map +1 -1
  55. package/dist/cli/link.d.ts +21 -0
  56. package/dist/cli/link.d.ts.map +1 -0
  57. package/dist/cli/link.js +112 -0
  58. package/dist/cli/link.js.map +1 -0
  59. package/dist/cli/org.d.ts.map +1 -1
  60. package/dist/cli/org.js +79 -67
  61. package/dist/cli/org.js.map +1 -1
  62. package/dist/cli/platform.d.ts +3 -0
  63. package/dist/cli/platform.d.ts.map +1 -0
  64. package/dist/cli/platform.js +133 -0
  65. package/dist/cli/platform.js.map +1 -0
  66. package/dist/cli/tickets.d.ts +3 -0
  67. package/dist/cli/tickets.d.ts.map +1 -0
  68. package/dist/cli/tickets.js +125 -0
  69. package/dist/cli/tickets.js.map +1 -0
  70. package/dist/cli/utils/output.d.ts +15 -9
  71. package/dist/cli/utils/output.d.ts.map +1 -1
  72. package/dist/cli/utils/output.js +53 -46
  73. package/dist/cli/utils/output.js.map +1 -1
  74. package/dist/cli/voice.d.ts +3 -0
  75. package/dist/cli/voice.d.ts.map +1 -0
  76. package/dist/cli/voice.js +206 -0
  77. package/dist/cli/voice.js.map +1 -0
  78. package/dist/core/diff.d.ts +16 -0
  79. package/dist/core/diff.d.ts.map +1 -0
  80. package/dist/core/diff.js +57 -0
  81. package/dist/core/diff.js.map +1 -0
  82. package/dist/core/org-resolver.d.ts +37 -0
  83. package/dist/core/org-resolver.d.ts.map +1 -0
  84. package/dist/core/org-resolver.js +73 -0
  85. package/dist/core/org-resolver.js.map +1 -0
  86. package/dist/core/runtime.d.ts +36 -0
  87. package/dist/core/runtime.d.ts.map +1 -0
  88. package/dist/core/runtime.js +62 -0
  89. package/dist/core/runtime.js.map +1 -0
  90. package/dist/core/scope.d.ts +33 -0
  91. package/dist/core/scope.d.ts.map +1 -0
  92. package/dist/core/scope.js +37 -0
  93. package/dist/core/scope.js.map +1 -0
  94. package/dist/mcp/registry.d.ts +67 -0
  95. package/dist/mcp/registry.d.ts.map +1 -0
  96. package/dist/mcp/registry.js +195 -0
  97. package/dist/mcp/registry.js.map +1 -0
  98. package/dist/mcp/server.d.ts +9 -0
  99. package/dist/mcp/server.d.ts.map +1 -1
  100. package/dist/mcp/server.js +87 -2
  101. package/dist/mcp/server.js.map +1 -1
  102. package/dist/mcp/tool-meta.d.ts +21 -0
  103. package/dist/mcp/tool-meta.d.ts.map +1 -0
  104. package/dist/mcp/tool-meta.js +203 -0
  105. package/dist/mcp/tool-meta.js.map +1 -0
  106. package/dist/mcp/tools.d.ts +2 -1
  107. package/dist/mcp/tools.d.ts.map +1 -1
  108. package/dist/mcp/tools.js +1462 -215
  109. package/dist/mcp/tools.js.map +1 -1
  110. package/dist/sdk/agent-bundle.d.ts +99 -0
  111. package/dist/sdk/agent-bundle.d.ts.map +1 -0
  112. package/dist/sdk/agent-bundle.js +211 -0
  113. package/dist/sdk/agent-bundle.js.map +1 -0
  114. package/dist/sdk/agents.d.ts +54 -1
  115. package/dist/sdk/agents.d.ts.map +1 -1
  116. package/dist/sdk/agents.js +59 -5
  117. package/dist/sdk/agents.js.map +1 -1
  118. package/dist/sdk/analytics.d.ts +158 -0
  119. package/dist/sdk/analytics.d.ts.map +1 -0
  120. package/dist/sdk/analytics.js +185 -0
  121. package/dist/sdk/analytics.js.map +1 -0
  122. package/dist/sdk/auth.d.ts +34 -12
  123. package/dist/sdk/auth.d.ts.map +1 -1
  124. package/dist/sdk/auth.js +60 -77
  125. package/dist/sdk/auth.js.map +1 -1
  126. package/dist/sdk/billing.d.ts +25 -0
  127. package/dist/sdk/billing.d.ts.map +1 -0
  128. package/dist/sdk/billing.js +61 -0
  129. package/dist/sdk/billing.js.map +1 -0
  130. package/dist/sdk/campaigns.d.ts +35 -0
  131. package/dist/sdk/campaigns.d.ts.map +1 -0
  132. package/dist/sdk/campaigns.js +59 -0
  133. package/dist/sdk/campaigns.js.map +1 -0
  134. package/dist/sdk/chat.d.ts +25 -2
  135. package/dist/sdk/chat.d.ts.map +1 -1
  136. package/dist/sdk/chat.js +37 -10
  137. package/dist/sdk/chat.js.map +1 -1
  138. package/dist/sdk/client.d.ts +93 -5
  139. package/dist/sdk/client.d.ts.map +1 -1
  140. package/dist/sdk/client.js +362 -100
  141. package/dist/sdk/client.js.map +1 -1
  142. package/dist/sdk/config-change.d.ts +74 -0
  143. package/dist/sdk/config-change.d.ts.map +1 -0
  144. package/dist/sdk/config-change.js +117 -0
  145. package/dist/sdk/config-change.js.map +1 -0
  146. package/dist/sdk/config.d.ts.map +1 -1
  147. package/dist/sdk/config.js +2 -1
  148. package/dist/sdk/config.js.map +1 -1
  149. package/dist/sdk/conversations.d.ts +101 -0
  150. package/dist/sdk/conversations.d.ts.map +1 -0
  151. package/dist/sdk/conversations.js +155 -0
  152. package/dist/sdk/conversations.js.map +1 -0
  153. package/dist/sdk/credentials.d.ts +25 -0
  154. package/dist/sdk/credentials.d.ts.map +1 -0
  155. package/dist/sdk/credentials.js +44 -0
  156. package/dist/sdk/credentials.js.map +1 -0
  157. package/dist/sdk/distribution.d.ts +94 -0
  158. package/dist/sdk/distribution.d.ts.map +1 -0
  159. package/dist/sdk/distribution.js +100 -0
  160. package/dist/sdk/distribution.js.map +1 -0
  161. package/dist/sdk/eval.d.ts +81 -0
  162. package/dist/sdk/eval.d.ts.map +1 -1
  163. package/dist/sdk/eval.js +117 -0
  164. package/dist/sdk/eval.js.map +1 -1
  165. package/dist/sdk/functions.d.ts +14 -1
  166. package/dist/sdk/functions.d.ts.map +1 -1
  167. package/dist/sdk/functions.js +48 -5
  168. package/dist/sdk/functions.js.map +1 -1
  169. package/dist/sdk/index.d.ts +2 -0
  170. package/dist/sdk/index.d.ts.map +1 -1
  171. package/dist/sdk/index.js +2 -0
  172. package/dist/sdk/index.js.map +1 -1
  173. package/dist/sdk/jarvis-chat.d.ts +28 -0
  174. package/dist/sdk/jarvis-chat.d.ts.map +1 -0
  175. package/dist/sdk/jarvis-chat.js +117 -0
  176. package/dist/sdk/jarvis-chat.js.map +1 -0
  177. package/dist/sdk/kb.d.ts +69 -1
  178. package/dist/sdk/kb.d.ts.map +1 -1
  179. package/dist/sdk/kb.js +111 -24
  180. package/dist/sdk/kb.js.map +1 -1
  181. package/dist/sdk/orgs.d.ts +13 -0
  182. package/dist/sdk/orgs.d.ts.map +1 -0
  183. package/dist/sdk/orgs.js +59 -0
  184. package/dist/sdk/orgs.js.map +1 -0
  185. package/dist/sdk/platform.d.ts +62 -0
  186. package/dist/sdk/platform.d.ts.map +1 -0
  187. package/dist/sdk/platform.js +95 -0
  188. package/dist/sdk/platform.js.map +1 -0
  189. package/dist/sdk/session.d.ts +25 -0
  190. package/dist/sdk/session.d.ts.map +1 -0
  191. package/dist/sdk/session.js +69 -0
  192. package/dist/sdk/session.js.map +1 -0
  193. package/dist/sdk/skills.d.ts +46 -0
  194. package/dist/sdk/skills.d.ts.map +1 -0
  195. package/dist/sdk/skills.js +203 -0
  196. package/dist/sdk/skills.js.map +1 -0
  197. package/dist/sdk/test-chat.d.ts +52 -0
  198. package/dist/sdk/test-chat.d.ts.map +1 -0
  199. package/dist/sdk/test-chat.js +134 -0
  200. package/dist/sdk/test-chat.js.map +1 -0
  201. package/dist/sdk/tickets.d.ts +98 -0
  202. package/dist/sdk/tickets.d.ts.map +1 -0
  203. package/dist/sdk/tickets.js +91 -0
  204. package/dist/sdk/tickets.js.map +1 -0
  205. package/dist/sdk/types.d.ts +91 -6
  206. package/dist/sdk/types.d.ts.map +1 -1
  207. package/dist/sdk/voice.d.ts +147 -0
  208. package/dist/sdk/voice.d.ts.map +1 -0
  209. package/dist/sdk/voice.js +236 -0
  210. package/dist/sdk/voice.js.map +1 -0
  211. package/package.json +22 -3
  212. package/scripts/check-skills.mjs +25 -0
  213. package/skills/agent-builder/SKILL.md +89 -252
  214. package/skills/evaluations/SKILL.md +23 -234
  215. package/skills/evaluations/references/analysis.md +95 -0
  216. package/skills/evaluations/references/cli-reference.md +28 -0
  217. package/skills/evaluations/references/json-formats.md +100 -0
  218. package/src/cli/agents.ts +202 -22
  219. package/src/cli/analytics.ts +138 -0
  220. package/src/cli/auth.ts +56 -18
  221. package/src/cli/billing.ts +25 -0
  222. package/src/cli/campaigns.ts +33 -0
  223. package/src/cli/chat.ts +66 -115
  224. package/src/cli/config.ts +65 -31
  225. package/src/cli/conversations.ts +133 -0
  226. package/src/cli/distribution.ts +116 -0
  227. package/src/cli/eval.ts +254 -70
  228. package/src/cli/functions.ts +105 -39
  229. package/src/cli/index.ts +58 -55
  230. package/src/cli/init.ts +154 -0
  231. package/src/cli/kb.ts +120 -17
  232. package/src/cli/link.ts +117 -0
  233. package/src/cli/org.ts +81 -75
  234. package/src/cli/platform.ts +128 -0
  235. package/src/cli/tickets.ts +130 -0
  236. package/src/cli/utils/output.ts +51 -42
  237. package/src/cli/voice.ts +204 -0
  238. package/src/core/diff.ts +52 -0
  239. package/src/core/org-resolver.ts +113 -0
  240. package/src/core/runtime.ts +73 -0
  241. package/src/core/scope.ts +64 -0
  242. package/src/mcp/registry.ts +236 -0
  243. package/src/mcp/server.ts +84 -2
  244. package/src/mcp/tool-meta.ts +241 -0
  245. package/src/mcp/tools.ts +1529 -265
  246. package/src/sdk/agent-bundle.ts +245 -0
  247. package/src/sdk/agents.ts +74 -5
  248. package/src/sdk/analytics.ts +220 -0
  249. package/src/sdk/auth.ts +87 -107
  250. package/src/sdk/billing.ts +59 -0
  251. package/src/sdk/campaigns.ts +60 -0
  252. package/src/sdk/chat.ts +44 -10
  253. package/src/sdk/client.ts +373 -102
  254. package/src/sdk/config-change.ts +148 -0
  255. package/src/sdk/config.ts +2 -1
  256. package/src/sdk/conversations.ts +199 -0
  257. package/src/sdk/credentials.ts +53 -0
  258. package/src/sdk/distribution.ts +132 -0
  259. package/src/sdk/eval.ts +140 -0
  260. package/src/sdk/functions.ts +52 -6
  261. package/src/sdk/index.ts +2 -0
  262. package/src/sdk/jarvis-chat.ts +112 -0
  263. package/src/sdk/kb.ts +116 -29
  264. package/src/sdk/orgs.ts +62 -0
  265. package/src/sdk/platform.ts +115 -0
  266. package/src/sdk/session.ts +90 -0
  267. package/src/sdk/skills.ts +188 -0
  268. package/src/sdk/test-chat.ts +151 -0
  269. package/src/sdk/tickets.ts +120 -0
  270. package/src/sdk/types.ts +88 -6
  271. package/src/sdk/voice.ts +262 -0
  272. package/test/cli/entry.test.ts +34 -0
  273. package/test/cli/init.test.ts +66 -0
  274. package/test/cli/link.test.ts +57 -0
  275. package/test/core/org-resolver.test.ts +92 -0
  276. package/test/core/runtime.test.ts +30 -0
  277. package/test/core/scope.test.ts +93 -0
  278. package/test/harness.test.ts +24 -0
  279. package/test/helpers/config-home.ts +22 -0
  280. package/test/helpers/fake-middleware.ts +56 -0
  281. package/test/helpers/fetch-recorder.ts +75 -0
  282. package/test/helpers/jwt.ts +9 -0
  283. package/test/helpers/mcp-pair.ts +38 -0
  284. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
  285. package/test/mcp/askers.test.ts +60 -0
  286. package/test/mcp/elicit-timeout.test.ts +29 -0
  287. package/test/mcp/registry.test.ts +169 -0
  288. package/test/mcp/shaping.test.ts +41 -0
  289. package/test/mcp/tools-snapshot.test.ts +42 -0
  290. package/test/sdk/agents.test.ts +232 -0
  291. package/test/sdk/analytics.test.ts +89 -0
  292. package/test/sdk/auth.test.ts +70 -0
  293. package/test/sdk/billing.test.ts +47 -0
  294. package/test/sdk/campaigns.test.ts +43 -0
  295. package/test/sdk/client.test.ts +80 -0
  296. package/test/sdk/config-change.test.ts +142 -0
  297. package/test/sdk/conversations.test.ts +80 -0
  298. package/test/sdk/credentials.test.ts +33 -0
  299. package/test/sdk/distribution.test.ts +55 -0
  300. package/test/sdk/eval.test.ts +155 -0
  301. package/test/sdk/kb-upload.test.ts +50 -0
  302. package/test/sdk/kb.test.ts +95 -0
  303. package/test/sdk/orgs.test.ts +48 -0
  304. package/test/sdk/platform.test.ts +89 -0
  305. package/test/sdk/renewal.test.ts +115 -0
  306. package/test/sdk/session.test.ts +60 -0
  307. package/test/sdk/skills.test.ts +235 -0
  308. package/test/sdk/test-chat.test.ts +112 -0
  309. package/test/sdk/tickets.test.ts +42 -0
  310. package/test/sdk/voice.test.ts +147 -0
  311. package/vitest.config.ts +11 -0
package/src/sdk/client.ts CHANGED
@@ -1,42 +1,108 @@
1
1
  import Conf from 'conf';
2
2
  import { chmodSync } from 'node:fs';
3
3
  import type { ApiResponse, Environment, EnvironmentHosts, IndemnConfig } from './types.js';
4
+ import { CliError, cliVersion, isDebug, SessionExpiredError } from '../core/runtime.js';
5
+ import { copilotAuthHeader, darwinAuthHeader, resolveCredential, type Credential } from './credentials.js';
6
+ import { exchangeIdToken, needsRenewal, refreshIdToken, toStoredSession } from './session.js';
7
+ import { findLinkFile, resolveOrg, type LinkFile, type OrgChoice, type ResolvedOrg } from '../core/org-resolver.js';
8
+ import { emitOrgResolved, getAskers, getOrgFlag, scopedOrg } from '../core/scope.js';
4
9
 
5
- const DEFAULT_HOSTS: Record<Environment, EnvironmentHosts> = {
10
+ export const DEFAULT_HOSTS: Record<Environment, EnvironmentHosts> = {
6
11
  dev: {
7
12
  copilot: 'https://devcopilot.indemn.ai/api',
8
13
  evaluations: 'https://devevaluations.indemn.ai',
9
14
  middleware: 'https://devproxy.indemn.ai',
15
+ platform: 'https://devplatform.indemn.ai/api/v1',
10
16
  },
11
17
  production: {
12
18
  copilot: 'https://copilot.indemn.ai/api',
13
19
  evaluations: 'https://evaluations.indemn.ai',
14
20
  middleware: 'https://proxy.indemn.ai',
21
+ platform: 'https://platform.indemn.ai/api/v1',
15
22
  },
16
23
  };
17
24
 
25
+ export type ClientKind = 'cli' | 'mcp';
26
+ let clientKind: ClientKind = 'cli';
27
+
28
+ type Backend = 'copilot' | 'evaluations' | 'platform';
29
+ type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
30
+
31
+ interface SendOptions {
32
+ body?: unknown;
33
+ headers?: Record<string, string>;
34
+ /** Attach the credential. False only for the login exchange. */
35
+ auth?: boolean;
36
+ }
37
+
38
+ const RETRY_STATUSES = new Set([502, 503, 504]);
39
+ const RETRY_DELAYS_MS = [300, 600];
40
+
41
+ function isEnvironment(value: unknown): value is Environment {
42
+ return value === 'dev' || value === 'production';
43
+ }
44
+
45
+ /**
46
+ * The environment belongs to the credential. A saved login knows its own
47
+ * environment; an API key says it in its prefix. INDEMN_ENV is only read for a
48
+ * host-supplied session token (INDEMN_TOKEN), which carries no environment.
49
+ */
50
+ export function resolveEnvironment(config: IndemnConfig | null, env: NodeJS.ProcessEnv = process.env): Environment {
51
+ if (env.INDEMN_TOKEN && isEnvironment(env.INDEMN_ENV)) return env.INDEMN_ENV;
52
+ if (env.INDEMN_API_KEY?.startsWith('ind_live_')) return 'production';
53
+ if (env.INDEMN_API_KEY?.startsWith('ind_dev_')) return 'dev';
54
+ if (config && isEnvironment(config.environment)) return config.environment;
55
+ return 'dev';
56
+ }
57
+
58
+ function sleep(ms: number): Promise<void> {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+
62
+ /** One renewal at a time per process, shared by every client. */
63
+ let renewing: Promise<void> | null = null;
64
+
65
+ export interface OrgContext extends ResolvedOrg {
66
+ name?: string;
67
+ }
68
+
18
69
  export class IndemnClient {
19
- private apiKey: string;
20
- private orgId: string;
70
+ private explicitCredential: Credential | null;
71
+ private orgOverride?: string;
21
72
  private environment: Environment;
22
73
  private hosts: EnvironmentHosts;
74
+ private resolvedOrg?: Promise<OrgContext>;
75
+ private linkCache?: LinkFile | null;
76
+ private membershipCache?: OrgChoice[];
23
77
 
24
- constructor(opts?: { apiKey?: string; orgId?: string; environment?: Environment }) {
25
- // Priority: explicit opts > env vars > config file
26
- const config = IndemnClient.loadConfig();
78
+ private ignoreLink: boolean;
27
79
 
28
- this.apiKey = opts?.apiKey || process.env.INDEMN_API_KEY || config?.api_key || '';
29
- this.orgId = opts?.orgId || process.env.INDEMN_ORG_ID || config?.org_id || '';
30
- this.environment = opts?.environment || (process.env.INDEMN_ENV as Environment) || config?.environment || 'dev';
80
+ constructor(opts?: { apiKey?: string; token?: string; orgId?: string; environment?: Environment; ignoreLink?: boolean }) {
81
+ const config = IndemnClient.loadConfig();
82
+ const env = opts?.environment || resolveEnvironment(config);
83
+ this.explicitCredential = opts?.token
84
+ ? { kind: 'token', token: opts.token, source: 'env', environment: env }
85
+ : opts?.apiKey
86
+ ? { kind: 'api_key', key: opts.apiKey, source: 'env', environment: env }
87
+ : null;
88
+ this.orgOverride = opts?.orgId;
89
+ this.ignoreLink = Boolean(opts?.ignoreLink);
90
+ this.environment = env;
31
91
  const defaultHosts = DEFAULT_HOSTS[this.environment];
32
92
  const configHosts = config?.hosts?.[this.environment];
33
93
  this.hosts = {
34
94
  copilot: process.env.INDEMN_COPILOT_URL || configHosts?.copilot || defaultHosts.copilot,
35
95
  evaluations: process.env.INDEMN_EVALS_URL || configHosts?.evaluations || defaultHosts.evaluations,
36
96
  middleware: process.env.INDEMN_MIDDLEWARE_URL || configHosts?.middleware || defaultHosts.middleware,
97
+ platform: process.env.INDEMN_PLATFORM_URL || configHosts?.platform || defaultHosts.platform,
37
98
  };
38
99
  }
39
100
 
101
+ /** Name the caller in the X-Indemn-Client header: the terminal CLI or the MCP server. */
102
+ static setClientKind(kind: ClientKind): void {
103
+ clientKind = kind;
104
+ }
105
+
40
106
  static getConfigStore(): Conf<IndemnConfig> {
41
107
  return new Conf<IndemnConfig>({
42
108
  projectName: 'indemn',
@@ -45,17 +111,20 @@ export class IndemnClient {
45
111
  });
46
112
  }
47
113
 
114
+ /** The saved login, or null. Read fresh on every request so a new login in another terminal is picked up. */
48
115
  static loadConfig(): IndemnConfig | null {
49
116
  try {
50
117
  const store = IndemnClient.getConfigStore();
118
+ const session = store.get('session');
51
119
  const apiKey = store.get('api_key');
52
- if (!apiKey) return null;
120
+ if (!session?.token && !apiKey) return null;
53
121
  return {
54
- api_key: apiKey,
55
- org_id: store.get('org_id', ''),
56
- org_name: store.get('org_name', '') || undefined,
57
- user_id: store.get('user_id', ''),
58
122
  environment: store.get('environment', 'dev'),
123
+ user_id: store.get('user_id', ''),
124
+ email: store.get('email'),
125
+ session,
126
+ orgs: store.get('orgs'),
127
+ api_key: apiKey,
59
128
  hosts: store.get('hosts', DEFAULT_HOSTS),
60
129
  };
61
130
  } catch {
@@ -66,9 +135,10 @@ export class IndemnClient {
66
135
  static saveConfig(config: Partial<IndemnConfig>): void {
67
136
  const store = IndemnClient.getConfigStore();
68
137
  for (const [key, value] of Object.entries(config)) {
69
- store.set(key as keyof IndemnConfig, value);
138
+ if (value === undefined) store.delete(key as keyof IndemnConfig);
139
+ else store.set(key as keyof IndemnConfig, value);
70
140
  }
71
- // Restrict config file to owner-only (contains API key)
141
+ // Restrict config file to owner-only (contains credentials)
72
142
  try {
73
143
  chmodSync(store.path, 0o600);
74
144
  } catch {
@@ -82,19 +152,158 @@ export class IndemnClient {
82
152
  }
83
153
 
84
154
  getEnvironment(): Environment { return this.environment; }
85
- getOrgId(): string { return this.orgId; }
86
- getApiKey(): string { return this.apiKey; }
87
155
  getHosts(): EnvironmentHosts { return this.hosts; }
88
156
 
89
- private ensureAuth(): void {
90
- if (!this.apiKey) {
91
- throw new Error('Not authenticated. Run `indemn login` first.');
157
+ /** The credential for the next request. Never cached: the saved login may have been renewed or replaced. */
158
+ getCredential(): Credential | null {
159
+ return this.explicitCredential ?? resolveCredential(IndemnClient.loadConfig());
160
+ }
161
+
162
+ private requireCredential(): Credential {
163
+ const cred = this.getCredential();
164
+ if (!cred) throw new SessionExpiredError('Not logged in. Run `indemn login` first.');
165
+ return cred;
166
+ }
167
+
168
+ /**
169
+ * Get a fresh copilot session with the saved Firebase refresh token, save
170
+ * it, and revoke the old one. Accounts with MFA are asked for the emailed
171
+ * code when a terminal or MCP host can ask; otherwise the person must log in.
172
+ */
173
+ async renewSession(): Promise<void> {
174
+ if (renewing) return renewing;
175
+ renewing = (async () => {
176
+ const config = IndemnClient.loadConfig();
177
+ const old = config?.session;
178
+ if (!config || !old?.refresh_token) {
179
+ throw new SessionExpiredError('Session expired. Run `indemn login`, then retry.');
180
+ }
181
+ const { idToken, refreshToken } = await refreshIdToken(config.environment, old.refresh_token);
182
+ const { token } = await exchangeIdToken(
183
+ (path, body, headers) => this.copilotPostUnauth(path, body, headers),
184
+ idToken,
185
+ getAskers().mfaCode,
186
+ );
187
+ IndemnClient.saveConfig({ session: toStoredSession(token, refreshToken) });
188
+ try {
189
+ await this.send('copilot', 'POST', `${this.hosts.copilot}/auth/firebase/logout`, {
190
+ auth: false,
191
+ headers: { Authorization: `JWT ${old.token}` },
192
+ });
193
+ } catch {
194
+ // Best effort: the old token expires within the hour anyway.
195
+ }
196
+ })().finally(() => {
197
+ renewing = null;
198
+ });
199
+ return renewing;
200
+ }
201
+
202
+ /** The credential for the next request, renewed first when a saved session is about to expire. */
203
+ private async freshCredential(): Promise<Credential> {
204
+ let cred = this.requireCredential();
205
+ if (cred.kind === 'token' && cred.source === 'config') {
206
+ const session = IndemnClient.loadConfig()?.session;
207
+ if (session && needsRenewal(session)) {
208
+ await this.renewSession();
209
+ cred = this.requireCredential();
210
+ }
211
+ }
212
+ return cred;
213
+ }
214
+
215
+ private async authHeader(): Promise<string> {
216
+ return copilotAuthHeader(await this.freshCredential());
217
+ }
218
+
219
+ // --- Organizations -------------------------------------------------------
220
+
221
+ /** The `.indemn/project.json` in effect for this working directory. */
222
+ getLink(): LinkFile | null {
223
+ if (this.ignoreLink) return null;
224
+ if (this.linkCache === undefined) this.linkCache = findLinkFile();
225
+ return this.linkCache;
226
+ }
227
+
228
+ /** Orgs the user belongs to: saved at login, else asked live from copilot. */
229
+ async memberships(): Promise<OrgChoice[]> {
230
+ if (this.membershipCache) return this.membershipCache;
231
+ const cred = this.requireCredential();
232
+ const saved = cred.source === 'config' ? IndemnClient.loadConfig()?.orgs : undefined;
233
+ if (saved?.length) {
234
+ this.membershipCache = saved;
235
+ } else {
236
+ const res = await this.copilotGet<{ userOrganizations?: Array<{ _id: string; name: string }> }>('/organization');
237
+ this.membershipCache = (res.userOrganizations ?? []).map((o) => ({ id: String(o._id), name: o.name }));
238
+ }
239
+ return this.membershipCache;
240
+ }
241
+
242
+ /**
243
+ * The org this client acts on. `explicit` is an MCP tool's org_id; the
244
+ * terminal passes --org through the scope module. Decided once per client.
245
+ */
246
+ async resolveOrg(explicit?: string, opts: { cache?: boolean } = {}): Promise<OrgContext> {
247
+ const run = async (): Promise<OrgContext> => {
248
+ const resolved = await resolveOrg({
249
+ flag: explicit ?? this.orgOverride ?? getOrgFlag(),
250
+ envOrg: process.env.INDEMN_ORG_ID || undefined,
251
+ link: this.getLink(),
252
+ environment: this.environment,
253
+ memberships: () => this.memberships(),
254
+ ask: getAskers().chooseOrg,
255
+ });
256
+ const known = this.membershipCache ?? IndemnClient.loadConfig()?.orgs ?? [];
257
+ const name = known.find((o) => o.id === resolved.id)?.name ?? resolved.link?.org_name;
258
+ return { ...resolved, name };
259
+ };
260
+ if (explicit || opts.cache === false) return run();
261
+ if (!this.resolvedOrg) {
262
+ this.resolvedOrg = run().then((org) => {
263
+ emitOrgResolved({ id: org.id, name: org.name, source: org.source, environment: this.environment });
264
+ return org;
265
+ });
266
+ this.resolvedOrg.catch(() => { this.resolvedOrg = undefined; });
267
+ }
268
+ return this.resolvedOrg;
269
+ }
270
+
271
+ /** Org id for the current call: an MCP call's scope first, then this client's resolution. */
272
+ async currentOrgId(): Promise<string> {
273
+ return scopedOrg() ?? (await this.resolveOrg()).id;
274
+ }
275
+
276
+ /** What every MCP result reports as its origin. Never triggers a question. */
277
+ async getResultContext(): Promise<{ org_id?: string; org_name?: string; environment: Environment }> {
278
+ const scoped = scopedOrg();
279
+ const settled = this.resolvedOrg ? await this.resolvedOrg.catch(() => undefined) : undefined;
280
+ const id = scoped ?? settled?.id;
281
+ const known = this.membershipCache ?? IndemnClient.loadConfig()?.orgs ?? [];
282
+ return { org_id: id, org_name: id ? known.find((o) => o.id === id)?.name : undefined, environment: this.environment };
283
+ }
284
+
285
+ /**
286
+ * In a linked folder with an agent list, refuse any agent that is not on
287
+ * the list before a request leaves the machine.
288
+ */
289
+ private assertAgentInScope(path: string): void {
290
+ const link = this.getLink();
291
+ if (!link || link.agents.length === 0) return;
292
+ const match = /\/bots\/([a-f0-9]{24})(?:\/|$|\?)/i.exec(path);
293
+ if (match && !link.agents.includes(match[1])) {
294
+ throw new CliError(
295
+ `Agent ${match[1]} is not linked in ${link.path}. Linked agents: ${link.agents.join(', ')}. ` +
296
+ 'Add it with `indemn link` or work from another folder.',
297
+ );
92
298
  }
93
299
  }
94
300
 
95
- private buildCopilotUrl(path: string): string {
96
- // Inject org_id into path templates
97
- const resolvedPath = path.replace(':org_id', this.orgId);
301
+ private async buildCopilotUrl(path: string): Promise<string> {
302
+ let resolvedPath = path;
303
+ if (path.includes(':org_id')) {
304
+ resolvedPath = path.replace(':org_id', await this.currentOrgId());
305
+ }
306
+ this.assertAgentInScope(resolvedPath);
98
307
  return `${this.hosts.copilot}${resolvedPath}`;
99
308
  }
100
309
 
@@ -102,134 +311,196 @@ export class IndemnClient {
102
311
  return `${this.hosts.evaluations}${path}`;
103
312
  }
104
313
 
105
- // Copilot Server requests (with /api/ prefix and org_id injection)
106
- async copilotGet<T = unknown>(path: string, query?: Record<string, string>): Promise<T> {
107
- this.ensureAuth();
108
- const url = new URL(this.buildCopilotUrl(path));
109
- if (query) {
110
- for (const [k, v] of Object.entries(query)) {
111
- if (v !== undefined && v !== null) url.searchParams.set(k, v);
314
+ /**
315
+ * The one place every request goes through: client headers, credential,
316
+ * --debug tracing, and retries for reads only (a retried create could
317
+ * create twice).
318
+ */
319
+ private async send(backend: Backend, method: Method, url: string, opts: SendOptions = {}): Promise<Response> {
320
+ const headers: Record<string, string> = {
321
+ 'User-Agent': `indemn-cli/${cliVersion()}`,
322
+ 'X-Indemn-Client': clientKind,
323
+ ...opts.headers,
324
+ };
325
+ let body: BodyInit | undefined;
326
+ if (opts.body instanceof FormData) {
327
+ body = opts.body;
328
+ } else if (opts.body !== undefined) {
329
+ headers['Content-Type'] = 'application/json';
330
+ body = JSON.stringify(opts.body);
331
+ } else if (method !== 'GET') {
332
+ headers['Content-Type'] = 'application/json';
333
+ }
334
+ if (backend === 'copilot' && opts.auth !== false) {
335
+ headers['Authorization'] = await this.authHeader();
336
+ }
337
+
338
+ const attempts = method === 'GET' ? RETRY_DELAYS_MS.length + 1 : 1;
339
+ for (let attempt = 0; ; attempt++) {
340
+ const started = Date.now();
341
+ if (isDebug()) process.stderr.write(`[debug] → ${method} ${url}\n`);
342
+ try {
343
+ const res = await fetch(url, { method, headers, body });
344
+ if (isDebug()) process.stderr.write(`[debug] ← ${res.status} ${method} ${url} (${Date.now() - started} ms)\n`);
345
+ if (RETRY_STATUSES.has(res.status) && attempt + 1 < attempts) {
346
+ await sleep(RETRY_DELAYS_MS[attempt]);
347
+ continue;
348
+ }
349
+ return res;
350
+ } catch (err) {
351
+ if (isDebug()) process.stderr.write(`[debug] ✗ ${method} ${url}: ${(err as Error).message}\n`);
352
+ if (attempt + 1 < attempts) {
353
+ await sleep(RETRY_DELAYS_MS[attempt]);
354
+ continue;
355
+ }
356
+ throw err;
112
357
  }
113
358
  }
114
- const res = await fetch(url.toString(), {
115
- headers: {
116
- 'Authorization': `Bearer ${this.apiKey}`,
117
- 'Content-Type': 'application/json',
118
- },
119
- });
359
+ }
360
+
361
+ private withQuery(url: string, query?: Record<string, string>): string {
362
+ if (!query) return url;
363
+ const u = new URL(url);
364
+ for (const [k, v] of Object.entries(query)) {
365
+ if (v !== undefined && v !== null) u.searchParams.set(k, v);
366
+ }
367
+ return u.toString();
368
+ }
369
+
370
+ /**
371
+ * A copilot request with the session rules: renew before expiry, and on a
372
+ * 401 with a saved session renew once and retry once.
373
+ */
374
+ private async copilotRequest<T>(method: Method, path: string, opts: { body?: unknown; query?: Record<string, string> } = {}): Promise<T> {
375
+ const url = this.withQuery(await this.buildCopilotUrl(path), opts.query);
376
+ let res = await this.send('copilot', method, url, { body: opts.body });
377
+ const cred = this.getCredential();
378
+ if (res.status === 401 && cred?.kind === 'token' && cred.source === 'config') {
379
+ await this.renewSession();
380
+ res = await this.send('copilot', method, url, { body: opts.body });
381
+ }
120
382
  return this.handleCopilotResponse<T>(res);
121
383
  }
122
384
 
385
+ /** A copilot request with any method (for PATCH and other routes without a helper). */
386
+ async copilotRequestRaw<T = unknown>(method: Method | 'PATCH', path: string, body?: unknown): Promise<T> {
387
+ return this.copilotRequest<T>(method as Method, path, { body });
388
+ }
389
+
390
+ /** A copilot request whose answer is a file or audio, returned as bytes. */
391
+ async copilotBinary(method: Method, path: string, body?: unknown): Promise<{ contentType: string; data: Buffer }> {
392
+ const res = await this.send('copilot', method, await this.buildCopilotUrl(path), { body });
393
+ if (res.status === 401) throw new SessionExpiredError('Authentication failed. Run `indemn login` to re-authenticate.');
394
+ if (!res.ok) throw new Error(`API error (${res.status}): ${await res.text()}`);
395
+ return { contentType: res.headers.get('content-type') ?? 'application/octet-stream', data: Buffer.from(await res.arrayBuffer()) };
396
+ }
397
+
398
+ // Copilot Server requests (with /api/ prefix and org_id injection)
399
+ async copilotGet<T = unknown>(path: string, query?: Record<string, string>): Promise<T> {
400
+ return this.copilotRequest<T>('GET', path, { query });
401
+ }
402
+
123
403
  async copilotPost<T = unknown>(path: string, body?: unknown): Promise<T> {
124
- this.ensureAuth();
125
- const res = await fetch(this.buildCopilotUrl(path), {
126
- method: 'POST',
127
- headers: {
128
- 'Authorization': `Bearer ${this.apiKey}`,
129
- 'Content-Type': 'application/json',
130
- },
131
- body: body ? JSON.stringify(body) : undefined,
132
- });
133
- return this.handleCopilotResponse<T>(res);
404
+ return this.copilotRequest<T>('POST', path, { body });
405
+ }
406
+
407
+ /** Multipart upload (for example a knowledge-base file). */
408
+ async copilotPostForm<T = unknown>(path: string, form: FormData): Promise<T> {
409
+ return this.copilotRequest<T>('POST', path, { body: form });
134
410
  }
135
411
 
136
412
  async copilotPut<T = unknown>(path: string, body?: unknown): Promise<T> {
137
- this.ensureAuth();
138
- const res = await fetch(this.buildCopilotUrl(path), {
139
- method: 'PUT',
140
- headers: {
141
- 'Authorization': `Bearer ${this.apiKey}`,
142
- 'Content-Type': 'application/json',
143
- },
144
- body: body ? JSON.stringify(body) : undefined,
145
- });
146
- return this.handleCopilotResponse<T>(res);
413
+ return this.copilotRequest<T>('PUT', path, { body });
147
414
  }
148
415
 
149
416
  async copilotDelete<T = unknown>(path: string): Promise<T> {
150
- this.ensureAuth();
151
- const res = await fetch(this.buildCopilotUrl(path), {
152
- method: 'DELETE',
153
- headers: {
154
- 'Authorization': `Bearer ${this.apiKey}`,
155
- 'Content-Type': 'application/json',
156
- },
157
- });
158
- return this.handleCopilotResponse<T>(res);
417
+ return this.copilotRequest<T>('DELETE', path);
159
418
  }
160
419
 
161
- // Evaluations Service requests (no /api/ prefix, no org_id, no auth in Phase 1)
162
- async evalsGet<T = unknown>(path: string, query?: Record<string, string>): Promise<T> {
163
- const url = new URL(this.buildEvalsUrl(path));
164
- if (query) {
165
- for (const [k, v] of Object.entries(query)) {
166
- if (v !== undefined && v !== null) url.searchParams.set(k, v);
167
- }
420
+ /**
421
+ * Darwin (the V2 platform) request: the same session token as copilot, sent
422
+ * as Bearer, with the org in X-Organization-Id. Darwin checks membership and
423
+ * roles itself. A 401 on a saved session renews once and retries once.
424
+ */
425
+ async platformRequest<T = unknown>(method: Method, path: string, opts: { body?: unknown; query?: Record<string, string>; orgHeader?: boolean } = {}): Promise<T> {
426
+ const base = this.hosts.platform;
427
+ if (!base) throw new CliError('No V2 platform host is configured for this environment.');
428
+ const url = this.withQuery(`${base}${path}`, opts.query);
429
+ this.assertAgentInScope(path);
430
+ const headersFor = async (): Promise<Record<string, string>> => {
431
+ const headers: Record<string, string> = { Authorization: darwinAuthHeader(await this.freshCredential()) };
432
+ if (opts.orgHeader !== false) headers['X-Organization-Id'] = await this.currentOrgId();
433
+ return headers;
434
+ };
435
+ let res = await this.send('platform', method, url, { body: opts.body, headers: await headersFor() });
436
+ const cred = this.getCredential();
437
+ if (res.status === 401 && cred?.kind === 'token' && cred.source === 'config') {
438
+ await this.renewSession();
439
+ res = await this.send('platform', method, url, { body: opts.body, headers: await headersFor() });
168
440
  }
169
- const res = await fetch(url.toString(), {
170
- headers: { 'Content-Type': 'application/json' },
171
- });
441
+ if (res.status === 401) throw new SessionExpiredError('The V2 platform rejected the session. Run `indemn login`, then retry.');
442
+ if (!res.ok) {
443
+ const text = await res.text();
444
+ let detail = text;
445
+ try { detail = (JSON.parse(text) as { detail?: unknown }).detail as string ?? text; } catch { /* plain text */ }
446
+ if (res.status === 403) throw new CliError(`Not allowed by the V2 platform: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
447
+ if (res.status === 404) throw new CliError('Not found on the V2 platform.');
448
+ throw new CliError(`V2 platform error (${res.status}): ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
449
+ }
450
+ if (res.status === 204) return undefined as T;
451
+ return res.json() as Promise<T>;
452
+ }
453
+
454
+ // Evaluations Service requests (no /api/ prefix, no org_id, no auth)
455
+ async evalsGet<T = unknown>(path: string, query?: Record<string, string>): Promise<T> {
456
+ const res = await this.send('evaluations', 'GET', this.withQuery(this.buildEvalsUrl(path), query));
172
457
  return this.handleEvalsResponse<T>(res);
173
458
  }
174
459
 
175
460
  async evalsPost<T = unknown>(path: string, body?: unknown): Promise<T> {
176
- const res = await fetch(this.buildEvalsUrl(path), {
177
- method: 'POST',
178
- headers: { 'Content-Type': 'application/json' },
179
- body: body ? JSON.stringify(body) : undefined,
180
- });
461
+ const res = await this.send('evaluations', 'POST', this.buildEvalsUrl(path), { body });
181
462
  return this.handleEvalsResponse<T>(res);
182
463
  }
183
464
 
184
465
  async evalsPut<T = unknown>(path: string, body?: unknown): Promise<T> {
185
- const res = await fetch(this.buildEvalsUrl(path), {
186
- method: 'PUT',
187
- headers: { 'Content-Type': 'application/json' },
188
- body: body ? JSON.stringify(body) : undefined,
189
- });
466
+ const res = await this.send('evaluations', 'PUT', this.buildEvalsUrl(path), { body });
190
467
  return this.handleEvalsResponse<T>(res);
191
468
  }
192
469
 
193
470
  async evalsDelete<T = unknown>(path: string): Promise<T> {
194
- const res = await fetch(this.buildEvalsUrl(path), {
195
- method: 'DELETE',
196
- headers: { 'Content-Type': 'application/json' },
197
- });
471
+ const res = await this.send('evaluations', 'DELETE', this.buildEvalsUrl(path));
198
472
  return this.handleEvalsResponse<T>(res);
199
473
  }
200
474
 
201
475
  // Unauthenticated Copilot Server requests (for login flow)
202
476
  async copilotPostUnauth<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
203
- const res = await fetch(`${this.hosts.copilot}${path}`, {
204
- method: 'POST',
205
- headers: {
206
- 'Content-Type': 'application/json',
207
- ...headers,
208
- },
209
- body: body ? JSON.stringify(body) : undefined,
210
- });
477
+ const res = await this.send('copilot', 'POST', `${this.hosts.copilot}${path}`, { body, headers, auth: false });
211
478
  return this.handleCopilotResponse<T>(res);
212
479
  }
213
480
 
214
481
  private async handleCopilotResponse<T>(res: Response): Promise<T> {
215
482
  if (res.status === 401) {
216
- throw new Error('Authentication failed. Run `indemn login` to re-authenticate.');
483
+ throw new SessionExpiredError('Authentication failed. Run `indemn login` to re-authenticate.');
217
484
  }
218
485
  if (res.status === 403) {
219
486
  throw new Error('Access denied. You may not have permission for this operation.');
220
487
  }
221
488
  if (res.status === 404) {
222
- throw new Error('Resource not found.');
489
+ // Keep the server's reason: voice-service answers 404 with specific messages (no phone number, ...).
490
+ const text = await res.text().catch(() => '');
491
+ let reason = '';
492
+ try { const b = JSON.parse(text) as { message?: unknown; error?: unknown }; reason = String(b.message ?? b.error ?? ''); } catch { reason = text.trim().startsWith('<') ? '' : text.slice(0, 200); }
493
+ throw new Error(reason ? `Not found: ${reason}` : 'Resource not found.');
223
494
  }
224
495
  if (!res.ok) {
225
496
  const text = await res.text();
226
497
  throw new Error(`API error (${res.status}): ${text}`);
227
498
  }
228
- const json = await res.json() as ApiResponse<T>;
499
+ const json = await res.json() as ApiResponse<T> & { message?: string };
229
500
  // Copilot Server wraps responses in { success, data }
230
501
  if (json.success !== undefined) {
231
502
  if (!json.success) {
232
- throw new Error(json.msg || 'API request failed');
503
+ throw new Error(json.msg || json.message || 'API request failed');
233
504
  }
234
505
  return (json.data !== undefined ? json.data : json) as T;
235
506
  }