@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 +94 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +11 -12
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/index.js +13 -43
- package/dist/listModels.d.ts +6 -0
- package/dist/listModels.js +62 -0
- package/dist/pickFolder.d.ts +5 -0
- package/dist/pickFolder.js +65 -0
- package/dist/worker.js +50 -1
- package/package.json +3 -2
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
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
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({
|
|
9
|
+
body: JSON.stringify({ code: normalized }),
|
|
8
10
|
});
|
|
9
11
|
if (!res.ok) {
|
|
10
|
-
const
|
|
11
|
-
throw new Error(`
|
|
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:
|
|
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 {
|
|
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.
|
|
23
|
+
.version("0.1.1");
|
|
55
24
|
program
|
|
56
25
|
.command("login")
|
|
57
|
-
.description("
|
|
26
|
+
.description("Pair CLI with your Steer account (Clerk via web pairing code)")
|
|
58
27
|
.action(async () => {
|
|
59
28
|
try {
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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("
|
|
37
|
+
const spinner = ora("Pairing…").start();
|
|
68
38
|
try {
|
|
69
|
-
const config = await
|
|
70
|
-
spinner.succeed(`
|
|
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,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,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", {
|
|
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.
|
|
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",
|