@linzumi/cli 0.0.1-beta → 0.0.3-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
@@ -1,32 +1,33 @@
1
1
  # @linzumi/cli
2
2
 
3
3
  ```text
4
- . * . . *
5
- * _.-"""-._ .
6
- .-' _ _ '-. *
7
- / (o)_(o) \
8
- | .-. | mouse in the grove
9
- | .-( )-. | listening for Kandan
10
- \ / '-' \ /
11
- '-.\__ __/.-'
12
- /| |\
13
- _/ | | | \_
14
- .' |__|__| '.
15
- / . /___\ . \
16
- '._.' /_____\ '._.'
4
+ ___ ___
5
+ .-' '-. .-' '-.
6
+ .' ' '.
7
+ / _ _ \
8
+ | .' '. .' '. |
9
+ | / \ .-. / \ |
10
+ \ \ (o o) / /
11
+ '._'._ \_/ _.'_.'
12
+ | | |
13
+ | /|\ |
14
+ |___/ | \___|
15
+ \_|_/
16
+ / \
17
17
  ```
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.3-beta
30
31
  ```
31
32
 
32
33
  Or install the current beta tag:
@@ -35,68 +36,76 @@ 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.3-beta
48
51
  ```
49
52
 
50
- ## Basic Use
53
+ ## Prod Quick Start
51
54
 
52
- Run the CLI without arguments to start the default connection command:
55
+ For the Linzumi workspace in prod, this is the first command to try:
53
56
 
54
57
  ```bash
55
- linzumi
58
+ npm install -g @linzumi/cli@0.0.3-beta
59
+
60
+ linzumi connect \
61
+ --kandan-url wss://serve.kandanai.com \
62
+ --workspace linzumi \
63
+ --channel seans-playground \
64
+ --listen-user sean \
65
+ --codex-bin codex \
66
+ --launch-tui
56
67
  ```
57
68
 
58
- That prints the connection guide and tells you to start the local runner
59
- connection flow from Kandan.
69
+ The runner handles OAuth itself. If the cached Kandan token is missing or
70
+ rejected, it opens the OAuth flow and saves the refreshed auth cache.
60
71
 
61
- You can print the same guide directly:
72
+ For a more explicit launch that pins the Codex model, reasoning effort, and
73
+ fast service tier:
62
74
 
63
75
  ```bash
64
- linzumi connect
76
+ linzumi connect \
77
+ --kandan-url wss://serve.kandanai.com \
78
+ --workspace linzumi \
79
+ --channel seans-playground \
80
+ --listen-user sean \
81
+ --codex-bin codex \
82
+ --model gpt-5.5 \
83
+ --reasoning-effort low \
84
+ --fast \
85
+ --launch-tui
65
86
  ```
66
87
 
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.
70
-
71
- ## Connect To The Linzumi Workspace
88
+ ## Basic Use
72
89
 
73
- 1. Install the CLI:
90
+ Running `linzumi` without arguments prints the local runner guide:
74
91
 
75
92
  ```bash
76
- npm install -g @linzumi/cli@0.0.1-beta
93
+ linzumi
77
94
  ```
78
95
 
79
- 2. Confirm the CLI is installed:
96
+ Start the runner for a Kandan workspace/channel:
80
97
 
81
98
  ```bash
82
- linzumi --version
99
+ linzumi connect \
100
+ --kandan-url wss://serve.kandanai.com \
101
+ --workspace linzumi \
102
+ --channel seans-playground \
103
+ --listen-user sean \
104
+ --codex-bin codex \
105
+ --launch-tui
83
106
  ```
84
107
 
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
- ```
108
+ Leave that terminal process running while Kandan uses the local runner.
100
109
 
101
110
  ## Commands
102
111
 
@@ -104,74 +113,42 @@ Supported commands right now:
104
113
 
105
114
  ```bash
106
115
  linzumi
107
- linzumi connect
108
116
  linzumi --help
109
117
  linzumi --version
110
118
  linzumi connect --help
111
- linzumi connect --version
119
+ linzumi connect [runner options]
120
+ linzumi auth [auth options]
112
121
  ```
113
122
 
114
- `local-codex-runner` is still accepted as a compatibility alias:
123
+ `linzumi local-codex-runner` is accepted as a compatibility alias for
124
+ `linzumi connect`.
115
125
 
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:
126
+ ## Useful Options
137
127
 
138
128
  ```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
129
+ --kandan-url <ws-url> Kandan websocket URL, for example wss://serve.kandanai.com
130
+ --workspace <slug> Workspace slug, for example linzumi
131
+ --channel <slug|w/c> Channel slug, or workspace/channel
132
+ --kandan-thread-id <uuid> Resume an existing Kandan thread
133
+ --listen-user <user|all> User whose replies are accepted, or all
134
+ --cwd <path> Working directory for Codex
135
+ --codex-bin <path> Codex executable, default codex
136
+ --model <name> Codex model
137
+ --reasoning-effort <value> Codex reasoning effort
138
+ --fast Request the fast service tier
139
+ --launch-tui Launch codex --remote against the app-server
140
+ --oauth-callback-host <ip> Callback host reachable by your browser
143
141
  ```
144
142
 
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
- ```
143
+ Run `linzumi connect --help` for the full option list.
161
144
 
162
145
  ## Troubleshooting
163
146
 
164
147
  If `linzumi` is not found, confirm the global npm bin directory is on your
165
- `PATH`:
166
-
167
- ```bash
168
- npm bin -g
169
- ```
148
+ `PATH`.
170
149
 
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.
150
+ If `bun` is not found, install Bun first. This beta wraps the existing Bun local
151
+ runner implementation instead of a Node rewrite.
174
152
 
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.
153
+ If OAuth opens but does not return to the CLI, pass `--oauth-callback-host` with
154
+ 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.3-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
+ }