@codegrammer/co-od 0.1.0

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/SKILL.md ADDED
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: co-od
3
+ description: CLI for connecting local dev environments to co-ode rooms
4
+ version: 0.1.0
5
+ ---
6
+
7
+ # co-od CLI
8
+
9
+ Run AI agents in co-ode rooms from the command line.
10
+
11
+ ## Commands
12
+
13
+ - `co-od login` -- authenticate via browser (or `--token <t>` for CI)
14
+ - `co-od rooms` -- list rooms (supports `--json`)
15
+ - `co-od run <room> <goal>` -- execute a single task, fire-and-forget
16
+ - `co-od join <room>` -- join a room as an interactive agent (PTY + WebSocket)
17
+ - `co-od daemon <room>` -- autonomous watch mode, polls for events and executes
18
+ - `co-od share` -- generate a relay code for teammates
19
+ - `co-od connect <code>` -- connect to a shared relay
20
+ - `co-od status` -- show login state and session info
21
+
22
+ ## Provider Support
23
+
24
+ Use `--provider claude|codex` on `run`, `daemon`, and `share` commands.
25
+
26
+ - `claude` (default) -- spawns `claude -p --dangerously-skip-permissions`
27
+ - `codex` -- spawns `codex exec --full-auto`
28
+
29
+ ## Usage with Claude Code
30
+
31
+ Dispatch tasks to a room from within Claude Code hooks:
32
+
33
+ ```bash
34
+ co-od run room_abc123 "Fix the failing tests in src/utils" --json
35
+ ```
36
+
37
+ ## Environment Variables
38
+
39
+ - `CO_ODE_SERVER` -- override the server URL (default: https://co-ode.vercel.app)
40
+
41
+ ## Session
42
+
43
+ Sessions are cached at `~/.co-ode/session.json` (valid for 23 hours).
@@ -0,0 +1,15 @@
1
+ export interface AgentAdapter {
2
+ name: string;
3
+ available(): Promise<boolean>;
4
+ execute(goal: string, options: ExecuteOptions): Promise<ExecuteResult>;
5
+ }
6
+ export interface ExecuteOptions {
7
+ workDir: string;
8
+ onOutput?: (data: string) => void;
9
+ signal?: AbortSignal;
10
+ }
11
+ export interface ExecuteResult {
12
+ exitCode: number;
13
+ stdout: string;
14
+ stderr: string;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { AgentAdapter, ExecuteOptions, ExecuteResult } from "./base.js";
2
+ export declare class ClaudeAdapter implements AgentAdapter {
3
+ name: string;
4
+ available(): Promise<boolean>;
5
+ execute(goal: string, options: ExecuteOptions): Promise<ExecuteResult>;
6
+ }
@@ -0,0 +1,45 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ function which(cmd) {
4
+ const paths = (process.env.PATH || "").split(":");
5
+ return paths.some((dir) => existsSync(`${dir}/${cmd}`));
6
+ }
7
+ export class ClaudeAdapter {
8
+ name = "claude";
9
+ async available() {
10
+ return which("claude");
11
+ }
12
+ async execute(goal, options) {
13
+ const { workDir, onOutput, signal } = options;
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn("claude", ["-p", "--dangerously-skip-permissions", "--", goal], {
16
+ cwd: workDir,
17
+ env: { ...process.env },
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ });
20
+ let stdout = "";
21
+ let stderr = "";
22
+ child.stdout?.setEncoding("utf-8");
23
+ child.stderr?.setEncoding("utf-8");
24
+ child.stdout?.on("data", (chunk) => {
25
+ stdout += chunk;
26
+ onOutput?.(chunk);
27
+ });
28
+ child.stderr?.on("data", (chunk) => {
29
+ stderr += chunk;
30
+ onOutput?.(chunk);
31
+ });
32
+ child.on("exit", (code) => {
33
+ resolve({ exitCode: code ?? 1, stdout, stderr });
34
+ });
35
+ child.on("error", (err) => {
36
+ reject(err);
37
+ });
38
+ if (signal) {
39
+ signal.addEventListener("abort", () => {
40
+ child.kill("SIGTERM");
41
+ }, { once: true });
42
+ }
43
+ });
44
+ }
45
+ }
@@ -0,0 +1,6 @@
1
+ import type { AgentAdapter, ExecuteOptions, ExecuteResult } from "./base.js";
2
+ export declare class CodexAdapter implements AgentAdapter {
3
+ name: string;
4
+ available(): Promise<boolean>;
5
+ execute(goal: string, options: ExecuteOptions): Promise<ExecuteResult>;
6
+ }
@@ -0,0 +1,45 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ function which(cmd) {
4
+ const paths = (process.env.PATH || "").split(":");
5
+ return paths.some((dir) => existsSync(`${dir}/${cmd}`));
6
+ }
7
+ export class CodexAdapter {
8
+ name = "codex";
9
+ async available() {
10
+ return which("codex");
11
+ }
12
+ async execute(goal, options) {
13
+ const { workDir, onOutput, signal } = options;
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn("codex", ["exec", "--full-auto", "--", goal], {
16
+ cwd: workDir,
17
+ env: { ...process.env },
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ });
20
+ let stdout = "";
21
+ let stderr = "";
22
+ child.stdout?.setEncoding("utf-8");
23
+ child.stderr?.setEncoding("utf-8");
24
+ child.stdout?.on("data", (chunk) => {
25
+ stdout += chunk;
26
+ onOutput?.(chunk);
27
+ });
28
+ child.stderr?.on("data", (chunk) => {
29
+ stderr += chunk;
30
+ onOutput?.(chunk);
31
+ });
32
+ child.on("exit", (code) => {
33
+ resolve({ exitCode: code ?? 1, stdout, stderr });
34
+ });
35
+ child.on("error", (err) => {
36
+ reject(err);
37
+ });
38
+ if (signal) {
39
+ signal.addEventListener("abort", () => {
40
+ child.kill("SIGTERM");
41
+ }, { once: true });
42
+ }
43
+ });
44
+ }
45
+ }
@@ -0,0 +1,13 @@
1
+ interface CachedSession {
2
+ sessionToken: string;
3
+ expiresAt: number;
4
+ user?: string;
5
+ }
6
+ declare function getBaseUrl(): string;
7
+ declare function getSessionToken(): string | null;
8
+ export declare function getSession(): CachedSession | null;
9
+ declare function requireToken(): string;
10
+ export declare function get<T = unknown>(path: string): Promise<T>;
11
+ export declare function post<T = unknown>(path: string, body?: unknown): Promise<T>;
12
+ export declare function poll<T = unknown>(path: string, intervalMs: number, condition: (data: T) => boolean, signal?: AbortSignal): Promise<T>;
13
+ export { getBaseUrl, getSessionToken, requireToken };
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".co-ode");
5
+ const SESSION_FILE = join(CONFIG_DIR, "session.json");
6
+ function getBaseUrl() {
7
+ return (process.env.CO_ODE_SERVER || "https://co-ode.vercel.app");
8
+ }
9
+ function getSessionToken() {
10
+ try {
11
+ const data = JSON.parse(readFileSync(SESSION_FILE, "utf-8"));
12
+ if (data.expiresAt > Date.now()) {
13
+ return data.sessionToken;
14
+ }
15
+ }
16
+ catch {
17
+ // No session
18
+ }
19
+ return null;
20
+ }
21
+ export function getSession() {
22
+ try {
23
+ const data = JSON.parse(readFileSync(SESSION_FILE, "utf-8"));
24
+ if (data.expiresAt > Date.now()) {
25
+ return data;
26
+ }
27
+ }
28
+ catch {
29
+ // No session
30
+ }
31
+ return null;
32
+ }
33
+ function requireToken() {
34
+ const token = getSessionToken();
35
+ if (!token) {
36
+ console.error("[co-od] Not logged in. Run `co-od login` first.");
37
+ process.exit(1);
38
+ }
39
+ return token;
40
+ }
41
+ function headers(token) {
42
+ return {
43
+ "content-type": "application/json",
44
+ authorization: `Bearer ${token}`,
45
+ };
46
+ }
47
+ export async function get(path) {
48
+ const token = requireToken();
49
+ const url = `${getBaseUrl()}${path}`;
50
+ const res = await fetch(url, { headers: headers(token) });
51
+ if (!res.ok) {
52
+ throw new Error(`GET ${path} failed: ${res.status} ${res.statusText}`);
53
+ }
54
+ return res.json();
55
+ }
56
+ export async function post(path, body) {
57
+ const token = requireToken();
58
+ const url = `${getBaseUrl()}${path}`;
59
+ const res = await fetch(url, {
60
+ method: "POST",
61
+ headers: headers(token),
62
+ body: body !== undefined ? JSON.stringify(body) : undefined,
63
+ });
64
+ if (!res.ok) {
65
+ throw new Error(`POST ${path} failed: ${res.status} ${res.statusText}`);
66
+ }
67
+ return res.json();
68
+ }
69
+ export async function poll(path, intervalMs, condition, signal) {
70
+ while (true) {
71
+ if (signal?.aborted) {
72
+ throw new Error("Polling aborted");
73
+ }
74
+ const data = await get(path);
75
+ if (condition(data)) {
76
+ return data;
77
+ }
78
+ await new Promise((resolve, reject) => {
79
+ const timer = setTimeout(resolve, intervalMs);
80
+ if (signal) {
81
+ signal.addEventListener("abort", () => {
82
+ clearTimeout(timer);
83
+ reject(new Error("Polling aborted"));
84
+ }, { once: true });
85
+ }
86
+ });
87
+ }
88
+ }
89
+ export { getBaseUrl, getSessionToken, requireToken };
package/dist/auth.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ interface AuthResult {
2
+ sessionToken: string;
3
+ realtimeToken: string;
4
+ terminalWsUrl: string;
5
+ }
6
+ export declare function authenticate({ serverUrl, roomId, inviteToken, }: {
7
+ serverUrl: string;
8
+ roomId: string;
9
+ inviteToken?: string;
10
+ }): Promise<AuthResult>;
11
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,154 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".co-ode");
5
+ const SESSION_FILE = join(CONFIG_DIR, "session.json");
6
+ function loadCachedSession() {
7
+ try {
8
+ const data = JSON.parse(readFileSync(SESSION_FILE, "utf-8"));
9
+ if (data.expiresAt > Date.now()) {
10
+ return data;
11
+ }
12
+ }
13
+ catch {
14
+ // No cached session
15
+ }
16
+ return null;
17
+ }
18
+ function saveCachedSession(session) {
19
+ try {
20
+ mkdirSync(CONFIG_DIR, { recursive: true });
21
+ writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
22
+ }
23
+ catch {
24
+ // Non-fatal
25
+ }
26
+ }
27
+ async function deviceAuthFlow(serverUrl) {
28
+ // Step 1: Request a device code
29
+ const authorizeRes = await fetch(`${serverUrl}/api/auth/cli/authorize`, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json" },
32
+ body: JSON.stringify({}),
33
+ });
34
+ if (!authorizeRes.ok) {
35
+ throw new Error(`Failed to start auth flow: ${authorizeRes.status}`);
36
+ }
37
+ const { code, verificationUrl, expiresAt } = (await authorizeRes.json());
38
+ // Step 2: Open browser for approval
39
+ console.error(`\n[co-ode] To authenticate, open this URL in your browser:\n`);
40
+ console.error(` ${verificationUrl}\n`);
41
+ console.error(`[co-ode] Your code: ${code}\n`);
42
+ try {
43
+ const open = (await import("open")).default;
44
+ await open(verificationUrl);
45
+ console.error(`[co-ode] Browser opened. Waiting for approval...`);
46
+ }
47
+ catch {
48
+ console.error(`[co-ode] Could not open browser automatically. Please open the URL manually.`);
49
+ }
50
+ // Step 3: Poll for token
51
+ const pollInterval = 2000;
52
+ const deadline = expiresAt;
53
+ while (Date.now() < deadline) {
54
+ await sleep(pollInterval);
55
+ const tokenRes = await fetch(`${serverUrl}/api/auth/cli/token`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify({ code }),
59
+ });
60
+ if (tokenRes.status === 410) {
61
+ throw new Error("Auth code expired. Please try again.");
62
+ }
63
+ if (tokenRes.status === 202) {
64
+ // Still pending
65
+ continue;
66
+ }
67
+ if (tokenRes.ok) {
68
+ const { token } = (await tokenRes.json());
69
+ if (token) {
70
+ // Cache for 23 hours (tokens last 24h)
71
+ saveCachedSession({
72
+ sessionToken: token,
73
+ expiresAt: Date.now() + 23 * 60 * 60 * 1000,
74
+ });
75
+ return token;
76
+ }
77
+ }
78
+ throw new Error(`Unexpected auth response: ${tokenRes.status}`);
79
+ }
80
+ throw new Error("Auth timed out. Please try again.");
81
+ }
82
+ export async function authenticate({ serverUrl, roomId, inviteToken, }) {
83
+ // Try cached session first
84
+ let sessionToken;
85
+ const cached = loadCachedSession();
86
+ if (cached) {
87
+ console.error(`[co-ode] Using cached session`);
88
+ sessionToken = cached.sessionToken;
89
+ }
90
+ else {
91
+ // If invite token provided, redeem it first
92
+ if (inviteToken) {
93
+ console.error(`[co-ode] Redeeming invite token...`);
94
+ const inviteRes = await fetch(`${serverUrl}/api/invites`, {
95
+ method: "POST",
96
+ headers: { "content-type": "application/json" },
97
+ body: JSON.stringify({ token: inviteToken }),
98
+ });
99
+ if (!inviteRes.ok) {
100
+ console.error(`[co-ode] Warning: invite redemption failed (${inviteRes.status}), continuing with auth...`);
101
+ }
102
+ }
103
+ sessionToken = await deviceAuthFlow(serverUrl);
104
+ }
105
+ // Get realtime session for the room
106
+ console.error(`[co-ode] Getting realtime session for room ${roomId}...`);
107
+ const realtimeRes = await fetch(`${serverUrl}/api/rooms/${roomId}/realtime/session`, {
108
+ method: "POST",
109
+ headers: {
110
+ "content-type": "application/json",
111
+ authorization: `Bearer ${sessionToken}`,
112
+ },
113
+ body: JSON.stringify({ ttlSeconds: 3600 }),
114
+ });
115
+ if (!realtimeRes.ok) {
116
+ // Cached session may be invalid, clear it and retry
117
+ if (cached) {
118
+ console.error(`[co-ode] Cached session invalid, re-authenticating...`);
119
+ try {
120
+ const { unlinkSync } = await import("node:fs");
121
+ unlinkSync(SESSION_FILE);
122
+ }
123
+ catch { /* ignore */ }
124
+ sessionToken = await deviceAuthFlow(serverUrl);
125
+ const retryRes = await fetch(`${serverUrl}/api/rooms/${roomId}/realtime/session`, {
126
+ method: "POST",
127
+ headers: {
128
+ "content-type": "application/json",
129
+ authorization: `Bearer ${sessionToken}`,
130
+ },
131
+ body: JSON.stringify({ ttlSeconds: 3600 }),
132
+ });
133
+ if (!retryRes.ok) {
134
+ throw new Error(`Failed to get realtime session: ${retryRes.status}`);
135
+ }
136
+ const data = (await retryRes.json());
137
+ return {
138
+ sessionToken,
139
+ realtimeToken: data.token,
140
+ terminalWsUrl: data.terminalWsUrl,
141
+ };
142
+ }
143
+ throw new Error(`Failed to get realtime session: ${realtimeRes.status}`);
144
+ }
145
+ const realtimeData = (await realtimeRes.json());
146
+ return {
147
+ sessionToken,
148
+ realtimeToken: realtimeData.token,
149
+ terminalWsUrl: realtimeData.terminalWsUrl,
150
+ };
151
+ }
152
+ function sleep(ms) {
153
+ return new Promise((resolve) => setTimeout(resolve, ms));
154
+ }
@@ -0,0 +1 @@
1
+ export declare function run(args: string[]): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import * as api from "../api-client.js";
2
+ function parseArgs(args) {
3
+ const parsed = {
4
+ provider: "claude",
5
+ dir: process.cwd(),
6
+ };
7
+ for (let i = 0; i < args.length; i++) {
8
+ if (args[i] === "--provider" && args[i + 1]) {
9
+ parsed.provider = args[++i];
10
+ }
11
+ else if (args[i] === "--dir" && args[i + 1]) {
12
+ parsed.dir = args[++i];
13
+ }
14
+ else if (args[i] === "--server" && args[i + 1]) {
15
+ parsed.server = args[++i];
16
+ }
17
+ else if (!args[i].startsWith("--")) {
18
+ parsed.code = args[i];
19
+ }
20
+ }
21
+ return parsed;
22
+ }
23
+ export async function run(args) {
24
+ const parsed = parseArgs(args);
25
+ if (!parsed.code) {
26
+ console.error("Usage: co-od connect <relay-code>");
27
+ process.exit(1);
28
+ }
29
+ if (parsed.server) {
30
+ process.env.CO_ODE_SERVER = parsed.server;
31
+ }
32
+ console.error(`[co-od] Connecting to relay ${parsed.code}...`);
33
+ const res = await api.post("/api/relay/join", {
34
+ code: parsed.code,
35
+ });
36
+ if (!res.ok) {
37
+ console.error(`[co-od] Failed to join relay. Code may be invalid or expired.`);
38
+ process.exit(1);
39
+ }
40
+ console.log(`Connected to relay ${parsed.code}`);
41
+ console.log(`You can now dispatch tasks to the shared machine.`);
42
+ console.log(`\nRelay ID: ${res.relayId}`);
43
+ }
@@ -0,0 +1 @@
1
+ export declare function run(args: string[]): Promise<void>;