@indemn/cli 1.5.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 +125 -35
  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 +122 -36
  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
@@ -0,0 +1,130 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { IndemnClient } from '../sdk/client.js';
4
+ import { TicketsSDK, type TicketPriority, type TicketStatus } from '../sdk/tickets.js';
5
+ import { confirmOrExit } from './link.js';
6
+ import { formatTable, printError, printJson, printSuccess } from './utils/output.js';
7
+
8
+ function run(fn: () => Promise<void>): Promise<void> {
9
+ return fn().catch((err) => {
10
+ printError(err);
11
+ process.exit(1);
12
+ });
13
+ }
14
+ const sdk = () => new TicketsSDK(new IndemnClient());
15
+
16
+ export function registerTicketCommands(program: Command): void {
17
+ const tickets = program.command('tickets').description('Support tickets (the org must have tickets turned on)');
18
+
19
+ tickets.command('list')
20
+ .option('--status <list>', 'not_started, in_progress, completed (comma-separated)')
21
+ .option('--assignee <user-id>', 'Assigned to this member')
22
+ .option('--search <text>', 'Search title, description and reference')
23
+ .option('--limit <n>', 'At most this many', '50')
24
+ .option('--json', 'Output as JSON')
25
+ .action((options) => run(async () => {
26
+ const rows = await sdk().list({ status: options.status?.split(',') as TicketStatus[] | undefined, assignee: options.assignee, search: options.search, limit: Number(options.limit) });
27
+ if (options.json) { printJson(rows); return; }
28
+ console.log(formatTable(['ID', 'Ref', 'Title', 'Status', 'Priority'], rows.map((t) => [t._id, String(t.ticket_ref ?? ''), String(t.title ?? '').slice(0, 50), String(t.status ?? ''), String(t.priority ?? '')])));
29
+ }));
30
+
31
+ tickets.command('get').argument('<ticket-id>').action((id: string) => run(async () => printJson(await sdk().get(id))));
32
+
33
+ tickets.command('create')
34
+ .requiredOption('--title <text>', 'Title')
35
+ .option('--description <text>', 'Description')
36
+ .option('--priority <p>', 'urgent, high, normal or low')
37
+ .option('--assign <user-id>', 'Assign to this member')
38
+ .option('--conversation <request-id>', 'Attach a conversation')
39
+ .option('--force', 'Create even if similar tickets exist (skips the duplicate check)')
40
+ .action((options) => run(async () => {
41
+ const res = await sdk().create({
42
+ title: options.title, description: options.description, priority: options.priority as TicketPriority | undefined,
43
+ ...(options.assign ? { assignee: { type: 'user', target: options.assign } } : {}),
44
+ ...(options.conversation ? { conversations: [{ request_id: options.conversation }] } : {}),
45
+ }, { force: options.force });
46
+ if (!res.saved) {
47
+ console.log(chalk.yellow(res.message));
48
+ for (const t of res.similar) console.log(` ${t._id} ${t.title ?? ''}`);
49
+ process.exit(2);
50
+ }
51
+ printSuccess(`Ticket created: ${res.ticket._id}`);
52
+ }));
53
+
54
+ tickets.command('update')
55
+ .argument('<ticket-id>')
56
+ .option('--title <text>').option('--description <text>').option('--notes <text>')
57
+ .option('--status <status>', 'not_started or in_progress (use `resolve` to close)')
58
+ .option('--priority <p>', 'urgent, high, normal or low')
59
+ .action((id: string, options) => run(async () => {
60
+ const changes: Record<string, unknown> = {};
61
+ for (const k of ['title', 'description', 'notes', 'status', 'priority']) if (options[k] !== undefined) changes[k] = options[k];
62
+ printJson(await sdk().update(id, changes));
63
+ }));
64
+
65
+ tickets.command('assign')
66
+ .argument('<ticket-id>')
67
+ .argument('[user-id]', 'Member to assign; omit with --unassign')
68
+ .option('--unassign', 'Remove the assignee')
69
+ .action((id: string, userId: string | undefined, options) => run(async () => {
70
+ printJson(await sdk().assign(id, options.unassign || !userId ? null : { type: 'user', target: userId }));
71
+ }));
72
+
73
+ tickets.command('resolve')
74
+ .argument('<ticket-id>')
75
+ .option('--note <text>', 'Closing note')
76
+ .description('Close a ticket')
77
+ .action((id: string, options) => run(async () => { printJson(await sdk().resolve(id, options.note)); }));
78
+
79
+ tickets.command('link')
80
+ .argument('<ticket-id>')
81
+ .argument('<related-ticket-id>')
82
+ .action((id: string, related: string) => run(async () => { printJson(await sdk().link(id, related)); }));
83
+
84
+ tickets.command('archive')
85
+ .argument('<ticket-id>')
86
+ .option('--yes', 'Do not ask for confirmation')
87
+ .action((id: string, options) => run(async () => {
88
+ await confirmOrExit(`Archive ticket ${id}?`, Boolean(options.yes));
89
+ await sdk().archive(id);
90
+ printSuccess(`Ticket ${id} archived.`);
91
+ }));
92
+
93
+ const rules = tickets.command('rules').description('Rules that let agents open tickets from conversations');
94
+ rules.command('list').option('--agent <id>', 'Rules enabled for this agent').action((options) => run(async () => printJson(await sdk().listRules({ agentId: options.agent }))));
95
+ rules.command('create')
96
+ .requiredOption('--prompt <text>', 'When should a ticket be created?')
97
+ .option('--name <name>', 'Rule name (max 75 characters)')
98
+ .option('--agents <ids>', 'Only these agents (comma-separated); default all agents')
99
+ .option('--assign <user-id>', 'Assign created tickets to this member')
100
+ .option('--disabled', 'Create switched off')
101
+ .option('--force', 'Skip the overlap check')
102
+ .action((options) => run(async () => {
103
+ const res = await sdk().saveRule({
104
+ name: options.name, prompt: options.prompt, enabled: !options.disabled,
105
+ scope: options.agents ? { kind: 'specific', agents: options.agents.split(',') } : { kind: 'all_agents', agents: [] },
106
+ ...(options.assign ? { assignment: { type: 'user', target: options.assign } } : {}),
107
+ }, { force: options.force });
108
+ if (!res.saved) {
109
+ console.log(chalk.yellow('The rule overlaps existing ones; nothing was saved. Use --force to save anyway.'));
110
+ printJson({ caveats: res.caveats, suggested_name: res.suggested_name });
111
+ process.exit(2);
112
+ }
113
+ printJson(res.rule);
114
+ }));
115
+ rules.command('update')
116
+ .argument('<rule-id>')
117
+ .option('--name <name>').option('--prompt <text>')
118
+ .option('--enable').option('--disable')
119
+ .action((id: string, options) => run(async () => {
120
+ printJson((await sdk().saveRule({ ruleId: id, name: options.name, prompt: options.prompt, enabled: options.enable ? true : options.disable ? false : undefined })).rule);
121
+ }));
122
+ rules.command('delete')
123
+ .argument('<rule-id>')
124
+ .option('--yes', 'Do not ask for confirmation')
125
+ .action((id: string, options) => run(async () => {
126
+ await confirmOrExit(`Delete ticket rule ${id}? Agents stop opening tickets for it.`, Boolean(options.yes));
127
+ await sdk().deleteRule(id);
128
+ printSuccess(`Rule ${id} deleted.`);
129
+ }));
130
+ }
@@ -1,7 +1,9 @@
1
1
  import Table from 'cli-table3';
2
2
  import chalk from 'chalk';
3
3
  import * as readline from 'readline';
4
- import { IndemnClient } from '../../sdk/client.js';
4
+ import { onOrgResolved } from '../../core/scope.js';
5
+ import type { OrgChoice } from '../../core/org-resolver.js';
6
+ import { CliError, EXIT, NeedsInputError } from '../../core/runtime.js';
5
7
 
6
8
  export function formatTable(headers: string[], rows: string[][]): string {
7
9
  const table = new Table({
@@ -18,10 +20,21 @@ export function printJson(data: unknown): void {
18
20
  console.log(JSON.stringify(data, null, 2));
19
21
  }
20
22
 
23
+ /**
24
+ * Print an error. A CliError that carries its own exit code (2: a choice is
25
+ * needed, 3: log in again) ends the process with that code here, so every
26
+ * command gets the right code without handling it itself.
27
+ */
21
28
  export function printError(error: unknown): void {
22
29
  const message =
23
30
  error instanceof Error ? error.message : String(error);
24
31
  console.error(chalk.red(`Error: ${message}`));
32
+ if (error instanceof NeedsInputError && error.choices.length) {
33
+ for (const c of error.choices) console.error(` ${c.id} ${c.label}`);
34
+ }
35
+ if (error instanceof CliError && error.exitCode !== EXIT.ERROR) {
36
+ process.exit(error.exitCode);
37
+ }
25
38
  }
26
39
 
27
40
  export function printSuccess(message: string): void {
@@ -29,51 +42,47 @@ export function printSuccess(message: string): void {
29
42
  }
30
43
 
31
44
  /**
32
- * Render the active-org banner: [Voice Demos · 69a40cd9…6807f · dev]
33
- *
34
- * Printed to stderr so it never pollutes stdout pipes (JSON output stays clean
35
- * without --json suppression). Rendered red for prod to reinforce blast-radius
36
- * awareness. Silent if not logged in or if the caller opts out.
37
- *
38
- * Self-heals when org_name is missing from config (e.g., after upgrading from
39
- * a pre-1.4 install) by fetching /organization/:id once and caching.
45
+ * Print the org banner once, when a command's org is decided:
46
+ * [Voice Demos · 69a40cd9…6807 · dev · linked]. It goes to stderr so stdout
47
+ * stays clean for pipes, and is red on production.
40
48
  */
41
- export async function printBanner(): Promise<void> {
42
- if (process.env.INDEMN_NO_BANNER === '1') return;
49
+ export function installBanner(): void {
50
+ onOrgResolved((org) => {
51
+ if (process.env.INDEMN_NO_BANNER === '1') return;
52
+ const shortId = org.id.length > 12 ? `${org.id.slice(0, 8)}…${org.id.slice(-4)}` : org.id;
53
+ const how = { flag: '--org', env: 'INDEMN_ORG_ID', link: 'linked folder', single: 'only org', asked: 'chosen' }[org.source];
54
+ const text = `[${org.name ?? '(unnamed org)'} · ${shortId} · ${org.environment} · ${how}]`;
55
+ const colored = org.environment === 'production' ? chalk.red.bold(text) : chalk.yellow.dim(text);
56
+ // The org is often decided while a spinner is drawing on this line: clear it first.
57
+ const clear = process.stderr.isTTY ? '\r\x1b[2K' : '';
58
+ process.stderr.write(clear + colored + '\n');
59
+ });
60
+ }
43
61
 
44
- const config = IndemnClient.loadConfig();
45
- if (!config || !config.api_key || !config.org_id) return;
62
+ /** Read one line from the terminal, writing the question to stderr so stdout stays clean. */
63
+ export function askLine(question: string): Promise<string> {
64
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
65
+ return new Promise((resolve) => {
66
+ rl.question(question, (answer) => {
67
+ rl.close();
68
+ resolve(answer.trim());
69
+ });
70
+ });
71
+ }
46
72
 
47
- let orgName = config.org_name;
48
- if (!orgName) {
49
- // Lazy one-time fetch so post-upgrade installs don't render "(unknown)" forever.
50
- try {
51
- const client = new IndemnClient();
52
- const orgRes = await fetch(`${client.getHosts().copilot}/organization/${config.org_id}`, {
53
- headers: {
54
- 'Authorization': `Bearer ${client.getApiKey()}`,
55
- 'Content-Type': 'application/json',
56
- },
57
- });
58
- if (orgRes.ok) {
59
- const data = await orgRes.json() as { organization?: { name?: string } };
60
- orgName = data.organization?.name;
61
- if (orgName) IndemnClient.saveConfig({ org_name: orgName });
62
- }
63
- } catch {
64
- // Silent — banner degrades to ID-only display
65
- }
66
- }
73
+ /** Terminal version of "which org?": a numbered list; answer with the number or the id. */
74
+ export async function askOrgInTerminal(choices: OrgChoice[]): Promise<string | undefined> {
75
+ process.stderr.write(chalk.bold('You belong to several organizations. Which one?\n'));
76
+ choices.forEach((c, i) => process.stderr.write(` ${i + 1}) ${c.name ?? c.id} ${chalk.dim(c.id)}\n`));
77
+ const answer = await askLine('Number or id: ');
78
+ const index = Number.parseInt(answer, 10);
79
+ if (String(index) === answer && index >= 1 && index <= choices.length) return choices[index - 1].id;
80
+ return choices.find((c) => c.id === answer)?.id;
81
+ }
67
82
 
68
- const shortId = config.org_id.length > 12
69
- ? `${config.org_id.slice(0, 8)}…${config.org_id.slice(-4)}`
70
- : config.org_id;
71
- const label = orgName || '(unknown org)';
72
- const text = `[${label} · ${shortId} · ${config.environment}]`;
73
- const colored = config.environment === 'production'
74
- ? chalk.red.bold(text)
75
- : chalk.yellow.dim(text);
76
- process.stderr.write(colored + '\n');
83
+ export async function askMfaInTerminal(): Promise<string> {
84
+ process.stderr.write(chalk.yellow('Your session needs renewing and your account uses MFA. A code was emailed to you.\n'));
85
+ return askLine('MFA code: ');
77
86
  }
78
87
 
79
88
  export async function confirm(message: string): Promise<boolean> {
@@ -0,0 +1,204 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { writeFileSync } from 'node:fs';
4
+ import { IndemnClient } from '../sdk/client.js';
5
+ import { VoiceSDK, toE164 } from '../sdk/voice.js';
6
+ import { CliError, EXIT } from '../core/runtime.js';
7
+ import { confirmOrExit } from './link.js';
8
+ import { formatTable, printError, printJson, printSuccess } from './utils/output.js';
9
+
10
+ function run(fn: () => Promise<void>): Promise<void> {
11
+ return fn().catch((err) => {
12
+ printError(err);
13
+ process.exit(1);
14
+ });
15
+ }
16
+
17
+ export function registerVoiceCommands(program: Command): void {
18
+ const voices = program.command('voices').description('Text-to-speech voices an agent can use');
19
+
20
+ voices.command('list')
21
+ .option('--provider <name>', 'cartesia (default) or elevenlabs', 'cartesia')
22
+ .option('--language <code>', 'For example en')
23
+ .option('--gender <gender>', 'masculine, feminine or gender_neutral')
24
+ .option('--search <text>', 'Name search (ElevenLabs needs 3+ characters)')
25
+ .option('--limit <n>', 'At most this many', '50')
26
+ .option('--json', 'Output as JSON')
27
+ .description('List voices; copy the id, name, gender, language and country into `agents voice set`')
28
+ .action((options) => run(async () => {
29
+ const res = await new VoiceSDK(new IndemnClient()).listVoices({ provider: options.provider, language: options.language, gender: options.gender, q: options.search, limit: Number(options.limit) });
30
+ if (options.json) { printJson(res); return; }
31
+ const rows = (res.data ?? []) as Array<Record<string, unknown>>;
32
+ console.log(formatTable(['ID', 'Name', 'Gender', 'Language', 'Country'], rows.map((v) => [String(v.id), String(v.name ?? ''), String(v.gender ?? ''), String(v.language ?? ''), String(v.countryCode ?? '')])));
33
+ if (res.has_more) console.log(chalk.dim('More voices exist; narrow with --language, --gender or --search.'));
34
+ }));
35
+
36
+ voices.command('filters')
37
+ .argument('<provider>', 'cartesia or elevenlabs')
38
+ .description('Languages, genders and accents a provider offers')
39
+ .action((provider: string) => run(async () => printJson(await new VoiceSDK(new IndemnClient()).voiceFilters(provider))));
40
+
41
+ voices.command('play')
42
+ .argument('<voice-id>', 'Voice ID')
43
+ .option('--text <text>', 'What to say')
44
+ .option('--speed <n>', 'Speaking speed')
45
+ .requiredOption('-o, --output <file>', 'Audio file to write')
46
+ .description('Save a speech sample of a voice')
47
+ .action((voiceId: string, options) => run(async () => {
48
+ const audio = await new VoiceSDK(new IndemnClient()).playVoice(voiceId, options.text, options.speed ? Number(options.speed) : undefined);
49
+ writeFileSync(options.output, audio.data);
50
+ printSuccess(`Wrote ${options.output} (${audio.contentType}, ${audio.data.length} bytes).`);
51
+ }));
52
+
53
+ program.command('voice-models')
54
+ .description('Speech providers and models')
55
+ .command('list')
56
+ .requiredOption('--kind <kind>', 'stt (speech to text) or tts (text to speech)')
57
+ .action((options) => run(async () => {
58
+ if (!['stt', 'tts'].includes(options.kind)) throw new CliError('--kind must be stt or tts.', EXIT.NEEDS_INPUT);
59
+ printJson(await new VoiceSDK(new IndemnClient()).voiceModels(options.kind));
60
+ }));
61
+
62
+ const agents = program.commands.find((c) => c.name() === 'agents');
63
+ if (!agents) throw new Error('agents command group must be registered before voice commands');
64
+
65
+ const voice = agents.command('voice').description('Voice settings of an agent (they live on its phone number)');
66
+ voice.command('get')
67
+ .argument('<agent-id>', 'Agent ID')
68
+ .action((agentId: string) => run(async () => printJson(await new VoiceSDK(new IndemnClient()).getAgentVoice(agentId))));
69
+
70
+ voice.command('set')
71
+ .argument('<agent-id>', 'Agent ID')
72
+ .requiredOption('--voice-id <id>', 'Voice ID from `voices list`')
73
+ .requiredOption('--name <name>', 'Voice name')
74
+ .requiredOption('--gender <gender>', 'Voice gender')
75
+ .requiredOption('--language <code>', 'Language, for example en')
76
+ .requiredOption('--country <code>', 'Country code, for example US')
77
+ .option('--provider <name>', 'cartesia or elevenlabs', 'cartesia')
78
+ .option('--model <model>', 'TTS model')
79
+ .option('--speed <n>', 'Speaking speed')
80
+ .option('--yes', 'Do not ask for confirmation')
81
+ .description('Change the voice of an agent')
82
+ .action((agentId: string, options) => run(async () => {
83
+ const sdk = new VoiceSDK(new IndemnClient());
84
+ const current = await sdk.getAgentVoice(agentId) as { tts?: { name?: string; voice_id?: string } };
85
+ await confirmOrExit(`Change the voice of agent ${agentId} from ${current.tts?.name ?? current.tts?.voice_id ?? '(default)'} to ${options.name}? Callers hear it on the next call.`, Boolean(options.yes));
86
+ await sdk.setAgentVoice(agentId, {
87
+ provider: options.provider, voice_id: options.voiceId, name: options.name, gender: options.gender, language: options.language,
88
+ countryCode: options.country, ...(options.model ? { model: options.model } : {}), ...(options.speed ? { settings: { speed: Number(options.speed) } } : {}),
89
+ });
90
+ printSuccess(`Voice of ${agentId} is now ${options.name}.`);
91
+ }));
92
+
93
+ voice.command('transcriber')
94
+ .argument('<agent-id>', 'Agent ID')
95
+ .requiredOption('--provider <name>', 'Speech-to-text provider (see `voice-models list --kind stt`)')
96
+ .requiredOption('--model <model>', 'Speech-to-text model')
97
+ .requiredOption('--language <code>', 'Language, for example en')
98
+ .option('--keyterms <list>', 'Words to listen for, comma-separated (up to 50)')
99
+ .option('--yes', 'Do not ask for confirmation')
100
+ .description('Change how the agent hears callers (speech-to-text)')
101
+ .action((agentId: string, options) => run(async () => {
102
+ const sdk = new VoiceSDK(new IndemnClient());
103
+ await confirmOrExit(`Change the transcriber of agent ${agentId} to ${options.provider}/${options.model} (${options.language})? It applies from the next call.`, Boolean(options.yes));
104
+ await sdk.setAgentTranscriber(agentId, { provider: options.provider, model: options.model, language: options.language, keyterms: options.keyterms ? String(options.keyterms).split(',') : undefined });
105
+ printSuccess(`Transcriber of ${agentId} is now ${options.provider}/${options.model}.`);
106
+ }));
107
+
108
+ voice.command('play')
109
+ .argument('<agent-id>', 'Agent ID')
110
+ .option('--text <text>', 'What to say')
111
+ .requiredOption('-o, --output <file>', 'Audio file to write')
112
+ .description('Save a speech sample in the agent\'s current voice')
113
+ .action((agentId: string, options) => run(async () => {
114
+ const sdk = new VoiceSDK(new IndemnClient());
115
+ const tts = (await sdk.getAgentVoice(agentId) as { tts?: { voice_id?: string; provider?: string; name?: string; language?: string } }).tts;
116
+ if (!tts?.voice_id) throw new CliError(`Agent ${agentId} has no text-to-speech voice set.`);
117
+ const audio = await sdk.playVoice(tts.voice_id, options.text, undefined, { provider: tts.provider, name: tts.name, language: tts.language });
118
+ writeFileSync(options.output, audio.data);
119
+ printSuccess(`Wrote ${options.output}.`);
120
+ }));
121
+
122
+ const numbers = agents.command('numbers').description('Phone numbers of an agent');
123
+ numbers.command('list')
124
+ .argument('[agent-id]', 'Agent ID (omit to list every number the org owns)')
125
+ .option('--json', 'Output as JSON')
126
+ .action((agentId: string | undefined, options) => run(async () => {
127
+ const sdk = new VoiceSDK(new IndemnClient());
128
+ const rows = agentId ? await sdk.agentNumbers(agentId) : await sdk.orgNumbers();
129
+ if (options.json) { printJson(rows); return; }
130
+ console.log(formatTable(['Number', 'Name', 'Attached'], rows.map((n) => [String(n.phoneNumber ?? ''), String(n.friendlyName ?? ''), agentId ? 'yes' : n.is_active ? 'yes' : 'no'])));
131
+ }));
132
+
133
+ numbers.command('attach')
134
+ .argument('<agent-id>', 'Agent ID')
135
+ .argument('<phone-number>', 'A number the org already owns (see `agents numbers list`)')
136
+ .option('--yes', 'Do not ask for confirmation')
137
+ .description('Attach an owned number; never buys one. Its voice settings reset to the defaults')
138
+ .action((agentId: string, phone: string, options) => run(async () => {
139
+ await confirmOrExit(`Attach ${phone} to agent ${agentId}? Calls to it reach this agent, and its voice settings reset to the defaults.`, Boolean(options.yes));
140
+ await new VoiceSDK(new IndemnClient()).attachNumber(agentId, phone);
141
+ printSuccess(`${phone} is attached to ${agentId}.`);
142
+ }));
143
+
144
+ numbers.command('search')
145
+ .option('--area-code <code>', 'Three-digit area code, for example 331')
146
+ .option('--contains <digits>', 'Only numbers containing these digits')
147
+ .option('--country <code>', 'US, CA, GB or AU', 'US')
148
+ .option('--toll-free', 'Toll-free numbers instead of local ones')
149
+ .option('--limit <n>', 'How many to show (default 5)', '5')
150
+ .option('--json', 'Output as JSON')
151
+ .description('Numbers available to buy (nothing is bought)')
152
+ .action((options) => run(async () => {
153
+ const rows = await new VoiceSDK(new IndemnClient()).searchNumbers({ areaCode: options.areaCode, contains: options.contains, country: options.country, tollFree: Boolean(options.tollFree), limit: Number(options.limit) });
154
+ if (options.json) { printJson(rows); return; }
155
+ if (!rows.length) { console.log('No numbers available for that search.'); return; }
156
+ console.log(formatTable(['Number', 'E.164', 'Location', 'Voice / SMS'], rows.map((r) => [
157
+ String(r.friendlyName ?? ''), String(r.phoneNumber ?? ''), [r.locality, r.region].filter(Boolean).join(', '),
158
+ `${r.capabilities?.voice ? 'yes' : 'no'} / ${r.capabilities?.SMS ?? r.capabilities?.sms ? 'yes' : 'no'}`,
159
+ ])));
160
+ console.log(chalk.dim('Buy one with `indemn agents numbers buy <agent-id> <number>`. Buying costs the org money every month.'));
161
+ }));
162
+
163
+ numbers.command('get')
164
+ .argument('<agent-id>', 'Agent ID')
165
+ .description('The agent\'s phone number: location, type and voice settings')
166
+ .action((agentId: string) => run(async () => printJson(await new VoiceSDK(new IndemnClient()).numberDetails(agentId))));
167
+
168
+ numbers.command('buy')
169
+ .argument('<agent-id>', 'Voice agent to attach the number to')
170
+ .argument('<number>', 'A number from `agents numbers search`')
171
+ .option('--country <code>', 'US, CA, GB or AU', 'US')
172
+ .option('--toll-free', 'The number is toll-free')
173
+ .option('--yes', 'Do not ask for confirmation')
174
+ .option('--confirm-purchase', 'Required with --yes: you agree to buy this number')
175
+ .description('Buy an available number and attach it to a voice agent (costs money every month)')
176
+ .action((agentId: string, number: string, options) => run(async () => {
177
+ const phone = toE164(number, String(options.country).toUpperCase());
178
+ if (options.yes && !options.confirmPurchase) throw new CliError('Buying a number costs money: pass --confirm-purchase with --yes, or run without --yes to be asked.', EXIT.NEEDS_INPUT);
179
+ await confirmOrExit(`Buy ${phone} and attach it to agent ${agentId}? The org pays for it every month, and it cannot be undone from the CLI.`, Boolean(options.yes));
180
+ const res = await new VoiceSDK(new IndemnClient()).buyNumber(agentId, number, { country: options.country, tollFree: Boolean(options.tollFree) });
181
+ printSuccess(`Bought ${res.name ?? res.phone_number}${res.locality ? ` (${res.locality}, ${res.region})` : ''} and attached it to ${res.agent}. Its voice settings are the defaults.`);
182
+ }));
183
+
184
+ numbers.command('detach')
185
+ .argument('<agent-id>', 'Agent ID')
186
+ .argument('<phone-number>', 'Number to detach (the org keeps owning it)')
187
+ .option('--yes', 'Do not ask for confirmation')
188
+ .action((agentId: string, phone: string, options) => run(async () => {
189
+ await confirmOrExit(`Detach ${phone} from agent ${agentId}? Calls to it stop reaching the agent.`, Boolean(options.yes));
190
+ await new VoiceSDK(new IndemnClient()).detachNumber(agentId, phone);
191
+ printSuccess(`${phone} detached.`);
192
+ }));
193
+
194
+ agents.command('voice-preview')
195
+ .argument('<agent-id>', 'Agent ID')
196
+ .option('--json', 'Output as JSON')
197
+ .description('A browser link for a test voice call with the agent (marked as test, valid 1 hour)')
198
+ .action((agentId: string, options) => run(async () => {
199
+ const res = await new VoiceSDK(new IndemnClient()).voicePreview(agentId);
200
+ if (options.json) { printJson(res); return; }
201
+ console.log(res.join_url);
202
+ console.log(chalk.dim(`Test call room ${res.room_name}. The link carries a token valid for 1 hour; do not share it.`));
203
+ }));
204
+ }
@@ -0,0 +1,52 @@
1
+ export interface DiffLine {
2
+ op: ' ' | '+' | '-';
3
+ text: string;
4
+ }
5
+
6
+ /** Line diff by longest common subsequence. Very large inputs fall back to "all removed, all added". */
7
+ export function lineDiff(before: string, after: string): DiffLine[] {
8
+ const a = before.split('\n');
9
+ const b = after.split('\n');
10
+ if (a.length * b.length > 4_000_000) {
11
+ return [...a.map((text) => ({ op: '-' as const, text })), ...b.map((text) => ({ op: '+' as const, text }))];
12
+ }
13
+ const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
14
+ for (let i = a.length - 1; i >= 0; i--) {
15
+ for (let j = b.length - 1; j >= 0; j--) {
16
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
17
+ }
18
+ }
19
+ const out: DiffLine[] = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ while (i < a.length && j < b.length) {
23
+ if (a[i] === b[j]) { out.push({ op: ' ', text: a[i] }); i++; j++; }
24
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ op: '-', text: a[i++] });
25
+ else out.push({ op: '+', text: b[j++] });
26
+ }
27
+ while (i < a.length) out.push({ op: '-', text: a[i++] });
28
+ while (j < b.length) out.push({ op: '+', text: b[j++] });
29
+ return out;
30
+ }
31
+
32
+ export function diffStats(diff: DiffLine[]): { added: number; removed: number } {
33
+ return { added: diff.filter((d) => d.op === '+').length, removed: diff.filter((d) => d.op === '-').length };
34
+ }
35
+
36
+ /** Changed lines with `context` unchanged lines around them, as unified-style text. */
37
+ export function formatDiff(diff: DiffLine[], opts: { context?: number; maxLines?: number } = {}): string {
38
+ const context = opts.context ?? 2;
39
+ const keep = new Set<number>();
40
+ diff.forEach((d, i) => {
41
+ if (d.op !== ' ') for (let k = Math.max(0, i - context); k <= Math.min(diff.length - 1, i + context); k++) keep.add(k);
42
+ });
43
+ const lines: string[] = [];
44
+ let last = -1;
45
+ for (const i of [...keep].sort((x, y) => x - y)) {
46
+ if (last >= 0 && i > last + 1) lines.push('…');
47
+ lines.push(`${diff[i].op} ${diff[i].text}`);
48
+ last = i;
49
+ }
50
+ const max = opts.maxLines ?? Infinity;
51
+ return lines.length > max ? [...lines.slice(0, max), `… ${lines.length - max} more line(s)`].join('\n') : lines.join('\n');
52
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import type { Environment } from '../sdk/types.js';
4
+ import { CliError, EXIT, NeedsInputError } from './runtime.js';
5
+
6
+ /** Where `indemn link` pins a project folder to one org and its agents. */
7
+ export const LINK_RELATIVE_PATH = join('.indemn', 'project.json');
8
+
9
+ export interface OrgChoice {
10
+ id: string;
11
+ name?: string;
12
+ }
13
+
14
+ export interface LinkFile {
15
+ org_id: string;
16
+ org_name?: string;
17
+ environment: Environment;
18
+ agents: string[];
19
+ /** Absolute path of the file, for messages. */
20
+ path: string;
21
+ }
22
+
23
+ export type OrgSource = 'flag' | 'env' | 'link' | 'single' | 'asked';
24
+
25
+ export interface ResolvedOrg {
26
+ id: string;
27
+ source: OrgSource;
28
+ link: LinkFile | null;
29
+ }
30
+
31
+ /** Walk up from `startDir` to the filesystem root looking for `.indemn/project.json`. */
32
+ export function findLinkFile(startDir: string = process.cwd()): LinkFile | null {
33
+ let dir = resolve(startDir);
34
+ for (;;) {
35
+ const candidate = join(dir, LINK_RELATIVE_PATH);
36
+ if (existsSync(candidate)) {
37
+ let raw: Partial<LinkFile>;
38
+ try {
39
+ raw = JSON.parse(readFileSync(candidate, 'utf8'));
40
+ } catch {
41
+ throw new CliError(`Cannot read ${candidate}. Fix the file or run \`indemn unlink\` in ${dir}.`);
42
+ }
43
+ if (!raw.org_id || (raw.environment !== 'dev' && raw.environment !== 'production')) {
44
+ throw new CliError(`${candidate} has no org_id or environment. Run \`indemn link\` again in ${dir}.`);
45
+ }
46
+ return {
47
+ org_id: raw.org_id,
48
+ org_name: raw.org_name,
49
+ environment: raw.environment,
50
+ agents: Array.isArray(raw.agents) ? raw.agents.map(String) : [],
51
+ path: candidate,
52
+ };
53
+ }
54
+ const parent = dirname(dir);
55
+ if (parent === dir) return null;
56
+ dir = parent;
57
+ }
58
+ }
59
+
60
+ function label(o: OrgChoice): string {
61
+ return o.name ? `${o.name}` : o.id;
62
+ }
63
+
64
+ /**
65
+ * Decide which org a command acts on. The saved config never decides.
66
+ * Order: the --org flag (or an MCP tool's org_id), INDEMN_ORG_ID from a host,
67
+ * the linked folder, the only org the user belongs to, otherwise ask.
68
+ */
69
+ export async function resolveOrg(input: {
70
+ flag?: string;
71
+ envOrg?: string;
72
+ link: LinkFile | null;
73
+ environment: Environment;
74
+ memberships: () => Promise<OrgChoice[]>;
75
+ ask?: (choices: OrgChoice[]) => Promise<string | undefined>;
76
+ }): Promise<ResolvedOrg> {
77
+ const { link } = input;
78
+ if (link && link.environment !== input.environment) {
79
+ throw new CliError(
80
+ `This folder is linked to ${link.environment} (${link.path}) but you are logged in to ${input.environment}. ` +
81
+ 'Log in to the matching platform or run `indemn unlink`.',
82
+ );
83
+ }
84
+
85
+ const explicit = input.flag || input.envOrg;
86
+ if (explicit) {
87
+ if (link && link.org_id !== explicit) {
88
+ throw new CliError(
89
+ `This folder is linked to org ${link.org_name ?? link.org_id} (${link.path}); ` +
90
+ `refusing to act on ${explicit}. Remove --org, or run \`indemn unlink\` first.`,
91
+ EXIT.NEEDS_INPUT,
92
+ );
93
+ }
94
+ return { id: explicit, source: input.flag ? 'flag' : 'env', link };
95
+ }
96
+
97
+ if (link) return { id: link.org_id, source: 'link', link };
98
+
99
+ const orgs = await input.memberships();
100
+ if (orgs.length === 0) {
101
+ throw new CliError('You are not a member of any organization on this platform. Ask an admin to add you.');
102
+ }
103
+ if (orgs.length === 1) return { id: orgs[0].id, source: 'single', link: null };
104
+
105
+ if (input.ask) {
106
+ const chosen = await input.ask(orgs);
107
+ if (chosen && orgs.some((o) => o.id === chosen)) return { id: chosen, source: 'asked', link: null };
108
+ }
109
+ throw new NeedsInputError(
110
+ `You belong to ${orgs.length} organizations. Choose one with --org <id>, or pin this folder with \`indemn link --org <id>\`.`,
111
+ orgs.map((o) => ({ id: o.id, label: label(o) })),
112
+ );
113
+ }