@oblivihon/steer 0.1.0 → 0.1.1

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 ADDED
@@ -0,0 +1,94 @@
1
+ # @oblivihon/steer
2
+
3
+ CLI worker for **Steer** — runs local Cursor agents on your machine for multiplayer sessions hosted in the web app.
4
+
5
+ When you create a **Local** session in Steer, prompts are relayed to this worker, which executes `cursor agent` against a folder on your machine.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 18+
10
+ - [Cursor CLI](https://cursor.com) on your `PATH` (`cursor` command)
11
+ - A Steer account (sign in on the web app)
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g @oblivihon/steer
17
+ ```
18
+
19
+ Or from this repo:
20
+
21
+ ```bash
22
+ cd cli
23
+ pnpm install
24
+ pnpm build
25
+ npm link
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ 1. Sign in on the Steer web app and open **Pair CLI** (`/cli-pair`).
31
+ 2. Generate a pairing code.
32
+ 3. On your machine:
33
+
34
+ ```bash
35
+ steer login
36
+ # Server URL defaults to https://cursor-multiplayer-agent.onrender.com — press Enter
37
+ # Paste the pairing code
38
+
39
+ cursor agent login # if not already logged into Cursor CLI
40
+ steer start
41
+ ```
42
+
43
+ 4. Keep `steer start` running while you use Local sessions in the browser.
44
+
45
+ Credentials are stored at `~/.config/steer/config.json`.
46
+
47
+ ## Commands
48
+
49
+ | Command | Description |
50
+ |---------|-------------|
51
+ | `steer login` | Pair with your Steer account via a one-time web code |
52
+ | `steer logout` | Clear stored credentials |
53
+ | `steer status` | Show logged-in user, server URL, and your rooms |
54
+ | `steer start` | Start the worker (connects to the API over WebSocket) |
55
+ | `steer start --repo <path>` | Same, but force every prompt to use this repo path |
56
+
57
+ ## Server URL
58
+
59
+ `steer login` defaults to:
60
+
61
+ ```
62
+ https://cursor-multiplayer-agent.onrender.com
63
+ ```
64
+
65
+ Override per login at the prompt, or with:
66
+
67
+ ```bash
68
+ export STEER_SERVER_URL=http://localhost:3000
69
+ steer login
70
+ ```
71
+
72
+ Use the local URL when developing against a local API.
73
+
74
+ ## What the worker does
75
+
76
+ While `steer start` is connected, the API can ask your machine to:
77
+
78
+ - List Cursor models (`cursor agent --list-models`)
79
+ - Open a native folder picker for Local session create
80
+ - Run agent prompts in the selected repo and stream events back to the room
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ pnpm install
86
+ pnpm dev # run via tsx without building
87
+ pnpm build # compile to dist/
88
+ ```
89
+
90
+ The published binary is `dist/index.js` (`bin`: `steer`).
91
+
92
+ ## License
93
+
94
+ MIT
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Config } from "./config.js";
2
- export declare function login(serverUrl: string, email: string, password: string): Promise<Config>;
2
+ /** Pair CLI with a Clerk-authenticated web account via one-time code. */
3
+ export declare function loginWithPairingCode(serverUrl: string, code: string): Promise<Config>;
3
4
  export declare function logout(): void;
4
5
  export declare function getAuthHeaders(): Record<string, string>;
package/dist/auth.js CHANGED
@@ -1,23 +1,22 @@
1
- import { loadConfig, saveConfig, clearConfig } from "./config.js";
2
- export async function login(serverUrl, email, password) {
3
- const url = `${serverUrl.replace(/\/+$/, "")}/api/auth/login`;
4
- const res = await fetch(url, {
1
+ import { saveConfig, clearConfig, loadConfig } from "./config.js";
2
+ /** Pair CLI with a Clerk-authenticated web account via one-time code. */
3
+ export async function loginWithPairingCode(serverUrl, code) {
4
+ const base = serverUrl.replace(/\/+$/, "");
5
+ const normalized = code.trim().toUpperCase().replace(/\s+/g, "");
6
+ const res = await fetch(`${base}/api/auth/pairing/claim`, {
5
7
  method: "POST",
6
8
  headers: { "Content-Type": "application/json" },
7
- body: JSON.stringify({ email, password }),
9
+ body: JSON.stringify({ code: normalized }),
8
10
  });
9
11
  if (!res.ok) {
10
- const body = await res.text();
11
- throw new Error(`Login failed (${res.status}): ${body}`);
12
+ const err = (await res.json().catch(() => ({})));
13
+ throw new Error(err.error || `Pairing failed (${res.status})`);
12
14
  }
13
15
  const data = (await res.json());
14
- if (!data.token) {
15
- throw new Error("Server did not return a token");
16
- }
17
16
  const config = {
18
- serverUrl: serverUrl.replace(/\/+$/, ""),
17
+ serverUrl: base,
19
18
  token: data.token,
20
- email,
19
+ email: data.user.email,
21
20
  };
22
21
  saveConfig(config);
23
22
  return config;
package/dist/config.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface Config {
3
3
  token: string;
4
4
  email: string;
5
5
  }
6
+ /** Production API (override with STEER_SERVER_URL for local/dev). */
7
+ export declare const DEFAULT_SERVER_URL: string;
6
8
  export declare function getConfigDir(): string;
7
9
  export declare function loadConfig(): Config | null;
8
10
  export declare function saveConfig(config: Config): void;
package/dist/config.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
+ /** Production API (override with STEER_SERVER_URL for local/dev). */
5
+ export const DEFAULT_SERVER_URL = (process.env.STEER_SERVER_URL ||
6
+ "https://cursor-multiplayer-agent.onrender.com").replace(/\/+$/, "");
4
7
  const CONFIG_DIR_NAME = "steer";
5
8
  const LEGACY_CONFIG_DIR_NAME = "shared-agent";
6
9
  const CONFIG_FILE = "config.json";
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ import { Command } from "commander";
3
3
  import chalk from "chalk";
4
4
  import ora from "ora";
5
5
  import { createInterface } from "readline";
6
- import { login, logout } from "./auth.js";
7
- import { loadConfig } from "./config.js";
6
+ import { loginWithPairingCode, logout } from "./auth.js";
7
+ import { DEFAULT_SERVER_URL, loadConfig } from "./config.js";
8
8
  import { ensureCursorAuth } from "./cursorAuth.js";
9
9
  import { startWorker } from "./worker.js";
10
10
  const program = new Command();
@@ -17,57 +17,27 @@ function prompt(question) {
17
17
  });
18
18
  });
19
19
  }
20
- function promptSecret(question) {
21
- return new Promise((resolve) => {
22
- process.stdout.write(question);
23
- const stdin = process.stdin;
24
- const wasRaw = stdin.isRaw;
25
- if (stdin.isTTY)
26
- stdin.setRawMode(true);
27
- let input = "";
28
- const onData = (ch) => {
29
- const c = ch.toString();
30
- if (c === "\n" || c === "\r") {
31
- stdin.removeListener("data", onData);
32
- if (stdin.isTTY)
33
- stdin.setRawMode(wasRaw ?? false);
34
- process.stdout.write("\n");
35
- resolve(input);
36
- }
37
- else if (c === "\u0003") {
38
- process.exit(1);
39
- }
40
- else if (c === "\u007F" || c === "\b") {
41
- input = input.slice(0, -1);
42
- }
43
- else {
44
- input += c;
45
- }
46
- };
47
- stdin.resume();
48
- stdin.on("data", onData);
49
- });
50
- }
51
20
  program
52
21
  .name("steer")
53
22
  .description("CLI worker for Steer — run local Cursor agents")
54
- .version("0.1.0");
23
+ .version("0.1.1");
55
24
  program
56
25
  .command("login")
57
- .description("Log in to the Steer server")
26
+ .description("Pair CLI with your Steer account (Clerk via web pairing code)")
58
27
  .action(async () => {
59
28
  try {
60
- const serverUrl = await prompt("Server URL: ");
61
- const email = await prompt("Email: ");
62
- const password = await promptSecret("Password: ");
63
- if (!serverUrl || !email || !password) {
64
- console.error(chalk.red("All fields are required."));
29
+ console.log(chalk.gray("\n 1. Sign in on the web app\n 2. Open /cli-pair and generate a code\n 3. Enter that code below\n"));
30
+ const serverUrlRaw = await prompt(`Server URL [${DEFAULT_SERVER_URL}]: `);
31
+ const serverUrl = serverUrlRaw || DEFAULT_SERVER_URL;
32
+ const code = await prompt("Pairing code: ");
33
+ if (!code) {
34
+ console.error(chalk.red("Pairing code is required."));
65
35
  process.exit(1);
66
36
  }
67
- const spinner = ora("Logging in…").start();
37
+ const spinner = ora("Pairing…").start();
68
38
  try {
69
- const config = await login(serverUrl, email, password);
70
- spinner.succeed(`Logged in as ${chalk.cyan(config.email)}`);
39
+ const config = await loginWithPairingCode(serverUrl, code);
40
+ spinner.succeed(`Paired as ${chalk.cyan(config.email)}`);
71
41
  }
72
42
  catch (err) {
73
43
  spinner.fail(err.message);
@@ -0,0 +1,6 @@
1
+ export interface ModelInfo {
2
+ id: string;
3
+ displayName: string;
4
+ }
5
+ /** Models available to the logged-in Cursor CLI on this machine. */
6
+ export declare function listLocalModels(): Promise<ModelInfo[]>;
@@ -0,0 +1,62 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ const execFileAsync = promisify(execFile);
4
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
5
+ const MODEL_LINE_RE = /^([a-zA-Z0-9][a-zA-Z0-9._-]*)\s+[-–—]\s+(.+)$/;
6
+ function stripAnsi(text) {
7
+ return text.replace(ANSI_RE, "").replace(/\r/g, "");
8
+ }
9
+ function parseModels(stdout) {
10
+ const models = [];
11
+ const seen = new Set();
12
+ for (const raw of stripAnsi(stdout).split("\n")) {
13
+ const line = raw.trim();
14
+ if (!line || /^available models$/i.test(line))
15
+ continue;
16
+ const m = line.match(MODEL_LINE_RE);
17
+ if (!m)
18
+ continue;
19
+ const id = m[1];
20
+ if (seen.has(id))
21
+ continue;
22
+ seen.add(id);
23
+ models.push({ id, displayName: m[2].trim() });
24
+ }
25
+ return models;
26
+ }
27
+ async function runListModels() {
28
+ try {
29
+ const { stdout } = await execFileAsync("cursor", ["agent", "--list-models"], {
30
+ timeout: 60_000,
31
+ encoding: "utf8",
32
+ maxBuffer: 16 * 1024 * 1024,
33
+ env: {
34
+ ...process.env,
35
+ NO_COLOR: "1",
36
+ FORCE_COLOR: "0",
37
+ TERM: "dumb",
38
+ },
39
+ });
40
+ return stdout || "";
41
+ }
42
+ catch (err) {
43
+ const e = err;
44
+ if (e.stdout && String(e.stdout).trim())
45
+ return String(e.stdout);
46
+ if (e.code === "ENOENT") {
47
+ throw new Error("cursor CLI not found. Install Cursor and ensure `cursor` is on PATH.");
48
+ }
49
+ throw new Error(e.stderr?.trim() ||
50
+ e.message ||
51
+ "Failed to run `cursor agent --list-models`");
52
+ }
53
+ }
54
+ /** Models available to the logged-in Cursor CLI on this machine. */
55
+ export async function listLocalModels() {
56
+ const stdout = await runListModels();
57
+ const models = parseModels(stdout);
58
+ if (models.length === 0) {
59
+ return [{ id: "auto", displayName: "Auto" }];
60
+ }
61
+ return models;
62
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Open a native OS folder picker and return an absolute path.
3
+ * Returns null if the user cancels.
4
+ */
5
+ export declare function pickFolder(): Promise<string | null>;
@@ -0,0 +1,65 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { platform } from "os";
4
+ const execFileAsync = promisify(execFile);
5
+ /**
6
+ * Open a native OS folder picker and return an absolute path.
7
+ * Returns null if the user cancels.
8
+ */
9
+ export async function pickFolder() {
10
+ const os = platform();
11
+ try {
12
+ if (os === "darwin") {
13
+ const { stdout } = await execFileAsync("osascript", [
14
+ "-e",
15
+ 'POSIX path of (choose folder with prompt "Select a repository folder")',
16
+ ]);
17
+ const path = stdout.trim().replace(/\/$/, "");
18
+ return path || null;
19
+ }
20
+ if (os === "win32") {
21
+ const script = [
22
+ "Add-Type -AssemblyName System.Windows.Forms",
23
+ "$d = New-Object System.Windows.Forms.FolderBrowserDialog",
24
+ '$d.Description = "Select a repository folder"',
25
+ "$d.ShowNewFolderButton = $true",
26
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $d.SelectedPath }",
27
+ ].join("; ");
28
+ const { stdout } = await execFileAsync("powershell", [
29
+ "-NoProfile",
30
+ "-Command",
31
+ script,
32
+ ]);
33
+ const path = stdout.trim();
34
+ return path || null;
35
+ }
36
+ // Linux: prefer zenity, then kdialog
37
+ try {
38
+ const { stdout } = await execFileAsync("zenity", [
39
+ "--file-selection",
40
+ "--directory",
41
+ "--title=Select a repository folder",
42
+ ]);
43
+ const path = stdout.trim();
44
+ return path || null;
45
+ }
46
+ catch {
47
+ const { stdout } = await execFileAsync("kdialog", [
48
+ "--getexistingdirectory",
49
+ process.env.HOME || "/",
50
+ "--title",
51
+ "Select a repository folder",
52
+ ]);
53
+ const path = stdout.trim();
54
+ return path || null;
55
+ }
56
+ }
57
+ catch (err) {
58
+ const e = err;
59
+ // User cancel: osascript exit 1 / zenity exit 1
60
+ if (e.code === 1 || e.code === "1")
61
+ return null;
62
+ throw new Error(e.message ||
63
+ "Failed to open folder picker. On Linux install zenity or kdialog.");
64
+ }
65
+ }
package/dist/worker.js CHANGED
@@ -3,6 +3,8 @@ import { hostname } from "os";
3
3
  import { createHash } from "crypto";
4
4
  import chalk from "chalk";
5
5
  import { loadConfig } from "./config.js";
6
+ import { pickFolder } from "./pickFolder.js";
7
+ import { listLocalModels } from "./listModels.js";
6
8
  import { runAgent, abortAgent, isEditTool, getFileDiff, } from "./agent.js";
7
9
  function generateWorkerId(email) {
8
10
  const raw = `${hostname()}-${email}`;
@@ -28,7 +30,10 @@ export function startWorker(repoPathOverride) {
28
30
  });
29
31
  socket.on("connect", () => {
30
32
  console.log(chalk.green("✓ Connected to server"));
31
- socket.emit("worker:ready", { workerId });
33
+ socket.emit("worker:ready", {
34
+ workerId,
35
+ machineName: hostname(),
36
+ });
32
37
  });
33
38
  socket.on("disconnect", (reason) => {
34
39
  console.log(chalk.yellow(`Disconnected: ${reason}`));
@@ -99,6 +104,50 @@ export function startWorker(repoPathOverride) {
99
104
  console.log(chalk.yellow(" ⚠ Abort requested"));
100
105
  abortAgent();
101
106
  });
107
+ socket.on("worker:pick-folder", async (payload) => {
108
+ console.log(chalk.cyan(" 📂 Opening folder picker…"));
109
+ try {
110
+ const path = await pickFolder();
111
+ if (path) {
112
+ console.log(chalk.green(` ✓ Selected: ${path}`));
113
+ }
114
+ else {
115
+ console.log(chalk.yellow(" Folder pick cancelled"));
116
+ }
117
+ socket.emit("worker:folder-picked", {
118
+ requestId: payload.requestId,
119
+ path,
120
+ });
121
+ }
122
+ catch (err) {
123
+ const message = err.message;
124
+ console.error(chalk.red(` ✗ Folder picker error: ${message}`));
125
+ socket.emit("worker:folder-picked", {
126
+ requestId: payload.requestId,
127
+ path: null,
128
+ error: message,
129
+ });
130
+ }
131
+ });
132
+ socket.on("worker:list-models", async (payload) => {
133
+ console.log(chalk.cyan(" Listing Cursor models…"));
134
+ try {
135
+ const models = await listLocalModels();
136
+ console.log(chalk.green(` ✓ ${models.length} models`));
137
+ socket.emit("worker:models-listed", {
138
+ requestId: payload.requestId,
139
+ models,
140
+ });
141
+ }
142
+ catch (err) {
143
+ const message = err.message;
144
+ console.error(chalk.red(` ✗ List models error: ${message}`));
145
+ socket.emit("worker:models-listed", {
146
+ requestId: payload.requestId,
147
+ error: message,
148
+ });
149
+ }
150
+ });
102
151
  process.on("SIGINT", () => {
103
152
  console.log(chalk.gray("\nShutting down worker…"));
104
153
  abortAgent();
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@oblivihon/steer",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI worker for Steer — run local Cursor agents for multiplayer sessions",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "steer": "./dist/index.js"
8
8
  },
9
9
  "files": [
10
- "dist"
10
+ "dist",
11
+ "README.md"
11
12
  ],
12
13
  "scripts": {
13
14
  "build": "tsc",