@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/README.md CHANGED
@@ -18,15 +18,16 @@
18
18
 
19
19
  Linzumi's CLI package. It installs the `linzumi` executable.
20
20
 
21
- The current CLI is intentionally small: it connects this machine to Kandan as a
22
- local Codex runner.
21
+ The initial CLI command connects this computer to Kandan as a local Codex
22
+ runner. It wraps the same local runner that was previously started with
23
+ `bun run start -- ...`.
23
24
 
24
25
  ## Install
25
26
 
26
27
  Install the exact beta version:
27
28
 
28
29
  ```bash
29
- npm install -g @linzumi/cli@0.0.1-beta
30
+ npm install -g @linzumi/cli@0.0.2-beta
30
31
  ```
31
32
 
32
33
  Or install the current beta tag:
@@ -35,68 +36,83 @@ Or install the current beta tag:
35
36
  npm install -g @linzumi/cli@beta
36
37
  ```
37
38
 
38
- Check that the executable is available:
39
+ This beta expects Bun and Codex to be available locally:
39
40
 
40
41
  ```bash
42
+ bun --version
43
+ codex --version
41
44
  linzumi --version
42
45
  ```
43
46
 
44
- Expected output for this beta:
47
+ Expected CLI output:
45
48
 
46
49
  ```bash
47
- linzumi 0.0.1-beta
50
+ linzumi 0.0.2-beta
48
51
  ```
49
52
 
50
- ## Basic Use
53
+ ## Equivalent Command
51
54
 
52
- Run the CLI without arguments to start the default connection command:
55
+ The old local runner command:
53
56
 
54
57
  ```bash
55
- linzumi
58
+ bun run start -- \
59
+ --kandan-url wss://serve.kandanai.com \
60
+ --workspace linzumi \
61
+ --channel seans-playground \
62
+ --kandan-thread-id 6d454179-8c6c-45c6-a447-06f01cb6fa71 \
63
+ --listen-user sean \
64
+ --cwd /Users/seans/code/linzumi-main \
65
+ --codex-bin codex \
66
+ --model gpt-5.5 \
67
+ --reasoning-effort low \
68
+ --fast \
69
+ --launch-tui \
70
+ --oauth-callback-host 100.71.192.98
56
71
  ```
57
72
 
58
- That prints the connection guide and tells you to start the local runner
59
- connection flow from Kandan.
60
-
61
- You can print the same guide directly:
73
+ is now:
62
74
 
63
75
  ```bash
64
- linzumi connect
65
- ```
66
-
67
- `linzumi connect` is the user-facing command. The CLI runs on your local
68
- computer; Kandan does not execute this binary for you. Kandan shows the command
69
- or connection details, and you run them locally.
76
+ linzumi connect \
77
+ --kandan-url wss://serve.kandanai.com \
78
+ --workspace linzumi \
79
+ --channel seans-playground \
80
+ --kandan-thread-id 6d454179-8c6c-45c6-a447-06f01cb6fa71 \
81
+ --listen-user sean \
82
+ --cwd /Users/seans/code/linzumi-main \
83
+ --codex-bin codex \
84
+ --model gpt-5.5 \
85
+ --reasoning-effort low \
86
+ --fast \
87
+ --launch-tui \
88
+ --oauth-callback-host 100.71.192.98
89
+ ```
90
+
91
+ The runner handles OAuth itself. If the cached Kandan token is missing or
92
+ rejected, it opens the OAuth flow and saves the refreshed auth cache.
70
93
 
71
- ## Connect To The Linzumi Workspace
94
+ ## Basic Use
72
95
 
73
- 1. Install the CLI:
96
+ Running `linzumi` without arguments prints the local runner guide:
74
97
 
75
98
  ```bash
76
- npm install -g @linzumi/cli@0.0.1-beta
99
+ linzumi
77
100
  ```
78
101
 
79
- 2. Confirm the CLI is installed:
102
+ Start the runner for a Kandan workspace/channel:
80
103
 
81
104
  ```bash
82
- linzumi --version
105
+ linzumi connect \
106
+ --kandan-url wss://serve.kandanai.com \
107
+ --workspace linzumi \
108
+ --channel seans-playground \
109
+ --listen-user sean \
110
+ --cwd /Users/seans/code/linzumi-main \
111
+ --codex-bin codex \
112
+ --launch-tui
83
113
  ```
84
114
 
85
- 3. Open Kandan in your browser and switch to the Linzumi workspace.
86
-
87
- 4. Start the local runner connection flow in Kandan.
88
-
89
- 5. Copy the command Kandan shows you and run it in a terminal on this computer.
90
-
91
- 6. Leave that terminal process running while you use the local runner from
92
- Kandan.
93
-
94
- For orientation, running the CLI without arguments prints the local connection
95
- guide:
96
-
97
- ```bash
98
- linzumi
99
- ```
115
+ Leave that terminal process running while Kandan uses the local runner.
100
116
 
101
117
  ## Commands
102
118
 
@@ -104,74 +120,42 @@ Supported commands right now:
104
120
 
105
121
  ```bash
106
122
  linzumi
107
- linzumi connect
108
123
  linzumi --help
109
124
  linzumi --version
110
125
  linzumi connect --help
111
- linzumi connect --version
126
+ linzumi connect [runner options]
127
+ linzumi auth [auth options]
112
128
  ```
113
129
 
114
- `local-codex-runner` is still accepted as a compatibility alias:
130
+ `linzumi local-codex-runner` is accepted as a compatibility alias for
131
+ `linzumi connect`.
115
132
 
116
- ```bash
117
- linzumi local-codex-runner
118
- ```
119
-
120
- ## Runner Protocol
121
-
122
- During the connection flow, Kandan may tell you to run a command shaped like:
123
-
124
- ```bash
125
- linzumi connect app-server --listen ws://127.0.0.1:45021
126
- ```
127
-
128
- That trailing `app-server --listen <url>` is runner protocol. It is not a
129
- separate Linzumi feature; it is the Codex app-server mode that lets this local
130
- process open the websocket Kandan will connect to.
131
-
132
- ## Configuration
133
-
134
- The Kandan local runner flow should give you the command or configuration to run
135
- on your machine. For manual testing, the same configuration can be supplied with
136
- environment variables:
133
+ ## Useful Options
137
134
 
138
135
  ```bash
139
- export LINZUMI_LOCAL_CODEX_RUNNER_LINZ_BIN=/path/to/kandan-local-bridge-linz
140
- export LINZUMI_LOCAL_CODEX_RUNNER_VM_ID=lzrunner123
141
- export LINZUMI_LOCAL_CODEX_RUNNER_REMOTE_CODEX_BIN=.kandan/tools/codex/0.116.0/bin/codex
142
- export LINZUMI_LOCAL_CODEX_RUNNER_REMOTE_ENV=.kandan/runtime/agents/13/channels/8/codex_app_server/runtime.env.sh
136
+ --kandan-url <ws-url> Kandan websocket URL, for example wss://serve.kandanai.com
137
+ --workspace <slug> Workspace slug, for example linzumi
138
+ --channel <slug|w/c> Channel slug, or workspace/channel
139
+ --kandan-thread-id <uuid> Resume an existing Kandan thread
140
+ --listen-user <user|all> User whose replies are accepted, or all
141
+ --cwd <path> Working directory for Codex
142
+ --codex-bin <path> Codex executable, default codex
143
+ --model <name> Codex model
144
+ --reasoning-effort <value> Codex reasoning effort
145
+ --fast Request the fast service tier
146
+ --launch-tui Launch codex --remote against the app-server
147
+ --oauth-callback-host <ip> Callback host reachable by your browser
143
148
  ```
144
149
 
145
- Or with flags:
146
-
147
- ```bash
148
- linzumi connect \
149
- --linz-bin /path/to/kandan-local-bridge-linz \
150
- --vm-id lzrunner123 \
151
- --remote-codex-bin .kandan/tools/codex/0.116.0/bin/codex \
152
- --remote-env .kandan/runtime/agents/13/channels/8/codex_app_server/runtime.env.sh \
153
- app-server --listen ws://127.0.0.1:45021
154
- ```
155
-
156
- The runner executes:
157
-
158
- ```bash
159
- <linz-bin> exec <vm-id> -- sh -lc "exec <remote-kandan-linz> app-server --env-file <remote-env> <remote-codex-bin> <listen-url>"
160
- ```
150
+ Run `linzumi connect --help` for the full option list.
161
151
 
162
152
  ## Troubleshooting
163
153
 
164
154
  If `linzumi` is not found, confirm the global npm bin directory is on your
165
- `PATH`:
166
-
167
- ```bash
168
- npm bin -g
169
- ```
155
+ `PATH`.
170
156
 
171
- If `linzumi connect` reports missing configuration, use the full command or
172
- configuration shown by Kandan's local runner connection flow. Manual launches
173
- need the environment variables or flags listed above.
157
+ If `bun` is not found, install Bun first. This beta wraps the existing Bun local
158
+ runner implementation instead of a Node rewrite.
174
159
 
175
- If the runner starts but Kandan cannot connect, check that the `--listen` URL
176
- was supplied by Kandan and that the configured LINZ bridge command can reach the
177
- runner VM.
160
+ If OAuth opens but does not return to the CLI, pass `--oauth-callback-host` with
161
+ an IP address that your browser can reach, such as your Tailscale IP.
package/bin/linzumi.js CHANGED
@@ -1,15 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runCli } from "../src/cli.js";
3
+ import { spawnSync } from "node:child_process";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
 
5
- const result = runCli({
6
- argv: process.argv.slice(2),
7
- env: process.env,
7
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
8
+ const entrypoint = join(packageRoot, "src", "index.ts");
9
+ const result = spawnSync("bun", ["run", entrypoint, ...process.argv.slice(2)], {
8
10
  cwd: process.cwd(),
9
- stdout: process.stdout,
10
- stderr: process.stderr,
11
+ env: process.env,
12
+ stdio: "inherit",
11
13
  });
12
14
 
13
- if (result.exitCode !== 0) {
14
- process.exit(result.exitCode);
15
+ if (result.error !== undefined) {
16
+ process.stderr.write(
17
+ `failed to launch linzumi runner with bun: ${result.error.message}\n` +
18
+ "Install Bun, then rerun the linzumi command.\n",
19
+ );
20
+ process.exit(127);
15
21
  }
22
+
23
+ process.exit(result.status ?? 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "0.0.1-beta",
3
+ "version": "0.0.2-beta",
4
4
  "description": "Linzumi local Codex runner CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,9 +12,11 @@
12
12
  "README.md"
13
13
  ],
14
14
  "scripts": {
15
- "test": "node ./test/local-codex-runner.test.js"
15
+ "start": "bun run src/index.ts",
16
+ "test": "bun test"
16
17
  },
17
18
  "engines": {
19
+ "bun": ">=1.1.0",
18
20
  "node": ">=22.0.0"
19
21
  },
20
22
  "publishConfig": {
@@ -0,0 +1,157 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
4
+ Relationship: Stores scoped local-runner OAuth tokens so users can log in
5
+ once and then start local Codex runners without pasting tokens repeatedly.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ import { kandanHttpBaseUrl } from "./oauth";
11
+
12
+ export type CachedLocalRunnerToken = {
13
+ readonly accessToken: string;
14
+ readonly kandanBaseUrl: string;
15
+ readonly issuedAt: string;
16
+ readonly expiresAt?: string | undefined;
17
+ };
18
+
19
+ type AuthFile = {
20
+ readonly version: 1;
21
+ readonly local_codex_runner?: Record<string, CachedTokenJson> | undefined;
22
+ };
23
+
24
+ type CachedTokenJson = {
25
+ readonly access_token: string;
26
+ readonly issued_at: string;
27
+ readonly expires_at?: string | undefined;
28
+ };
29
+
30
+ export function defaultAuthFilePath(): string {
31
+ const base = process.env.KANDAN_HOME ?? join(homedir(), ".kandan");
32
+ return join(base, "auth.json");
33
+ }
34
+
35
+ export function readCachedLocalRunnerToken(
36
+ kandanUrl: string,
37
+ authFilePath: string = defaultAuthFilePath(),
38
+ ): CachedLocalRunnerToken | undefined {
39
+ if (!existsSync(authFilePath)) {
40
+ return undefined;
41
+ }
42
+
43
+ const authFile = parseAuthFile(readFileSync(authFilePath, "utf8"));
44
+ const kandanBaseUrl = kandanHttpBaseUrl(kandanUrl);
45
+ const entry = authFile.local_codex_runner?.[kandanBaseUrl];
46
+
47
+ if (entry === undefined || entry.access_token.trim() === "") {
48
+ return undefined;
49
+ }
50
+
51
+ if (entry.expires_at !== undefined && Date.parse(entry.expires_at) <= Date.now()) {
52
+ return undefined;
53
+ }
54
+
55
+ return {
56
+ accessToken: entry.access_token,
57
+ kandanBaseUrl,
58
+ issuedAt: entry.issued_at,
59
+ expiresAt: entry.expires_at,
60
+ };
61
+ }
62
+
63
+ export function writeCachedLocalRunnerToken(args: {
64
+ readonly kandanUrl: string;
65
+ readonly accessToken: string;
66
+ readonly expiresInSeconds?: number | undefined;
67
+ readonly authFilePath?: string | undefined;
68
+ }): CachedLocalRunnerToken {
69
+ const authFilePath = args.authFilePath ?? defaultAuthFilePath();
70
+ const existing = existsSync(authFilePath)
71
+ ? parseAuthFile(readFileSync(authFilePath, "utf8"))
72
+ : { version: 1 as const };
73
+ const kandanBaseUrl = kandanHttpBaseUrl(args.kandanUrl);
74
+ const issuedAt = new Date();
75
+ const expiresAt =
76
+ args.expiresInSeconds === undefined
77
+ ? undefined
78
+ : new Date(issuedAt.getTime() + args.expiresInSeconds * 1000).toISOString();
79
+ const next: AuthFile = {
80
+ ...existing,
81
+ version: 1,
82
+ local_codex_runner: {
83
+ ...(existing.local_codex_runner ?? {}),
84
+ [kandanBaseUrl]: {
85
+ access_token: args.accessToken,
86
+ issued_at: issuedAt.toISOString(),
87
+ expires_at: expiresAt,
88
+ },
89
+ },
90
+ };
91
+
92
+ mkdirSync(dirname(authFilePath), { recursive: true });
93
+ writeFileSync(authFilePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
94
+
95
+ return {
96
+ accessToken: args.accessToken,
97
+ kandanBaseUrl,
98
+ issuedAt: issuedAt.toISOString(),
99
+ expiresAt,
100
+ };
101
+ }
102
+
103
+ function parseAuthFile(text: string): AuthFile {
104
+ const parsed: unknown = JSON.parse(text);
105
+
106
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
107
+ throw new Error("Kandan auth cache must contain a JSON object");
108
+ }
109
+
110
+ const value = parsed as Record<string, unknown>;
111
+ const localRunner = value.local_codex_runner;
112
+
113
+ if (
114
+ localRunner !== undefined &&
115
+ (typeof localRunner !== "object" || localRunner === null || Array.isArray(localRunner))
116
+ ) {
117
+ throw new Error("Kandan auth cache local_codex_runner must be an object");
118
+ }
119
+
120
+ return {
121
+ version: 1,
122
+ local_codex_runner: normalizeLocalRunnerCache(localRunner),
123
+ };
124
+ }
125
+
126
+ function normalizeLocalRunnerCache(value: unknown): Record<string, CachedTokenJson> | undefined {
127
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
128
+ return undefined;
129
+ }
130
+
131
+ return Object.fromEntries(
132
+ Object.entries(value).flatMap(([key, entry]) => {
133
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
134
+ return [];
135
+ }
136
+
137
+ const token = (entry as Record<string, unknown>).access_token;
138
+ const issuedAt = (entry as Record<string, unknown>).issued_at;
139
+ const expiresAt = (entry as Record<string, unknown>).expires_at;
140
+
141
+ if (typeof token !== "string" || typeof issuedAt !== "string") {
142
+ return [];
143
+ }
144
+
145
+ return [
146
+ [
147
+ key,
148
+ {
149
+ access_token: token,
150
+ issued_at: issuedAt,
151
+ expires_at: typeof expiresAt === "string" ? expiresAt : undefined,
152
+ },
153
+ ],
154
+ ];
155
+ }),
156
+ );
157
+ }
@@ -0,0 +1,75 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
4
+ Relationship: Implements the one-command runner auth behavior: use an
5
+ explicit token when supplied, validate cached scoped runner auth, and fall
6
+ back to the OAuth flow when cached auth is missing or rejected.
7
+ */
8
+ import { readCachedLocalRunnerToken, writeCachedLocalRunnerToken } from "./authCache";
9
+ import { acquireLocalRunnerTokenDetails, validateLocalRunnerToken } from "./oauth";
10
+
11
+ export type LocalRunnerTokenResolutionArgs = {
12
+ readonly kandanUrl: string;
13
+ readonly explicitToken?: string | undefined;
14
+ readonly workspaceSlug?: string | undefined;
15
+ readonly channelSlug?: string | undefined;
16
+ readonly authFilePath?: string | undefined;
17
+ readonly callbackHost?: string | undefined;
18
+ readonly reportRejectedCachedToken?: (() => void) | undefined;
19
+ };
20
+
21
+ type LocalRunnerTokenResolutionDeps = {
22
+ readonly readCachedToken: typeof readCachedLocalRunnerToken;
23
+ readonly validateToken: typeof validateLocalRunnerToken;
24
+ readonly acquireAndCacheToken: (args: LocalRunnerTokenResolutionArgs) => Promise<string>;
25
+ };
26
+
27
+ export async function resolveLocalRunnerToken(
28
+ args: LocalRunnerTokenResolutionArgs,
29
+ deps: LocalRunnerTokenResolutionDeps = {
30
+ readCachedToken: readCachedLocalRunnerToken,
31
+ validateToken: validateLocalRunnerToken,
32
+ acquireAndCacheToken,
33
+ },
34
+ ): Promise<string> {
35
+ if (args.explicitToken !== undefined) {
36
+ return args.explicitToken;
37
+ }
38
+
39
+ const cached = deps.readCachedToken(args.kandanUrl, args.authFilePath);
40
+
41
+ if (cached !== undefined) {
42
+ const cachedTokenIsUsable = await deps.validateToken({
43
+ kandanUrl: args.kandanUrl,
44
+ accessToken: cached.accessToken,
45
+ workspaceSlug: args.workspaceSlug,
46
+ channelSlug: args.channelSlug,
47
+ });
48
+
49
+ if (cachedTokenIsUsable) {
50
+ return cached.accessToken;
51
+ }
52
+
53
+ args.reportRejectedCachedToken?.();
54
+ }
55
+
56
+ return await deps.acquireAndCacheToken(args);
57
+ }
58
+
59
+ async function acquireAndCacheToken(args: LocalRunnerTokenResolutionArgs): Promise<string> {
60
+ const token = await acquireLocalRunnerTokenDetails({
61
+ kandanUrl: args.kandanUrl,
62
+ workspaceSlug: args.workspaceSlug,
63
+ channelSlug: args.channelSlug,
64
+ callbackHost: args.callbackHost,
65
+ });
66
+
67
+ writeCachedLocalRunnerToken({
68
+ kandanUrl: args.kandanUrl,
69
+ accessToken: token.accessToken,
70
+ expiresInSeconds: token.expiresInSeconds,
71
+ authFilePath: args.authFilePath,
72
+ });
73
+
74
+ return token.accessToken;
75
+ }