@linzumi/cli 0.0.1-beta → 0.0.2-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,354 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-runner-plan.md
4
+ Relationship: Provides the spec's initial Bun CLI entry point for starting
5
+ a customer-machine local runner with Kandan and Codex connection options.
6
+
7
+ - Date: 2026-04-24
8
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
9
+ Relationship: Parses the channel-bound runner options that let a local Codex
10
+ process bind to one Kandan channel/thread and filter accepted senders.
11
+
12
+ - Date: 2026-04-24
13
+ Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
14
+ Relationship: Keeps default local runner identity collision-resistant for
15
+ concurrent local starts.
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+ import { runLocalCodexRunner, type RunnerOptions } from "./runner";
19
+ import { writeCachedLocalRunnerToken } from "./authCache";
20
+ import { resolveLocalRunnerToken } from "./authResolution";
21
+ import { acquireLocalRunnerTokenDetails } from "./oauth";
22
+
23
+ type FlagDefinition = {
24
+ readonly kind: "value" | "boolean";
25
+ };
26
+
27
+ const flagDefinitions = new Map<string, FlagDefinition>([
28
+ ["version", { kind: "boolean" }],
29
+ ["kandan-url", { kind: "value" }],
30
+ ["token", { kind: "value" }],
31
+ ["runner-id", { kind: "value" }],
32
+ ["cwd", { kind: "value" }],
33
+ ["codex-bin", { kind: "value" }],
34
+ ["codex-url", { kind: "value" }],
35
+ ["launch-tui", { kind: "boolean" }],
36
+ ["workspace", { kind: "value" }],
37
+ ["channel", { kind: "value" }],
38
+ ["kandan-thread-id", { kind: "value" }],
39
+ ["listen-user", { kind: "value" }],
40
+ ["model", { kind: "value" }],
41
+ ["reasoning-effort", { kind: "value" }],
42
+ ["sandbox", { kind: "value" }],
43
+ ["approval-policy", { kind: "value" }],
44
+ ["fast", { kind: "boolean" }],
45
+ ["log-file", { kind: "value" }],
46
+ ["auth-file", { kind: "value" }],
47
+ ["oauth-callback-host", { kind: "value" }],
48
+ ["help", { kind: "boolean" }],
49
+ ]);
50
+
51
+ try {
52
+ await main(process.argv.slice(2));
53
+ } catch (error) {
54
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
55
+ process.exit(1);
56
+ }
57
+
58
+ async function main(args: readonly string[]): Promise<void> {
59
+ const parsed = parseCommand(args);
60
+
61
+ switch (parsed.command) {
62
+ case "guide":
63
+ process.stdout.write(connectGuideText());
64
+ return;
65
+ case "version":
66
+ process.stdout.write("linzumi 0.0.2-beta\n");
67
+ return;
68
+ case "auth":
69
+ await runAuthCommand(parsed.args);
70
+ return;
71
+ case "run": {
72
+ const options = await parseRunnerArgs(parsed.args);
73
+ await runLocalCodexRunner(options);
74
+ return;
75
+ }
76
+ }
77
+ }
78
+
79
+ type ParsedCommand =
80
+ | { readonly command: "guide"; readonly args: readonly string[] }
81
+ | { readonly command: "version"; readonly args: readonly string[] }
82
+ | { readonly command: "auth"; readonly args: readonly string[] }
83
+ | { readonly command: "run"; readonly args: readonly string[] };
84
+
85
+ function parseCommand(args: readonly string[]): ParsedCommand {
86
+ const [first, ...rest] = args;
87
+
88
+ switch (first) {
89
+ case undefined:
90
+ case "connect":
91
+ case "local-codex-runner":
92
+ return rest.length === 0
93
+ ? { command: "guide", args: [] }
94
+ : { command: "run", args: rest };
95
+ case "--version":
96
+ return { command: "version", args: [] };
97
+ case "--help":
98
+ case "-h":
99
+ return { command: "run", args: ["--help"] };
100
+ case "auth":
101
+ return { command: "auth", args: rest };
102
+ case "run":
103
+ return { command: "run", args: rest };
104
+ default:
105
+ return { command: "run", args };
106
+ }
107
+ }
108
+
109
+ async function runAuthCommand(args: readonly string[]): Promise<void> {
110
+ const values = strictFlagValues(args);
111
+
112
+ if (values.get("help") === true) {
113
+ process.stdout.write(helpText());
114
+ return;
115
+ }
116
+
117
+ const kandanUrl = required(values, "kandan-url");
118
+ const target = parseOptionalChannelTarget(values);
119
+ const token = await acquireLocalRunnerTokenDetails({
120
+ kandanUrl,
121
+ workspaceSlug: target?.workspaceSlug,
122
+ channelSlug: target?.channelSlug,
123
+ callbackHost: stringValue(values, "oauth-callback-host"),
124
+ });
125
+ const cached = writeCachedLocalRunnerToken({
126
+ kandanUrl,
127
+ accessToken: token.accessToken,
128
+ expiresInSeconds: token.expiresInSeconds,
129
+ authFilePath: stringValue(values, "auth-file"),
130
+ });
131
+
132
+ process.stdout.write(`Saved Kandan local runner auth for ${cached.kandanBaseUrl}\n`);
133
+ }
134
+
135
+ async function parseRunnerArgs(args: readonly string[]): Promise<RunnerOptions> {
136
+ const values = strictFlagValues(args);
137
+
138
+ if (values.get("help") === true) {
139
+ process.stdout.write(helpText());
140
+ process.exit(0);
141
+ }
142
+
143
+ if (values.get("version") === true) {
144
+ process.stdout.write("linzumi 0.0.2-beta\n");
145
+ process.exit(0);
146
+ }
147
+
148
+ const channelSession = parseChannelSession(values);
149
+ const kandanUrl = required(values, "kandan-url");
150
+ const explicitToken = stringValue(values, "token");
151
+ const token = await resolveLocalRunnerToken({
152
+ kandanUrl,
153
+ explicitToken,
154
+ workspaceSlug: channelSession?.workspaceSlug,
155
+ channelSlug: channelSession?.channelSlug,
156
+ authFilePath: stringValue(values, "auth-file"),
157
+ callbackHost: stringValue(values, "oauth-callback-host"),
158
+ reportRejectedCachedToken: () => {
159
+ process.stderr.write("Cached Kandan local runner auth was rejected; starting OAuth.\n");
160
+ },
161
+ });
162
+
163
+ return {
164
+ kandanUrl,
165
+ token,
166
+ runnerId: stringValue(values, "runner-id") ?? `runner-${randomUUID()}`,
167
+ cwd: stringValue(values, "cwd") ?? process.cwd(),
168
+ codexBin: stringValue(values, "codex-bin") ?? "codex",
169
+ codexUrl: stringValue(values, "codex-url"),
170
+ launchTui: values.get("launch-tui") === true,
171
+ fast: values.get("fast") === true,
172
+ logFile: stringValue(values, "log-file"),
173
+ channelSession
174
+ };
175
+ }
176
+
177
+ function strictFlagValues(args: readonly string[]): Map<string, string | true> {
178
+ const values = new Map<string, string | true>();
179
+
180
+ for (let index = 0; index < args.length; index += 1) {
181
+ const arg = args[index];
182
+
183
+ if (arg === undefined || !arg.startsWith("--")) {
184
+ throw new Error(`invalid argument: ${arg ?? ""}`);
185
+ }
186
+
187
+ const key = arg.slice(2);
188
+ const definition = flagDefinitions.get(key);
189
+
190
+ if (definition === undefined) {
191
+ throw new Error(`invalid flag: --${key}`);
192
+ }
193
+
194
+ if (definition.kind === "boolean") {
195
+ values.set(key, true);
196
+ continue;
197
+ }
198
+
199
+ const next = args[index + 1];
200
+
201
+ if (next === undefined || next.startsWith("--")) {
202
+ throw new Error(`missing value for --${key}`);
203
+ }
204
+
205
+ values.set(key, next);
206
+ index += 1;
207
+ }
208
+
209
+ return values;
210
+ }
211
+
212
+ function parseChannelSession(
213
+ values: Map<string, string | true>
214
+ ): RunnerOptions["channelSession"] {
215
+ const target = parseOptionalChannelTarget(values);
216
+
217
+ if (target === undefined) {
218
+ return undefined;
219
+ }
220
+
221
+ const listenUser = required(values, "listen-user");
222
+
223
+ return {
224
+ workspaceSlug: target.workspaceSlug,
225
+ channelSlug: target.channelSlug,
226
+ kandanThreadId: stringValue(values, "kandan-thread-id"),
227
+ listenUser,
228
+ model: stringValue(values, "model"),
229
+ reasoningEffort: stringValue(values, "reasoning-effort"),
230
+ sandbox: stringValue(values, "sandbox"),
231
+ approvalPolicy: stringValue(values, "approval-policy"),
232
+ };
233
+ }
234
+
235
+ function parseOptionalChannelTarget(
236
+ values: Map<string, string | true>
237
+ ): { readonly workspaceSlug: string; readonly channelSlug: string } | undefined {
238
+ const channel = stringValue(values, "channel");
239
+ const workspace = stringValue(values, "workspace");
240
+
241
+ if (channel === undefined && workspace === undefined) {
242
+ return undefined;
243
+ }
244
+
245
+ return channel !== undefined && channel.includes("/")
246
+ ? parseChannelPath(channel)
247
+ : {
248
+ workspaceSlug: workspace ?? required(values, "workspace"),
249
+ channelSlug: channel ?? required(values, "channel")
250
+ };
251
+ }
252
+
253
+ function parseChannelPath(channel: string): {
254
+ readonly workspaceSlug: string;
255
+ readonly channelSlug: string;
256
+ } {
257
+ const [workspaceSlug, channelSlug, ...rest] = channel.split("/");
258
+
259
+ if (
260
+ workspaceSlug === undefined ||
261
+ workspaceSlug.trim() === "" ||
262
+ channelSlug === undefined ||
263
+ channelSlug.trim() === "" ||
264
+ rest.length > 0
265
+ ) {
266
+ throw new Error("--channel must be CHANNEL or WORKSPACE/CHANNEL");
267
+ }
268
+
269
+ return {
270
+ workspaceSlug: workspaceSlug.trim(),
271
+ channelSlug: channelSlug.trim()
272
+ };
273
+ }
274
+
275
+ function required(values: Map<string, string | true>, key: string): string {
276
+ const value = stringValue(values, key);
277
+
278
+ if (value === undefined) {
279
+ throw new Error(`missing --${key}`);
280
+ }
281
+
282
+ return value;
283
+ }
284
+
285
+ function stringValue(values: Map<string, string | true>, key: string): string | undefined {
286
+ const value = values.get(key);
287
+
288
+ if (typeof value === "string" && value.trim() !== "") {
289
+ return value;
290
+ }
291
+
292
+ return undefined;
293
+ }
294
+
295
+ function helpText(): string {
296
+ return `Kandan local Codex runner
297
+
298
+ Usage:
299
+ linzumi
300
+ linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> --listen-user <username|all> [options]
301
+ linzumi auth --kandan-url <ws-url> [--workspace <slug> --channel <slug>]
302
+
303
+ Required:
304
+ --kandan-url <ws-url> Kandan websocket base URL, for example ws://127.0.0.1:4160
305
+ --token <jwt> Optional override token. Otherwise ~/.kandan/auth.json is validated or OAuth opens.
306
+ --auth-file <path> Auth cache path, default ~/.kandan/auth.json
307
+ --oauth-callback-host <ip> Callback host reachable by your browser, for example a Tailscale IP
308
+
309
+ Channel binding:
310
+ --workspace <slug> Workspace slug
311
+ --channel <slug|w/c> Channel slug, or workspace/channel
312
+ --kandan-thread-id <uuid> Resume an existing Kandan thread instead of announcing a new root
313
+ --listen-user <user|all> User whose replies are accepted, or all
314
+
315
+ Codex:
316
+ --cwd <path> Working directory for Codex, default current directory
317
+ --codex-bin <path> Codex executable, default codex
318
+ --codex-url <ws-url> Existing Codex app-server websocket URL
319
+ --launch-tui Launch codex --remote against the app-server
320
+ --model <name> Model requested for the Codex thread and shown in Kandan
321
+ --reasoning-effort <value> Reasoning effort requested for Codex and shown in Kandan
322
+ --sandbox <value> Sandbox metadata shown in Kandan
323
+ --approval-policy <value> Approval-policy metadata shown in Kandan
324
+ --fast Mark this runner as low-latency/fast in the availability message
325
+ --log-file <path> JSONL event log path, default <cwd>/.kandan-local-codex-runner.log
326
+
327
+ Examples:
328
+ Good:
329
+ linzumi auth --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
330
+ linzumi auth --kandan-url ws://100.71.192.98:4160 --oauth-callback-host 100.71.192.98 --workspace default --channel seans-playground
331
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --workspace default --channel seans-playground --listen-user sean --cwd /tmp/kandan-runner-a
332
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground --listen-user sean
333
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --channel default/seans-playground --listen-user all --launch-tui
334
+
335
+ Bad:
336
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
337
+ Missing --listen-user.
338
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --listen-users sean
339
+ Invalid flag: use --listen-user.
340
+ `;
341
+ }
342
+
343
+ function connectGuideText(): string {
344
+ return `linzumi connect
345
+
346
+ Connect this computer to Kandan as a local Codex runner.
347
+
348
+ Use:
349
+ linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> --listen-user <username|all> [options]
350
+
351
+ For help:
352
+ linzumi connect --help
353
+ `;
354
+ }
package/src/json.ts ADDED
@@ -0,0 +1,49 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
4
+ Relationship: Provides small JSON readers used by the local runner's
5
+ channel binding and Codex transcript normalization without ad hoc casts.
6
+ */
7
+ import { type JsonObject, type JsonValue, isJsonObject } from "./protocol";
8
+
9
+ export function objectValue(value: JsonValue | undefined): JsonObject | undefined {
10
+ return value !== undefined && isJsonObject(value) ? value : undefined;
11
+ }
12
+
13
+ export function arrayValue(
14
+ value: JsonValue | undefined,
15
+ ): readonly JsonValue[] | undefined {
16
+ return Array.isArray(value) ? value : undefined;
17
+ }
18
+
19
+ export function stringValue(value: JsonValue | undefined): string | undefined {
20
+ return typeof value === "string" && value.trim() !== ""
21
+ ? value.trim()
22
+ : undefined;
23
+ }
24
+
25
+ export function integerValue(value: JsonValue | undefined): number | undefined {
26
+ return typeof value === "number" && Number.isInteger(value)
27
+ ? value
28
+ : undefined;
29
+ }
30
+
31
+ export function stringListValue(value: JsonValue | undefined): string[] {
32
+ if (!Array.isArray(value)) {
33
+ return [];
34
+ }
35
+
36
+ return value.flatMap((entry) => {
37
+ const normalized = stringValue(entry);
38
+ return normalized === undefined ? [] : [normalized];
39
+ });
40
+ }
41
+
42
+ export function parseJsonObjectOrUndefined(text: string): JsonObject | undefined {
43
+ try {
44
+ const parsed: unknown = JSON.parse(text);
45
+ return isJsonObject(parsed) ? parsed : undefined;
46
+ } catch (_error) {
47
+ return undefined;
48
+ }
49
+ }
@@ -0,0 +1,102 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
4
+ Relationship: Owns deterministic queue formatting and interrupt fusion for
5
+ Kandan thread replies before they are sent into local Codex.
6
+
7
+ - Date: 2026-04-24
8
+ Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
9
+ Relationship: Carries selected queued message seqs through interrupt fusion
10
+ so the UI can show which pending replies were accepted by the interrupt.
11
+ */
12
+ export type QueuedKandanMessage = {
13
+ readonly seq: number;
14
+ readonly actorSlug: string | undefined;
15
+ readonly actorUserId: number | undefined;
16
+ readonly body: string;
17
+ };
18
+
19
+ export type QueueInterruptResult =
20
+ | {
21
+ readonly ok: true;
22
+ readonly queue: QueuedKandanMessage[];
23
+ readonly selectedCount: number;
24
+ readonly selectedSeqs: number[];
25
+ readonly remainingCount: number;
26
+ }
27
+ | {
28
+ readonly ok: false;
29
+ readonly reason: "queue_empty";
30
+ };
31
+
32
+ export function formatQueuedKandanMessage(message: QueuedKandanMessage): string {
33
+ const sender =
34
+ message.actorSlug === undefined || message.actorSlug.trim() === ""
35
+ ? "unknown"
36
+ : message.actorSlug.trim();
37
+ const userId =
38
+ message.actorUserId === undefined
39
+ ? "unknown"
40
+ : message.actorUserId.toString();
41
+
42
+ return [
43
+ `Kandan message seq=${message.seq} from ${sender} (user_id=${userId}):`,
44
+ "",
45
+ message.body,
46
+ ].join("\n");
47
+ }
48
+
49
+ export function codexInputForQueuedKandanMessage(message: QueuedKandanMessage): string {
50
+ const command = message.body.trimStart();
51
+
52
+ return command.startsWith("!") ? command : formatQueuedKandanMessage(message);
53
+ }
54
+
55
+ export function interruptQueuedMessages(
56
+ queue: readonly QueuedKandanMessage[],
57
+ throughSeq: number | undefined,
58
+ ): QueueInterruptResult {
59
+ const selected =
60
+ throughSeq === undefined
61
+ ? Array.from(queue)
62
+ : queue.filter((message) => message.seq <= throughSeq);
63
+ const remaining =
64
+ throughSeq === undefined
65
+ ? []
66
+ : queue.filter((message) => message.seq > throughSeq);
67
+
68
+ if (selected.length === 0) {
69
+ return { ok: false, reason: "queue_empty" };
70
+ }
71
+
72
+ const fused = fuseQueuedMessages(selected);
73
+
74
+ if (fused === undefined) {
75
+ return { ok: false, reason: "queue_empty" };
76
+ }
77
+
78
+ return {
79
+ ok: true,
80
+ queue: [fused, ...remaining],
81
+ selectedCount: selected.length,
82
+ selectedSeqs: selected.map(message => message.seq),
83
+ remainingCount: remaining.length,
84
+ };
85
+ }
86
+
87
+ function fuseQueuedMessages(
88
+ selected: readonly QueuedKandanMessage[],
89
+ ): QueuedKandanMessage | undefined {
90
+ const last = selected[selected.length - 1];
91
+
92
+ if (last === undefined) {
93
+ return undefined;
94
+ }
95
+
96
+ return {
97
+ seq: last.seq,
98
+ actorSlug: "kandan",
99
+ actorUserId: undefined,
100
+ body: selected.map(codexInputForQueuedKandanMessage).join("\n\n---\n\n"),
101
+ };
102
+ }