indusagi-coding-agent 0.2.8 → 0.2.9

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 (219) hide show
  1. package/package.json +2 -1
  2. package/src/_decls/entry.ts +18 -0
  3. package/src/_decls/guardrails.ts +35 -0
  4. package/src/_decls/index.ts +26 -0
  5. package/src/addons/contract.ts +236 -0
  6. package/src/addons/dispatch/event-dispatcher.ts +164 -0
  7. package/src/addons/dispatch/index.ts +25 -0
  8. package/src/addons/dispatch/tool-interceptor.ts +208 -0
  9. package/src/addons/host.ts +225 -0
  10. package/src/addons/index.ts +112 -0
  11. package/src/addons/manifest.ts +158 -0
  12. package/src/addons/sandbox.ts +170 -0
  13. package/src/addons/surface.ts +78 -0
  14. package/src/boot/auth-vault.ts +195 -0
  15. package/src/boot/boot.ts +138 -0
  16. package/src/boot/contract.ts +238 -0
  17. package/src/boot/heap.ts +59 -0
  18. package/src/boot/index.ts +28 -0
  19. package/src/boot/invocation.ts +93 -0
  20. package/src/boot/runners/addon-wiring.ts +153 -0
  21. package/src/boot/runners/checkpoint.ts +169 -0
  22. package/src/boot/runners/delegate-runner.ts +294 -0
  23. package/src/boot/runners/index.ts +13 -0
  24. package/src/boot/runners/link-runner.ts +45 -0
  25. package/src/boot/runners/memdir.ts +168 -0
  26. package/src/boot/runners/oneshot-runner.ts +58 -0
  27. package/src/boot/runners/read-state.ts +90 -0
  28. package/src/boot/runners/registry.ts +42 -0
  29. package/src/boot/runners/repl-runner.ts +143 -0
  30. package/src/boot/runners/server-mode.ts +121 -0
  31. package/src/boot/runners/session.ts +641 -0
  32. package/src/boot/server-token.ts +148 -0
  33. package/src/boot/stages.ts +167 -0
  34. package/src/boot/upgrade/apply.ts +94 -0
  35. package/src/boot/upgrade/index.ts +13 -0
  36. package/src/boot/upgrade/upgrades.ts +289 -0
  37. package/src/briefing/compose.ts +150 -0
  38. package/src/briefing/context-docs.ts +19 -0
  39. package/src/briefing/contract.ts +717 -0
  40. package/src/briefing/index.ts +31 -0
  41. package/src/briefing/macros.ts +97 -0
  42. package/src/briefing/skills.ts +47 -0
  43. package/src/capability-deck/bridge-ledger/index.ts +27 -0
  44. package/src/capability-deck/bridge-ledger/key.ts +67 -0
  45. package/src/capability-deck/bridge-ledger/ledger.ts +131 -0
  46. package/src/capability-deck/bridge-ledger/network.ts +117 -0
  47. package/src/capability-deck/builtin-bridge.ts +312 -0
  48. package/src/capability-deck/cards/bg-process-card.ts +335 -0
  49. package/src/capability-deck/cards/index.ts +115 -0
  50. package/src/capability-deck/cards/memory-card.ts +146 -0
  51. package/src/capability-deck/cards/plan-file.ts +97 -0
  52. package/src/capability-deck/cards/plan-tools.ts +185 -0
  53. package/src/capability-deck/cards/saas-card.ts +183 -0
  54. package/src/capability-deck/cards/task-card.ts +207 -0
  55. package/src/capability-deck/cards/todo-card.ts +168 -0
  56. package/src/capability-deck/cards/workflow-card.ts +247 -0
  57. package/src/capability-deck/contract.ts +388 -0
  58. package/src/capability-deck/index.ts +48 -0
  59. package/src/capability-deck/manifest.ts +109 -0
  60. package/src/capability-deck/provision.ts +169 -0
  61. package/src/channels/contract.ts +191 -0
  62. package/src/channels/framer.ts +50 -0
  63. package/src/channels/index.ts +101 -0
  64. package/src/channels/link/dialog.ts +129 -0
  65. package/src/channels/link/driver.ts +190 -0
  66. package/src/channels/link/index.ts +34 -0
  67. package/src/channels/link/server.ts +134 -0
  68. package/src/channels/oneshot.ts +90 -0
  69. package/src/channels/ops.ts +65 -0
  70. package/src/channels/session-ops.ts +81 -0
  71. package/src/conductor/bash-guard.ts +599 -0
  72. package/src/conductor/catalog/catalog.ts +116 -0
  73. package/src/conductor/catalog/index.ts +8 -0
  74. package/src/conductor/catalog/matcher.ts +134 -0
  75. package/src/conductor/conductor.ts +234 -0
  76. package/src/conductor/contract.ts +842 -0
  77. package/src/conductor/diagnostics.ts +227 -0
  78. package/src/conductor/index.ts +33 -0
  79. package/src/conductor/permissions.ts +588 -0
  80. package/src/conductor/quota-error.ts +49 -0
  81. package/src/conductor/signal-hub/hub.ts +46 -0
  82. package/src/conductor/signal-hub/index.ts +2 -0
  83. package/src/conductor/signal-hub/translate.test.ts +81 -0
  84. package/src/conductor/signal-hub/translate.ts +74 -0
  85. package/src/conductor/skill-parse/index.ts +2 -0
  86. package/src/conductor/skill-parse/parse.ts +108 -0
  87. package/src/conductor/transcript-store/index.ts +22 -0
  88. package/src/conductor/transcript-store/serialize.ts +116 -0
  89. package/src/conductor/transcript-store/store.ts +205 -0
  90. package/src/console/auth-status.ts +56 -0
  91. package/src/console/components/AgentsView.ts +165 -0
  92. package/src/console/components/BackgroundAgents.ts +155 -0
  93. package/src/console/components/Banner.ts +334 -0
  94. package/src/console/components/Composer.ts +94 -0
  95. package/src/console/components/StatusBar.ts +49 -0
  96. package/src/console/components/TerminalConsole.ts +1090 -0
  97. package/src/console/components/WorkingIndicator.ts +98 -0
  98. package/src/console/components/banner-sweep.ts +24 -0
  99. package/src/console/components/welcome.ts +74 -0
  100. package/src/console/contract.ts +630 -0
  101. package/src/console/index.ts +34 -0
  102. package/src/console/input/complete.ts +127 -0
  103. package/src/console/input/dir-reader.ts +34 -0
  104. package/src/console/input/index.ts +23 -0
  105. package/src/console/input/keymap.ts +159 -0
  106. package/src/console/input/paste.ts +104 -0
  107. package/src/console/mount.ts +56 -0
  108. package/src/console/overlays/approval-queue.ts +57 -0
  109. package/src/console/overlays/approval.ts +130 -0
  110. package/src/console/overlays/auth.ts +342 -0
  111. package/src/console/overlays/boards.ts +308 -0
  112. package/src/console/overlays/host.ts +36 -0
  113. package/src/console/overlays/index.ts +26 -0
  114. package/src/console/overlays/pickers.ts +258 -0
  115. package/src/console/overlays/sessions.ts +190 -0
  116. package/src/console/reducer.ts +182 -0
  117. package/src/console/slash/builtins.ts +81 -0
  118. package/src/console/slash/commands/dynamic.ts +83 -0
  119. package/src/console/slash/commands/integrations.ts +695 -0
  120. package/src/console/slash/commands/shared.ts +75 -0
  121. package/src/console/slash/commands/transcript.ts +263 -0
  122. package/src/console/slash/commands/workbench.ts +246 -0
  123. package/src/console/slash/index.ts +15 -0
  124. package/src/console/slash/registry.ts +70 -0
  125. package/src/console/slash/resolve.ts +63 -0
  126. package/src/console/startup.ts +209 -0
  127. package/src/console/theme/adapter.ts +45 -0
  128. package/src/console/theme/index.ts +7 -0
  129. package/src/console/theme/palette.ts +68 -0
  130. package/src/console/theme/resolve.ts +39 -0
  131. package/src/console/theme/tokens.ts +71 -0
  132. package/src/entry.ts +55 -0
  133. package/src/guardrails.ts +37 -0
  134. package/src/index.ts +18 -0
  135. package/src/insight/channel.ts +88 -0
  136. package/src/insight/contract.ts +185 -0
  137. package/src/insight/index.ts +110 -0
  138. package/src/insight/recorder.ts +213 -0
  139. package/src/insight/redaction.ts +157 -0
  140. package/src/insight/replay.ts +158 -0
  141. package/src/insight/sampling.ts +70 -0
  142. package/src/insight/serialize.ts +50 -0
  143. package/src/insight/sinks/console.ts +64 -0
  144. package/src/insight/sinks/file.ts +40 -0
  145. package/src/insight/sinks/index.ts +24 -0
  146. package/src/insight/sinks/stream.ts +54 -0
  147. package/src/integrations/sarvam/attach.ts +239 -0
  148. package/src/integrations/sarvam/config.ts +156 -0
  149. package/src/integrations/sarvam/index.ts +25 -0
  150. package/src/integrations/sarvam/sarvam.test.ts +60 -0
  151. package/src/integrations/sarvam/types.ts +27 -0
  152. package/src/integrations/zoho/attach.ts +342 -0
  153. package/src/integrations/zoho/config.ts +125 -0
  154. package/src/integrations/zoho/index.ts +27 -0
  155. package/src/integrations/zoho/types.ts +21 -0
  156. package/src/integrations/zoho/zoho.test.ts +50 -0
  157. package/src/kit/clipboard-image.ts +107 -0
  158. package/src/kit/external-editor.ts +48 -0
  159. package/src/kit/image.ts +59 -0
  160. package/src/kit/index.ts +51 -0
  161. package/src/kit/shell.ts +19 -0
  162. package/src/kit/tool-fetch.ts +85 -0
  163. package/src/launch/catalog.ts +148 -0
  164. package/src/launch/contract.ts +187 -0
  165. package/src/launch/credentials.ts +625 -0
  166. package/src/launch/index.ts +98 -0
  167. package/src/launch/invocation/attachments.ts +179 -0
  168. package/src/launch/invocation/flags.ts +196 -0
  169. package/src/launch/invocation/index.ts +25 -0
  170. package/src/launch/invocation/read.ts +260 -0
  171. package/src/launch/invocation/usage.ts +67 -0
  172. package/src/launch/login.ts +324 -0
  173. package/src/launch/oauth.test.ts +18 -0
  174. package/src/launch/oauth.ts +203 -0
  175. package/src/launch/packages.ts +194 -0
  176. package/src/launch/pickers.ts +189 -0
  177. package/src/runtime-bridge/bridges/_drive.ts +96 -0
  178. package/src/runtime-bridge/bridges/builtins.ts +68 -0
  179. package/src/runtime-bridge/bridges/claude-cli.ts +123 -0
  180. package/src/runtime-bridge/bridges/codex-cli.ts +142 -0
  181. package/src/runtime-bridge/bridges/index.ts +33 -0
  182. package/src/runtime-bridge/bridges/indusagi-cli.ts +155 -0
  183. package/src/runtime-bridge/broker.ts +227 -0
  184. package/src/runtime-bridge/contract.ts +122 -0
  185. package/src/runtime-bridge/index.ts +79 -0
  186. package/src/runtime-bridge/sink.ts +180 -0
  187. package/src/sessions/contract.ts +81 -0
  188. package/src/sessions/index.ts +13 -0
  189. package/src/sessions/library.ts +229 -0
  190. package/src/settings/contract.ts +114 -0
  191. package/src/settings/index.ts +32 -0
  192. package/src/settings/manager.ts +117 -0
  193. package/src/transcript-export/index.ts +45 -0
  194. package/src/transcript-export/publish.ts +260 -0
  195. package/src/transcript-export/sgr.ts +315 -0
  196. package/src/transcript-export/template.ts +272 -0
  197. package/src/transcript-export/theme-bridge.ts +150 -0
  198. package/src/window-budget/budget/estimate.ts +135 -0
  199. package/src/window-budget/budget/gate.ts +33 -0
  200. package/src/window-budget/budget/index.ts +16 -0
  201. package/src/window-budget/budget/slice.ts +56 -0
  202. package/src/window-budget/condenser.ts +58 -0
  203. package/src/window-budget/contract.ts +184 -0
  204. package/src/window-budget/index.ts +19 -0
  205. package/src/window-budget/microcompact.ts +95 -0
  206. package/src/window-budget/rehydrate.ts +136 -0
  207. package/src/window-budget/summarize/condense.ts +103 -0
  208. package/src/window-budget/summarize/index.ts +14 -0
  209. package/src/window-budget/summarize/prompt.ts +149 -0
  210. package/src/workflow-engine/agent-runner.ts +181 -0
  211. package/src/workflow-engine/display.ts +224 -0
  212. package/src/workflow-engine/engine.ts +294 -0
  213. package/src/workflow-engine/index.ts +23 -0
  214. package/src/workflow-engine/parse.ts +172 -0
  215. package/src/workflow-engine/structured-output.ts +35 -0
  216. package/src/workspace/brand.ts +29 -0
  217. package/src/workspace/index.ts +18 -0
  218. package/src/workspace/locator.ts +103 -0
  219. package/src/workspace/runtime-detect.ts +64 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The invocation reader — a generic, table-driven argv parser over
3
+ * {@link FLAG_SPECS}.
4
+ */
5
+ import {
6
+ type FlagValue,
7
+ type Invocation,
8
+ isThinkingEffort,
9
+ isToolName,
10
+ } from "../contract.js";
11
+ import { FLAG_SPECS, type GroupedFlagSpec } from "./flags.js";
12
+
13
+ interface ParseState {
14
+ flags: Record<string, FlagValue>;
15
+ positionals: string[];
16
+ prompt?: string;
17
+ fileRefs: string[];
18
+ }
19
+
20
+ function isFlagToken(token: string): boolean {
21
+ return token.length > 1 && token.startsWith("-");
22
+ }
23
+
24
+ function isLongToken(token: string): boolean {
25
+ return token.startsWith("--");
26
+ }
27
+
28
+ function isShortCluster(token: string): boolean {
29
+ return token.length > 1 && token[0] === "-" && token[1] !== "-";
30
+ }
31
+
32
+ function flagKey(name: string): string {
33
+ return name.replace(/^-+/, "");
34
+ }
35
+
36
+ function buildTokenIndex(): Map<string, GroupedFlagSpec> {
37
+ const index = new Map<string, GroupedFlagSpec>();
38
+ for (const spec of FLAG_SPECS) {
39
+ index.set(spec.name, spec);
40
+ for (const alias of spec.aliases ?? []) {
41
+ index.set(alias, spec);
42
+ }
43
+ }
44
+ return index;
45
+ }
46
+
47
+ const TOKEN_INDEX = buildTokenIndex();
48
+
49
+ function buildShortBooleanSet(): Set<string> {
50
+ const set = new Set<string>();
51
+ for (const spec of FLAG_SPECS) {
52
+ if (spec.kind !== "boolean") continue;
53
+ for (const alias of spec.aliases ?? []) {
54
+ if (alias.length === 2 && alias[0] === "-" && alias[1] !== "-") {
55
+ set.add(alias[1]);
56
+ }
57
+ }
58
+ }
59
+ return set;
60
+ }
61
+
62
+ const SHORT_BOOLEANS = buildShortBooleanSet();
63
+
64
+ function splitInline(token: string): { name: string; inline?: string } {
65
+ if (isLongToken(token)) {
66
+ const eq = token.indexOf("=");
67
+ if (eq !== -1) {
68
+ return { name: token.slice(0, eq), inline: token.slice(eq + 1) };
69
+ }
70
+ }
71
+ return { name: token };
72
+ }
73
+
74
+ function splitList(value: string): string[] {
75
+ return value
76
+ .split(",")
77
+ .map((part) => part.trim())
78
+ .filter((part) => part.length > 0);
79
+ }
80
+
81
+ function coerceScalar(kind: "string" | "number", raw: string): string | number {
82
+ if (kind === "number") return Number(raw);
83
+ return raw;
84
+ }
85
+
86
+ function pushPositional(state: ParseState, token: string): void {
87
+ if (state.prompt === undefined) {
88
+ state.prompt = token;
89
+ } else {
90
+ state.positionals.push(token);
91
+ }
92
+ }
93
+
94
+ function accumulateList(state: ParseState, key: string, items: string[]): void {
95
+ const prior = state.flags[key];
96
+ const base = Array.isArray(prior) ? prior : [];
97
+ state.flags[key] = [...base, ...items];
98
+ }
99
+
100
+ function applyFlag(
101
+ spec: GroupedFlagSpec,
102
+ inline: string | undefined,
103
+ argv: readonly string[],
104
+ i: number,
105
+ state: ParseState,
106
+ ): number {
107
+ const key = flagKey(spec.name);
108
+ if (spec.kind === "boolean") {
109
+ state.flags[key] = true;
110
+ return i;
111
+ }
112
+ let value: string | undefined = inline;
113
+ let consumed = i;
114
+ if (value === undefined) {
115
+ const next = argv[i + 1];
116
+ if (next !== undefined && !isFlagToken(next)) {
117
+ value = next;
118
+ consumed = i + 1;
119
+ }
120
+ }
121
+ const raw = value ?? "";
122
+ if (spec.kind === "list") {
123
+ accumulateList(state, key, splitList(raw));
124
+ } else {
125
+ state.flags[key] = coerceScalar(spec.kind, raw);
126
+ }
127
+ return consumed;
128
+ }
129
+
130
+ function applyShortCluster(token: string, state: ParseState): boolean {
131
+ const letters = token.slice(1).split("");
132
+ if (!letters.every((letter) => SHORT_BOOLEANS.has(letter))) {
133
+ return false;
134
+ }
135
+ for (const letter of letters) {
136
+ const spec = TOKEN_INDEX.get(`-${letter}`);
137
+ if (spec) state.flags[flagKey(spec.name)] = true;
138
+ }
139
+ return true;
140
+ }
141
+
142
+ function readString(flags: Record<string, FlagValue>, key: string): string | undefined {
143
+ const value = flags[key];
144
+ return typeof value === "string" && value.length > 0 ? value : undefined;
145
+ }
146
+
147
+ function readList(flags: Record<string, FlagValue>, key: string): string[] | undefined {
148
+ const value = flags[key];
149
+ return Array.isArray(value) ? value : undefined;
150
+ }
151
+
152
+ function readBool(flags: Record<string, FlagValue>, key: string): boolean {
153
+ return flags[key] === true;
154
+ }
155
+
156
+ function deriveMode(flags: Record<string, FlagValue>): Invocation["mode"] {
157
+ if (readBool(flags, "json")) return "rpc";
158
+ if (readBool(flags, "print") && !readBool(flags, "interactive")) return "json";
159
+ return "text";
160
+ }
161
+
162
+ function resolveThinking(flags: Record<string, FlagValue>): Invocation["thinking"] {
163
+ const raw = readString(flags, "thinking");
164
+ return raw !== undefined && isThinkingEffort(raw) ? raw : undefined;
165
+ }
166
+
167
+ function resolveTools(flags: Record<string, FlagValue>): Invocation["tools"] {
168
+ const raw = readList(flags, "tools");
169
+ if (raw === undefined) return undefined;
170
+ return raw.filter((name): name is Invocation["tools"] extends Array<infer T> ? T : never =>
171
+ isToolName(name),
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Read a sliced `argv` into the fully-parsed {@link Invocation}.
177
+ */
178
+ export function readInvocation(argv: readonly string[]): Invocation {
179
+ const state: ParseState = { flags: {}, positionals: [], fileRefs: [] };
180
+ let optionsTerminated = false;
181
+ for (let i = 0; i < argv.length; i += 1) {
182
+ const token = argv[i];
183
+ if (optionsTerminated) {
184
+ pushPositional(state, token);
185
+ continue;
186
+ }
187
+ if (token === "--") {
188
+ optionsTerminated = true;
189
+ continue;
190
+ }
191
+ if (token.startsWith("@") && token.length > 1) {
192
+ state.fileRefs.push(token.slice(1));
193
+ continue;
194
+ }
195
+ if (!isFlagToken(token)) {
196
+ pushPositional(state, token);
197
+ continue;
198
+ }
199
+ const { name, inline } = splitInline(token);
200
+ const spec = TOKEN_INDEX.get(name);
201
+ if (spec) {
202
+ i = applyFlag(spec, inline, argv, i, state);
203
+ continue;
204
+ }
205
+ if (isShortCluster(token) && applyShortCluster(token, state)) {
206
+ continue;
207
+ }
208
+ state.flags[flagKey(name)] = inline ?? true;
209
+ }
210
+ const { flags } = state;
211
+ const mode = deriveMode(flags);
212
+ const model = readString(flags, "model");
213
+ const fallbackModel = readString(flags, "fallback-model");
214
+ const account = readString(flags, "account");
215
+ const cwd = readString(flags, "cwd");
216
+ const system = readString(flags, "system");
217
+ const appendSystem = readString(flags, "append-system");
218
+ const thinking = resolveThinking(flags);
219
+ const tools = resolveTools(flags);
220
+ return {
221
+ mode,
222
+ ...(state.prompt !== undefined ? { prompt: state.prompt } : {}),
223
+ flags,
224
+ positionals: state.positionals,
225
+ ...(model !== undefined ? { model } : {}),
226
+ ...(fallbackModel !== undefined ? { fallbackModel } : {}),
227
+ ...(account !== undefined ? { account } : {}),
228
+ ...(cwd !== undefined ? { cwd } : {}),
229
+ ...(system !== undefined ? { system } : {}),
230
+ ...(appendSystem !== undefined ? { appendSystem } : {}),
231
+ ...(thinking !== undefined ? { thinking } : {}),
232
+ ...(tools !== undefined ? { tools } : {}),
233
+ noTools: readBool(flags, "no-tools"),
234
+ mcp: readList(flags, "mcp") ?? [],
235
+ print: readBool(flags, "print"),
236
+ interactive: readBool(flags, "interactive"),
237
+ help: readBool(flags, "help"),
238
+ version: readBool(flags, "version"),
239
+ };
240
+ }
241
+
242
+ /**
243
+ * The `@file` references collected from an argv, exposed for the attachment
244
+ * gatherer.
245
+ */
246
+ export function readFileReferences(argv: readonly string[]): string[] {
247
+ const refs: string[] = [];
248
+ let optionsTerminated = false;
249
+ for (const token of argv) {
250
+ if (optionsTerminated) continue;
251
+ if (token === "--") {
252
+ optionsTerminated = true;
253
+ continue;
254
+ }
255
+ if (token.startsWith("@") && token.length > 1) {
256
+ refs.push(token.slice(1));
257
+ }
258
+ }
259
+ return refs;
260
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The usage renderer — generated entirely from {@link FLAG_SPECS}.
3
+ */
4
+ import { FLAG_GROUPS, FLAG_SPECS, type GroupedFlagSpec } from "./flags.js";
5
+
6
+ const INDENT = " ";
7
+ const SIGNATURE_COLUMN = 28;
8
+
9
+ function placeholder(spec: GroupedFlagSpec): string {
10
+ switch (spec.kind) {
11
+ case "boolean":
12
+ return "";
13
+ case "number":
14
+ return " <n>";
15
+ case "list":
16
+ return " <a,b>";
17
+ case "string":
18
+ default:
19
+ return " <value>";
20
+ }
21
+ }
22
+
23
+ function signature(spec: GroupedFlagSpec): string {
24
+ const aliases = spec.aliases ?? [];
25
+ const aliasPart = aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
26
+ return `${spec.name}${aliasPart}${placeholder(spec)}`;
27
+ }
28
+
29
+ function optionLine(spec: GroupedFlagSpec): string {
30
+ const sig = signature(spec);
31
+ const pad = sig.length < SIGNATURE_COLUMN ? " ".repeat(SIGNATURE_COLUMN - sig.length) : " ";
32
+ return `${INDENT}${sig}${pad}${spec.describe}`;
33
+ }
34
+
35
+ function rowsForGroup(group: GroupedFlagSpec["group"]): GroupedFlagSpec[] {
36
+ return FLAG_SPECS.filter((spec) => spec.group === group);
37
+ }
38
+
39
+ /**
40
+ * Render the full usage banner.
41
+ */
42
+ export function renderUsage(): string {
43
+ const lines: string[] = [];
44
+ lines.push("Usage: indusagi [options] [prompt] [@file ...]");
45
+ lines.push("");
46
+ lines.push("A terminal-first AI coding agent.");
47
+ for (const group of FLAG_GROUPS) {
48
+ const rows = rowsForGroup(group.id);
49
+ if (rows.length === 0) continue;
50
+ lines.push("");
51
+ lines.push(`${group.title}:`);
52
+ for (const spec of rows) {
53
+ lines.push(optionLine(spec));
54
+ }
55
+ }
56
+ lines.push("");
57
+ lines.push("Arguments:");
58
+ lines.push(
59
+ `${INDENT}@file`.padEnd(SIGNATURE_COLUMN + INDENT.length) +
60
+ "Attach a text or image file to the first message.",
61
+ );
62
+ lines.push(
63
+ `${INDENT}--`.padEnd(SIGNATURE_COLUMN + INDENT.length) +
64
+ "Stop parsing options; treat every later token as a positional.",
65
+ );
66
+ return lines.join("\n");
67
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Login command — `runLogin`.
3
+ *
4
+ * The top-level `login` / `logout` / `whoami` surface that authenticates the CLI
5
+ * against the indus-server so a user with **no local provider key** can still run
6
+ * model turns through the server gateway.
7
+ */
8
+ import { createInterface } from "node:readline/promises";
9
+ import chalk from "chalk";
10
+ import type { OAuthLoginCallbacks } from "indusagi/ai";
11
+ import type { BootContext } from "../boot/contract.js";
12
+ import {
13
+ TOKEN_FILE,
14
+ clearServerToken,
15
+ getServerToken,
16
+ INDUS_SERVER_URL,
17
+ isServerTokenExpired,
18
+ storeServerToken,
19
+ } from "../boot/server-token.js";
20
+
21
+ export const SERVER_TIER_PROVIDER_ID = "indus-server";
22
+
23
+ const INDUS_CLIENT_ID = process.env.INDUS_CLIENT_ID ?? "indus-cli";
24
+ const USER_AGENT = "indus CLI";
25
+ const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
26
+ const DEVICE_SCOPE = "openid profile email";
27
+ const EXIT_OK = 0;
28
+ const EXIT_FAILURE = 1;
29
+
30
+ class LoginError extends Error {}
31
+
32
+ function out(line: string = ""): void {
33
+ process.stdout.write(`${line}\n`);
34
+ }
35
+
36
+ function err(line: string): void {
37
+ process.stderr.write(`${line}\n`);
38
+ }
39
+
40
+ function sleep(ms: number): Promise<void> {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+
44
+ interface DeviceCodeResponse {
45
+ device_code?: string;
46
+ user_code?: string;
47
+ verification_uri?: string;
48
+ verification_uri_complete?: string;
49
+ expires_in?: number;
50
+ interval?: number;
51
+ error?: string;
52
+ error_description?: string;
53
+ message?: string;
54
+ }
55
+
56
+ async function postJson(
57
+ url: string,
58
+ body: Record<string, unknown>,
59
+ ): Promise<{ status: number; data: DeviceCodeResponse }> {
60
+ const res = await fetch(url, {
61
+ method: "POST",
62
+ headers: {
63
+ "content-type": "application/json",
64
+ accept: "application/json",
65
+ "user-agent": USER_AGENT,
66
+ },
67
+ body: JSON.stringify(body),
68
+ });
69
+ const data = (await res.json().catch(() => ({}))) as DeviceCodeResponse;
70
+ return { status: res.status, data };
71
+ }
72
+
73
+ interface LoginArgs {
74
+ serverUrl: string;
75
+ clientId: string;
76
+ }
77
+
78
+ function parseLoginArgs(tail: string[]): LoginArgs {
79
+ let serverUrl = INDUS_SERVER_URL;
80
+ let clientId = INDUS_CLIENT_ID;
81
+ for (let i = 0; i < tail.length; i++) {
82
+ const token = tail[i];
83
+ const eq = token.indexOf("=");
84
+ const name = eq >= 0 ? token.slice(0, eq) : token;
85
+ const inline = eq >= 0 ? token.slice(eq + 1) : undefined;
86
+ const take = (): string | undefined =>
87
+ inline !== undefined ? inline : tail[++i];
88
+ if (name === "--server-url") {
89
+ const value = take();
90
+ if (value) serverUrl = value;
91
+ } else if (name === "--client-id") {
92
+ const value = take();
93
+ if (value) clientId = value;
94
+ }
95
+ }
96
+ return { serverUrl: serverUrl.replace(/\/+$/, ""), clientId };
97
+ }
98
+
99
+ async function confirm(prompt: string, defaultValue: boolean): Promise<boolean> {
100
+ if (!process.stdin.isTTY) return defaultValue;
101
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
102
+ try {
103
+ const hint = defaultValue ? "Y/n" : "y/N";
104
+ const answer = (await rl.question(`${prompt} [${hint}] `)).trim().toLowerCase();
105
+ if (answer === "") return defaultValue;
106
+ return answer === "y" || answer === "yes";
107
+ } finally {
108
+ rl.close();
109
+ }
110
+ }
111
+
112
+ async function requestDeviceCode(
113
+ serverUrl: string,
114
+ clientId: string,
115
+ ): Promise<DeviceCodeResponse> {
116
+ const { status, data } = await postJson(`${serverUrl}/api/auth/device/code`, {
117
+ client_id: clientId,
118
+ scope: DEVICE_SCOPE,
119
+ });
120
+ if (status >= 400 || typeof data.device_code !== "string") {
121
+ throw new LoginError(
122
+ `Authorization request failed: ${data.error_description ?? data.error ?? data.message ?? `HTTP ${status}`}`,
123
+ );
124
+ }
125
+ return data;
126
+ }
127
+
128
+ async function pollForToken(
129
+ serverUrl: string,
130
+ clientId: string,
131
+ device: DeviceCodeResponse,
132
+ ): Promise<DeviceCodeResponse> {
133
+ let intervalMs = Math.max(1, device.interval ?? 5) * 1000;
134
+ const deadline = Date.now() + (device.expires_in ?? 0) * 1000;
135
+ while (Date.now() < deadline) {
136
+ await sleep(intervalMs);
137
+ const { data } = await postJson(`${serverUrl}/api/auth/device/token`, {
138
+ grant_type: DEVICE_GRANT_TYPE,
139
+ device_code: device.device_code,
140
+ client_id: clientId,
141
+ });
142
+ if (typeof data.access_token === "string") {
143
+ return data;
144
+ }
145
+ const code = data.error;
146
+ switch (code) {
147
+ case "authorization_pending":
148
+ case undefined:
149
+ break;
150
+ case "slow_down":
151
+ intervalMs += 5000;
152
+ break;
153
+ case "access_denied":
154
+ throw new LoginError("Authorization was denied.");
155
+ case "expired_token":
156
+ throw new LoginError("Device code expired. Run `indus login` again.");
157
+ default:
158
+ throw new LoginError(
159
+ `Authorization error: ${data.error_description ?? code}`,
160
+ );
161
+ }
162
+ }
163
+ throw new LoginError("Timed out waiting for authorization. Run `indus login` again.");
164
+ }
165
+
166
+ /**
167
+ * Drive the indus-server device-authorization flow through the framework's
168
+ * generic {@link OAuthLoginCallbacks} shape.
169
+ */
170
+ export async function runServerTierLogin(
171
+ callbacks: OAuthLoginCallbacks,
172
+ serverUrl: string = INDUS_SERVER_URL,
173
+ clientId: string = INDUS_CLIENT_ID,
174
+ ): Promise<void> {
175
+ const device = await requestDeviceCode(serverUrl, clientId);
176
+ callbacks.onAuth({
177
+ url: device.verification_uri_complete ?? device.verification_uri ?? "",
178
+ instructions: `Enter code ${device.user_code} to authorize this device.`,
179
+ });
180
+ callbacks.onProgress?.("Waiting for authorization...");
181
+ const token = await pollForToken(serverUrl, clientId, device);
182
+ await storeServerToken(token);
183
+ callbacks.onProgress?.("Server sign-in complete.");
184
+ }
185
+
186
+ async function doLogin(args: LoginArgs): Promise<number> {
187
+ const { serverUrl, clientId } = args;
188
+ out();
189
+ out(chalk.bold.cyan(" indus login"));
190
+ out(chalk.dim(` ${serverUrl}`));
191
+ out();
192
+ const existing = await getServerToken();
193
+ if (existing && !isServerTokenExpired(existing)) {
194
+ const again = await confirm(
195
+ chalk.yellow("Already authenticated. Log in again?"),
196
+ false,
197
+ );
198
+ if (!again) {
199
+ out(chalk.dim(" Login cancelled \u2014 existing session preserved."));
200
+ return EXIT_OK;
201
+ }
202
+ }
203
+ try {
204
+ out(chalk.dim(" Requesting device authorization..."));
205
+ const device = await requestDeviceCode(serverUrl, clientId);
206
+ const url = device.verification_uri_complete ?? device.verification_uri ?? "";
207
+ out();
208
+ out(` ${chalk.bold("Open this URL to authorize:")}`);
209
+ out(` ${chalk.green.underline(url)}`);
210
+ out();
211
+ out(` ${chalk.bold("Verification code:")} ${chalk.bold.cyan(device.user_code ?? "")}`);
212
+ out(
213
+ chalk.dim(
214
+ ` Expires in ${Math.max(1, Math.floor((device.expires_in ?? 0) / 60))} min \xB7 polling every ${Math.max(
215
+ 1,
216
+ device.interval ?? 5,
217
+ )}s`,
218
+ ),
219
+ );
220
+ out();
221
+ out(chalk.dim(" Waiting for authorization..."));
222
+ const token = await pollForToken(serverUrl, clientId, device);
223
+ const saved = await storeServerToken(token);
224
+ out();
225
+ out(chalk.green.bold(" Authentication successful."));
226
+ if (saved) {
227
+ out(chalk.dim(` Token stored at ${TOKEN_FILE}`));
228
+ } else {
229
+ out(
230
+ chalk.yellow(
231
+ " Warning: could not write the token to disk \u2014 you may need to log in again.",
232
+ ),
233
+ );
234
+ }
235
+ out();
236
+ return EXIT_OK;
237
+ } catch (error) {
238
+ const message =
239
+ error instanceof LoginError
240
+ ? error.message
241
+ : error instanceof Error
242
+ ? error.message
243
+ : String(error);
244
+ err("");
245
+ err(chalk.red(` Login failed: ${message}`));
246
+ err("");
247
+ return EXIT_FAILURE;
248
+ }
249
+ }
250
+
251
+ async function doLogout(): Promise<number> {
252
+ const cleared = await clearServerToken();
253
+ if (cleared) {
254
+ out(chalk.green(" Logged out \u2014 server session token removed."));
255
+ } else {
256
+ out(chalk.dim(" No stored session token to remove."));
257
+ }
258
+ return EXIT_OK;
259
+ }
260
+
261
+ async function doWhoami(args: LoginArgs): Promise<number> {
262
+ const token = await getServerToken();
263
+ if (!token || isServerTokenExpired(token)) {
264
+ err(chalk.yellow(" Not logged in. Run `indus login` first."));
265
+ return EXIT_FAILURE;
266
+ }
267
+ try {
268
+ const res = await fetch(`${args.serverUrl}/api/user/me`, {
269
+ method: "GET",
270
+ headers: {
271
+ authorization: `Bearer ${token.access_token}`,
272
+ accept: "application/json",
273
+ "user-agent": USER_AGENT,
274
+ },
275
+ });
276
+ if (res.status === 401) {
277
+ err(chalk.yellow(" Session expired or invalid. Run `indus login` again."));
278
+ return EXIT_FAILURE;
279
+ }
280
+ if (!res.ok) {
281
+ err(chalk.red(` Could not fetch account: HTTP ${res.status}`));
282
+ return EXIT_FAILURE;
283
+ }
284
+ const user = (await res.json()) as {
285
+ name?: string;
286
+ email?: string;
287
+ plan?: string;
288
+ monthlyTokenLimit?: number;
289
+ };
290
+ out();
291
+ out(chalk.bold(" Signed in as:"));
292
+ out(` ${chalk.bold(user.name || user.email || "")} ${chalk.dim(`<${user.email}>`)}`);
293
+ out(` ${chalk.dim("plan:")} ${user.plan}`);
294
+ out(
295
+ ` ${chalk.dim("monthly token limit:")} ${(user.monthlyTokenLimit ?? 0).toLocaleString()}`,
296
+ );
297
+ out();
298
+ return EXIT_OK;
299
+ } catch (error) {
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ err(chalk.red(` Could not reach the server: ${message}`));
302
+ return EXIT_FAILURE;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Route a `login` / `logout` / `whoami` verb, if this invocation is one.
308
+ */
309
+ export async function runLogin(ctx: BootContext): Promise<number | undefined> {
310
+ const verb = ctx.argv[0];
311
+ if (verb !== "login" && verb !== "logout" && verb !== "whoami") {
312
+ return undefined;
313
+ }
314
+ const args = parseLoginArgs(ctx.argv.slice(1));
315
+ switch (verb) {
316
+ case "login":
317
+ return doLogin(args);
318
+ case "logout":
319
+ return doLogout();
320
+ case "whoami":
321
+ return doWhoami(args);
322
+ }
323
+ return undefined;
324
+ }
@@ -0,0 +1,18 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { hasRegisteredOAuthClientId, launchAttempts } from "./oauth.js";
3
+
4
+ afterEach(() => vi.unstubAllEnvs());
5
+
6
+ describe("OpenAI Codex OAuth registration", () => {
7
+ it("uses the framework client when no environment override exists", () => {
8
+ vi.stubEnv("INDUSAGI_OPENAI_CODEX_CLIENT_ID", "");
9
+ expect(hasRegisteredOAuthClientId("openai-codex")).toBe(true);
10
+ });
11
+
12
+ it("opens ampersand-heavy OAuth URLs on Windows without cmd parsing", () => {
13
+ const url = "https://example.com/oauth?response_type=code&client_id=client&scope=openid";
14
+ expect(launchAttempts(url, "win32")).toEqual([
15
+ { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] },
16
+ ]);
17
+ });
18
+ });