condcli 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/CODEOWNERS ADDED
@@ -0,0 +1 @@
1
+ * @baptistelaget
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # conductorcli
2
+
3
+ > **Unofficial** community tool — not affiliated with, endorsed by, or supported by [Conductor](https://conductor.build).
4
+
5
+ A CLI for [Conductor](https://conductor.build) — query workspaces, sessions, and launch deep links from your terminal.
6
+
7
+ Reads directly from Conductor's local SQLite database and triggers actions via `conductor://` deep links.
8
+
9
+ ## Install
10
+
11
+ Requires [Bun](https://bun.sh) (uses `bun:sqlite` for zero-dependency SQLite access).
12
+
13
+ ```bash
14
+ # run directly
15
+ bunx @baptistelaget/conductorcli status
16
+
17
+ # or install globally
18
+ bun install -g @baptistelaget/conductorcli
19
+ ```
20
+
21
+ ## Commands
22
+
23
+ ### Query
24
+
25
+ ```bash
26
+ # overview + recent activity
27
+ conductorcli status
28
+
29
+ # list repositories
30
+ conductorcli repos
31
+
32
+ # list workspaces (active by default)
33
+ conductorcli ws
34
+ conductorcli ws -f all
35
+ conductorcli ws -f archived
36
+
37
+ # workspace detail (supports partial IDs)
38
+ conductorcli workspace 7d4e
39
+
40
+ # list sessions
41
+ conductorcli ss
42
+ conductorcli ss -w 7d4e # filter by workspace
43
+
44
+ # session detail + message history
45
+ conductorcli session f969
46
+ conductorcli session f969 -m 10 # show last 10 messages
47
+
48
+ # search by branch, title, PR, or repo name
49
+ conductorcli search auth
50
+ ```
51
+
52
+ ### Create
53
+
54
+ ```bash
55
+ # new workspace with a prompt
56
+ conductorcli prompt "Fix the login bug"
57
+
58
+ # target a specific repo
59
+ conductorcli prompt -r my-repo "Add dark mode"
60
+ conductorcli prompt -d /path/to/repo "Add dark mode"
61
+
62
+ # new workspace from a Linear issue
63
+ conductorcli linear ENG-123
64
+ conductorcli linear ENG-123 "Implement with full test coverage"
65
+ conductorcli linear -r my-repo ENG-123
66
+
67
+ # new workspace from a plan file
68
+ conductorcli plan my-repo plan.md
69
+ echo "Fix all the bugs" | conductorcli plan my-repo -
70
+ ```
71
+
72
+ ### Server
73
+
74
+ Expose everything as a REST API:
75
+
76
+ ```bash
77
+ # start on default port 3141
78
+ conductorcli serve
79
+
80
+ # custom port
81
+ conductorcli serve -p 8080
82
+ ```
83
+
84
+ **Query endpoints:**
85
+
86
+ ```bash
87
+ curl localhost:3141/status
88
+ curl localhost:3141/repos
89
+ curl localhost:3141/workspaces?filter=all&limit=10
90
+ curl localhost:3141/workspaces/7d4e
91
+ curl localhost:3141/sessions?workspace=7d4e&limit=5
92
+ curl localhost:3141/sessions/f969
93
+ curl localhost:3141/sessions/f969/messages?limit=10
94
+ curl localhost:3141/search?q=auth
95
+ ```
96
+
97
+ **Action endpoints:**
98
+
99
+ ```bash
100
+ # new workspace with prompt
101
+ curl -X POST localhost:3141/prompt \
102
+ -H 'Content-Type: application/json' \
103
+ -d '{"message": "Fix the login bug", "repo": "my-repo"}'
104
+
105
+ # new workspace from Linear issue
106
+ curl -X POST localhost:3141/linear \
107
+ -H 'Content-Type: application/json' \
108
+ -d '{"issueId": "ENG-123", "prompt": "Implement with tests", "repo": "my-repo"}'
109
+
110
+ # new workspace from plan
111
+ curl -X POST localhost:3141/plan \
112
+ -H 'Content-Type: application/json' \
113
+ -d '{"repo": "my-repo", "plan": "## Plan\n\n- Fix all the bugs"}'
114
+ ```
115
+
116
+ ## How it works
117
+
118
+ **Read operations** query Conductor's SQLite database at:
119
+ ```
120
+ ~/Library/Application Support/com.conductor.app/conductor.db
121
+ ```
122
+
123
+ **Create operations** trigger `conductor://` deep links via `open`, which Conductor.app handles natively:
124
+
125
+ | Deep link | Action |
126
+ |---|---|
127
+ | `conductor://prompt=...&path=...` | New workspace with prompt |
128
+ | `conductor://linear_id=...` | New workspace from Linear issue |
129
+ | `conductor://async?repo=...&plan=...` | New workspace from base64 plan |
130
+
131
+ ## Requirements
132
+
133
+ - macOS (Conductor.app is macOS-only)
134
+ - [Bun](https://bun.sh) >= 1.0
135
+ - [Conductor.app](https://conductor.build) installed and signed in
136
+
137
+ ## Roadmap
138
+
139
+ These features aren't currently possible because Conductor doesn't expose public APIs or deep links for them. If Conductor adds official support (or if someone finds a clean way to do it), we'd love to add them:
140
+
141
+ - [ ] **Send follow-up messages** — send a prompt to an existing workspace/session without creating a new one
142
+ - [ ] **Create PR** — trigger the "Create PR" workflow on a workspace from the CLI
143
+ - [ ] **Approve/reject plans** — respond to pending plan reviews programmatically
144
+ - [ ] **Workspace management** — archive, pin, rename branches, or change session config from outside the app
145
+
146
+ Today, Conductor's sidecar communicates over an internal Unix socket using an undocumented JSON-RPC protocol. Until there's a stable way to interact with running sessions, these will remain on the wishlist.
147
+
148
+ ## Contributing
149
+
150
+ Feel free to open issues and PRs — contributions are welcome.
151
+
152
+ If you found a new deep link, found a way to talk to the sidecar, or just want to improve the CLI, go for it.
153
+
154
+ ## Disclaimer
155
+
156
+ This is an **unofficial, community-built tool**. It is not created, maintained, endorsed, or supported by the Conductor team or any of its affiliates.
157
+
158
+ - **No warranty.** This software is provided "as is", without warranty of any kind, express or implied. Use at your own risk.
159
+ - **May break at any time.** This tool relies on Conductor's internal SQLite schema and undocumented `conductor://` deep link protocol, both of which may change without notice in any Conductor update.
160
+ - **Read-only database access.** The CLI opens the database in read-only mode and never writes to it. Create operations work exclusively through deep links handled by Conductor.app itself.
161
+ - **"Conductor" _might be_ a trademark** of its respective owner. This project uses the name solely to describe compatibility.
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli.ts";
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "condcli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Conductor.app — query workspaces, sessions, and launch deep links",
5
+ "type": "module",
6
+ "bin": {
7
+ "conductorcli": "./bin/conductorcli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "bun run src/cli.ts",
11
+ "typecheck": "tsc --noEmit",
12
+ "prepare": "husky"
13
+ },
14
+ "devDependencies": {
15
+ "@commitlint/cli": "^20.5.0",
16
+ "@commitlint/config-conventional": "^20.5.0",
17
+ "@types/bun": "^1.0.0",
18
+ "husky": "^9.1.7",
19
+ "typescript": "^5.7.0"
20
+ },
21
+ "dependencies": {
22
+ "commander": "^13.0.0",
23
+ "chalk": "^5.4.0"
24
+ },
25
+ "keywords": [
26
+ "conductor",
27
+ "cli",
28
+ "conductor.build",
29
+ "workspace",
30
+ "linear",
31
+ "deeplink"
32
+ ],
33
+ "author": "baptistelaget",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/baptistelaget/conductorcli"
37
+ },
38
+ "license": "MIT",
39
+ "os": [
40
+ "darwin"
41
+ ],
42
+ "engines": {
43
+ "bun": ">=1.0.0"
44
+ },
45
+ "files": [
46
+ "bin/conductorcli.js",
47
+ "src/**/*.ts",
48
+ "package.json",
49
+ "README.md",
50
+ "CODEOWNERS"
51
+ ]
52
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import { readFileSync } from "fs";
6
+ import {
7
+ listRepos,
8
+ listWorkspaces,
9
+ getWorkspace,
10
+ listSessions,
11
+ getSession,
12
+ listMessages,
13
+ searchWorkspaces,
14
+ getStats,
15
+ } from "./db.js";
16
+ import {
17
+ formatRepos,
18
+ formatWorkspaces,
19
+ formatWorkspaceDetail,
20
+ formatSessions,
21
+ formatSessionDetail,
22
+ formatMessages,
23
+ formatStats,
24
+ } from "./format.js";
25
+ import { openPrompt, openLinear, openPlan } from "./deeplink.js";
26
+ import { startServer } from "./server.js";
27
+
28
+ const program = new Command();
29
+
30
+ function resolveRepoPath(name: string): string {
31
+ const repos = listRepos();
32
+ const match = repos.find(
33
+ (r) => r.name.toLowerCase() === name.toLowerCase()
34
+ );
35
+ if (!match) {
36
+ console.error(chalk.red(`\nRepo not found: ${name}\n`));
37
+ console.log(chalk.dim("Available repos:"));
38
+ for (const r of repos)
39
+ console.log(` ${chalk.cyan(r.name)} ${chalk.dim(r.root_path ?? "")}`);
40
+ console.log();
41
+ process.exit(1);
42
+ }
43
+ if (!match.root_path) {
44
+ console.error(chalk.red(`\nRepo "${name}" has no local path.\n`));
45
+ process.exit(1);
46
+ }
47
+ return match.root_path;
48
+ }
49
+
50
+ program
51
+ .name("conductorcli")
52
+ .description("CLI for Conductor.app")
53
+ .version("0.1.0");
54
+
55
+ // ── status ─────────────────────────────────────────────
56
+
57
+ program
58
+ .command("status")
59
+ .description("Overview and recent activity")
60
+ .action(() => {
61
+ const stats = getStats();
62
+ console.log(chalk.bold("\nConductor Status\n"));
63
+ console.log(formatStats(stats));
64
+
65
+ const active = listWorkspaces("active").slice(0, 5);
66
+ if (active.length) {
67
+ console.log(chalk.bold("\n\nRecent Active Workspaces\n"));
68
+ console.log(formatWorkspaces(active, "active"));
69
+ }
70
+ console.log();
71
+ });
72
+
73
+ // ── repos ──────────────────────────────────────────────
74
+
75
+ program
76
+ .command("repos")
77
+ .description("List repositories")
78
+ .action(() => {
79
+ const repos = listRepos();
80
+ console.log(chalk.bold("\nRepositories\n"));
81
+ console.log(formatRepos(repos));
82
+ console.log();
83
+ });
84
+
85
+ // ── workspaces ─────────────────────────────────────────
86
+
87
+ program
88
+ .command("workspaces")
89
+ .alias("ws")
90
+ .description("List workspaces")
91
+ .option("-f, --filter <filter>", "active | archived | pinned | all", "active")
92
+ .option("-n, --limit <n>", "max results", "30")
93
+ .action((opts) => {
94
+ const filter = opts.filter as "active" | "archived" | "pinned" | "all";
95
+ const workspaces = listWorkspaces(filter).slice(0, parseInt(opts.limit));
96
+ console.log(chalk.bold(`\nWorkspaces`) + chalk.dim(` (${filter})\n`));
97
+ console.log(formatWorkspaces(workspaces, filter));
98
+ console.log();
99
+ });
100
+
101
+ // ── workspace detail ───────────────────────────────────
102
+
103
+ program
104
+ .command("workspace <id>")
105
+ .description("Show workspace details (supports partial ID)")
106
+ .action((id) => {
107
+ const ws = getWorkspace(id);
108
+ if (!ws) {
109
+ console.error(chalk.red(`No workspace found matching: ${id}`));
110
+ process.exit(1);
111
+ }
112
+ console.log();
113
+ console.log(formatWorkspaceDetail(ws));
114
+ console.log();
115
+ });
116
+
117
+ // ── sessions ───────────────────────────────────────────
118
+
119
+ program
120
+ .command("sessions")
121
+ .alias("ss")
122
+ .description("List sessions")
123
+ .option("-w, --workspace <id>", "filter by workspace ID prefix")
124
+ .option("-n, --limit <n>", "max results", "20")
125
+ .action((opts) => {
126
+ const sessions = listSessions(opts.workspace).slice(
127
+ 0,
128
+ parseInt(opts.limit)
129
+ );
130
+ const label = opts.workspace ? `workspace ${opts.workspace}` : "all";
131
+ console.log(chalk.bold(`\nSessions`) + chalk.dim(` (${label})\n`));
132
+ console.log(formatSessions(sessions));
133
+ console.log();
134
+ });
135
+
136
+ // ── session detail ─────────────────────────────────────
137
+
138
+ program
139
+ .command("session <id>")
140
+ .description("Show session details (supports partial ID)")
141
+ .option("-m, --messages [n]", "show last N messages", "0")
142
+ .action((id, opts) => {
143
+ const session = getSession(id);
144
+ if (!session) {
145
+ console.error(chalk.red(`No session found matching: ${id}`));
146
+ process.exit(1);
147
+ }
148
+ console.log();
149
+ console.log(formatSessionDetail(session));
150
+
151
+ const msgCount = parseInt(opts.messages);
152
+ if (msgCount > 0) {
153
+ const messages = listMessages(id, msgCount);
154
+ console.log(chalk.bold("\n\nMessages\n"));
155
+ console.log(formatMessages(messages));
156
+ }
157
+ console.log();
158
+ });
159
+
160
+ // ── search ─────────────────────────────────────────────
161
+
162
+ program
163
+ .command("search <query>")
164
+ .description("Search workspaces by branch, title, PR, or repo")
165
+ .action((query) => {
166
+ const results = searchWorkspaces(query);
167
+ console.log(
168
+ chalk.bold(`\nSearch: `) + chalk.cyan(query) + chalk.dim(` (${results.length} results)\n`)
169
+ );
170
+ console.log(formatWorkspaces(results, "search"));
171
+ console.log();
172
+ });
173
+
174
+ // ── prompt ─────────────────────────────────────────────
175
+
176
+ program
177
+ .command("prompt <message...>")
178
+ .alias("p")
179
+ .description("Create a new workspace with a prompt")
180
+ .option("-d, --dir <path>", "target repo directory path")
181
+ .option("-r, --repo <name>", "target repo by name (resolved to path)")
182
+ .action(async (messageParts, opts) => {
183
+ const message = messageParts.join(" ");
184
+ let dir: string | undefined = opts.dir;
185
+ if (opts.repo && !dir) {
186
+ dir = resolveRepoPath(opts.repo);
187
+ }
188
+ console.log(
189
+ chalk.bold("\nCreating workspace") +
190
+ (dir ? chalk.dim(` in ${dir}`) : "") +
191
+ "\n"
192
+ );
193
+ console.log(` ${chalk.dim("Prompt:")} ${message}`);
194
+ await openPrompt(message, dir);
195
+ console.log(chalk.green("\n ✓ Deep link sent to Conductor.app\n"));
196
+ });
197
+
198
+ // ── linear ─────────────────────────────────────────────
199
+
200
+ program
201
+ .command("linear <issueId> [prompt...]")
202
+ .alias("lin")
203
+ .description("Create a workspace from a Linear issue")
204
+ .option("-r, --repo <name>", "target repo by name")
205
+ .action(async (issueId, promptParts, opts) => {
206
+ const prompt = promptParts?.length ? promptParts.join(" ") : undefined;
207
+ const repoPath = opts.repo ? resolveRepoPath(opts.repo) : undefined;
208
+
209
+ console.log(chalk.bold("\nCreating workspace from Linear issue\n"));
210
+ console.log(` ${chalk.dim("Issue:")} ${chalk.cyan(issueId)}`);
211
+ if (repoPath) console.log(` ${chalk.dim("Repo:")} ${repoPath}`);
212
+ if (prompt) console.log(` ${chalk.dim("Prompt:")} ${prompt}`);
213
+ await openLinear(issueId, prompt, repoPath);
214
+ console.log(chalk.green("\n ✓ Deep link sent to Conductor.app\n"));
215
+ });
216
+
217
+ // ── plan ───────────────────────────────────────────────
218
+
219
+ program
220
+ .command("plan <repo> <file>")
221
+ .description("Create a workspace from a plan file (use - for stdin)")
222
+ .action(async (repo, file) => {
223
+ let content: string;
224
+ if (file === "-") {
225
+ const chunks: Buffer[] = [];
226
+ for await (const chunk of process.stdin) chunks.push(chunk);
227
+ content = Buffer.concat(chunks).toString("utf-8");
228
+ } else {
229
+ content = readFileSync(file, "utf-8");
230
+ }
231
+ console.log(chalk.bold("\nCreating workspace from plan\n"));
232
+ console.log(` ${chalk.dim("Repo:")} ${chalk.cyan(repo)}`);
233
+ console.log(` ${chalk.dim("File:")} ${file}`);
234
+ await openPlan(repo, content);
235
+ console.log(chalk.green("\n ✓ Deep link sent to Conductor.app\n"));
236
+ });
237
+
238
+ // ── serve ──────────────────────────────────────────────
239
+
240
+ program
241
+ .command("serve")
242
+ .description("Start a REST API server")
243
+ .option("-p, --port <port>", "port number", "3141")
244
+ .action((opts) => {
245
+ startServer(parseInt(opts.port));
246
+ });
247
+
248
+ program.parse();
package/src/db.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ const DB_PATH = join(
6
+ homedir(),
7
+ "Library",
8
+ "Application Support",
9
+ "com.conductor.app",
10
+ "conductor.db"
11
+ );
12
+
13
+ let _db: Database | null = null;
14
+
15
+ export function getDb(): Database {
16
+ if (!_db) {
17
+ _db = new Database(DB_PATH, { readonly: true });
18
+ _db.exec("PRAGMA journal_mode = WAL");
19
+ }
20
+ return _db;
21
+ }
22
+
23
+ // ── Types ──────────────────────────────────────────────
24
+
25
+ export interface Repo {
26
+ id: string;
27
+ name: string;
28
+ remote_url: string | null;
29
+ root_path: string | null;
30
+ default_branch: string;
31
+ display_order: number;
32
+ hidden: number;
33
+ icon: string | null;
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+
38
+ export interface Workspace {
39
+ id: string;
40
+ repository_id: string;
41
+ directory_name: string | null;
42
+ branch: string | null;
43
+ state: string;
44
+ derived_status: string;
45
+ manual_status: string | null;
46
+ active_session_id: string | null;
47
+ pinned_at: string | null;
48
+ pr_title: string | null;
49
+ pr_description: string | null;
50
+ notes: string | null;
51
+ intended_target_branch: string | null;
52
+ created_at: string;
53
+ updated_at: string;
54
+ // joined
55
+ repo_name?: string;
56
+ repo_path?: string;
57
+ session_title?: string;
58
+ session_status?: string;
59
+ }
60
+
61
+ export interface Session {
62
+ id: string;
63
+ workspace_id: string | null;
64
+ status: string;
65
+ title: string;
66
+ agent_type: string | null;
67
+ model: string | null;
68
+ permission_mode: string;
69
+ thinking_enabled: number;
70
+ fast_mode: number;
71
+ context_used_percent: number | null;
72
+ context_token_count: number;
73
+ unread_count: number;
74
+ last_user_message_at: string | null;
75
+ created_at: string;
76
+ updated_at: string;
77
+ // joined
78
+ branch?: string;
79
+ repo_name?: string;
80
+ }
81
+
82
+ export interface SessionMessage {
83
+ id: string;
84
+ session_id: string;
85
+ role: string;
86
+ content: string | null;
87
+ sent_at: string | null;
88
+ cancelled_at: string | null;
89
+ model: string | null;
90
+ turn_id: string | null;
91
+ created_at: string;
92
+ }
93
+
94
+ // ── Queries ────────────────────────────────────────────
95
+
96
+ export function listRepos(): Repo[] {
97
+ return getDb()
98
+ .prepare(
99
+ `SELECT * FROM repos WHERE hidden = 0 ORDER BY display_order, name`
100
+ )
101
+ .all() as Repo[];
102
+ }
103
+
104
+ export function listWorkspaces(
105
+ filter: "active" | "archived" | "pinned" | "all" = "active"
106
+ ): Workspace[] {
107
+ const where: Record<string, string> = {
108
+ active: "WHERE w.state = 'active'",
109
+ archived: "WHERE w.state = 'archived'",
110
+ pinned: "WHERE w.pinned_at IS NOT NULL",
111
+ all: "",
112
+ };
113
+
114
+ return getDb()
115
+ .prepare(
116
+ `
117
+ SELECT
118
+ w.*,
119
+ r.name AS repo_name,
120
+ r.root_path AS repo_path,
121
+ s.title AS session_title,
122
+ s.status AS session_status
123
+ FROM workspaces w
124
+ LEFT JOIN repos r ON r.id = w.repository_id
125
+ LEFT JOIN sessions s ON s.id = w.active_session_id
126
+ ${where[filter]}
127
+ ORDER BY w.updated_at DESC
128
+ `
129
+ )
130
+ .all() as Workspace[];
131
+ }
132
+
133
+ export function getWorkspace(idPrefix: string): Workspace | undefined {
134
+ return getDb()
135
+ .prepare(
136
+ `
137
+ SELECT
138
+ w.*,
139
+ r.name AS repo_name,
140
+ r.root_path AS repo_path,
141
+ s.title AS session_title,
142
+ s.status AS session_status
143
+ FROM workspaces w
144
+ LEFT JOIN repos r ON r.id = w.repository_id
145
+ LEFT JOIN sessions s ON s.id = w.active_session_id
146
+ WHERE w.id LIKE ? || '%'
147
+ LIMIT 1
148
+ `
149
+ )
150
+ .get(idPrefix) as Workspace | undefined;
151
+ }
152
+
153
+ export function listSessions(workspaceId?: string): Session[] {
154
+ if (workspaceId) {
155
+ return getDb()
156
+ .prepare(
157
+ `
158
+ SELECT
159
+ s.*,
160
+ w.branch,
161
+ r.name AS repo_name
162
+ FROM sessions s
163
+ LEFT JOIN workspaces w ON w.id = s.workspace_id
164
+ LEFT JOIN repos r ON r.id = w.repository_id
165
+ WHERE s.workspace_id LIKE ? || '%'
166
+ ORDER BY s.updated_at DESC
167
+ `
168
+ )
169
+ .all(workspaceId) as Session[];
170
+ }
171
+
172
+ return getDb()
173
+ .prepare(
174
+ `
175
+ SELECT
176
+ s.*,
177
+ w.branch,
178
+ r.name AS repo_name
179
+ FROM sessions s
180
+ LEFT JOIN workspaces w ON w.id = s.workspace_id
181
+ LEFT JOIN repos r ON r.id = w.repository_id
182
+ ORDER BY s.updated_at DESC
183
+ `
184
+ )
185
+ .all() as Session[];
186
+ }
187
+
188
+ export function getSession(idPrefix: string): Session | undefined {
189
+ return getDb()
190
+ .prepare(
191
+ `
192
+ SELECT
193
+ s.*,
194
+ w.branch,
195
+ r.name AS repo_name
196
+ FROM sessions s
197
+ LEFT JOIN workspaces w ON w.id = s.workspace_id
198
+ LEFT JOIN repos r ON r.id = w.repository_id
199
+ WHERE s.id LIKE ? || '%'
200
+ LIMIT 1
201
+ `
202
+ )
203
+ .get(idPrefix) as Session | undefined;
204
+ }
205
+
206
+ export function listMessages(
207
+ sessionId: string,
208
+ limit = 20
209
+ ): SessionMessage[] {
210
+ return getDb()
211
+ .prepare(
212
+ `
213
+ SELECT id, session_id, role, content, sent_at, cancelled_at, model, turn_id, created_at
214
+ FROM session_messages
215
+ WHERE session_id LIKE ? || '%'
216
+ AND cancelled_at IS NULL
217
+ ORDER BY created_at DESC
218
+ LIMIT ?
219
+ `
220
+ )
221
+ .all(sessionId, limit) as SessionMessage[];
222
+ }
223
+
224
+ export function searchWorkspaces(query: string): Workspace[] {
225
+ const pattern = `%${query}%`;
226
+ return getDb()
227
+ .prepare(
228
+ `
229
+ SELECT
230
+ w.*,
231
+ r.name AS repo_name,
232
+ r.root_path AS repo_path,
233
+ s.title AS session_title,
234
+ s.status AS session_status
235
+ FROM workspaces w
236
+ LEFT JOIN repos r ON r.id = w.repository_id
237
+ LEFT JOIN sessions s ON s.id = w.active_session_id
238
+ WHERE w.branch LIKE ?
239
+ OR s.title LIKE ?
240
+ OR w.pr_title LIKE ?
241
+ OR r.name LIKE ?
242
+ OR w.notes LIKE ?
243
+ ORDER BY w.updated_at DESC
244
+ LIMIT 30
245
+ `
246
+ )
247
+ .all(pattern, pattern, pattern, pattern, pattern) as Workspace[];
248
+ }
249
+
250
+ export function getStats() {
251
+ const db = getDb();
252
+ return {
253
+ repos: (db.prepare("SELECT COUNT(*) as c FROM repos WHERE hidden = 0").get() as any).c,
254
+ activeWorkspaces: (db.prepare("SELECT COUNT(*) as c FROM workspaces WHERE state = 'active'").get() as any).c,
255
+ archivedWorkspaces: (db.prepare("SELECT COUNT(*) as c FROM workspaces WHERE state = 'archived'").get() as any).c,
256
+ sessions: (db.prepare("SELECT COUNT(*) as c FROM sessions").get() as any).c,
257
+ messages: (db.prepare("SELECT COUNT(*) as c FROM session_messages").get() as any).c,
258
+ };
259
+ }
@@ -0,0 +1,31 @@
1
+ import { exec } from "child_process";
2
+
3
+ function openUrl(url: string): Promise<void> {
4
+ return new Promise((resolve, reject) => {
5
+ exec(`open ${JSON.stringify(url)}`, (err) => {
6
+ if (err) reject(err);
7
+ else resolve();
8
+ });
9
+ });
10
+ }
11
+
12
+ export async function openPrompt(prompt: string, path?: string) {
13
+ const parts: string[] = [];
14
+ if (path) parts.push(`path=${encodeURIComponent(path)}`);
15
+ parts.push(`prompt=${encodeURIComponent(prompt)}`);
16
+ await openUrl(`conductor://${parts.join("&")}`);
17
+ }
18
+
19
+ export async function openLinear(issueId: string, prompt?: string, path?: string) {
20
+ const parts = [`linear_id=${encodeURIComponent(issueId)}`];
21
+ if (path) parts.push(`path=${encodeURIComponent(path)}`);
22
+ if (prompt) parts.push(`prompt=${encodeURIComponent(prompt)}`);
23
+ await openUrl(`conductor://${parts.join("&")}`);
24
+ }
25
+
26
+ export async function openPlan(repo: string, planContent: string) {
27
+ const encoded = Buffer.from(planContent).toString("base64");
28
+ await openUrl(
29
+ `conductor://async?repo=${encodeURIComponent(repo)}&plan=${encoded}`
30
+ );
31
+ }
package/src/format.ts ADDED
@@ -0,0 +1,248 @@
1
+ import chalk from "chalk";
2
+ import type { Repo, Workspace, Session, SessionMessage } from "./db.js";
3
+
4
+ export function truncate(str: string, max: number): string {
5
+ if (str.length <= max) return str;
6
+ return str.slice(0, max - 1) + "…";
7
+ }
8
+
9
+ function relativeTime(iso: string): string {
10
+ const d = new Date(iso.endsWith("Z") ? iso : iso + "Z");
11
+ const now = Date.now();
12
+ const diff = now - d.getTime();
13
+ const mins = Math.floor(diff / 60_000);
14
+ if (mins < 1) return "just now";
15
+ if (mins < 60) return `${mins}m ago`;
16
+ const hours = Math.floor(mins / 60);
17
+ if (hours < 24) return `${hours}h ago`;
18
+ const days = Math.floor(hours / 24);
19
+ if (days < 30) return `${days}d ago`;
20
+ return d.toLocaleDateString();
21
+ }
22
+
23
+ function stateColor(state: string): (s: string) => string {
24
+ switch (state) {
25
+ case "active": return chalk.green;
26
+ case "ready": return chalk.green;
27
+ case "archived": return chalk.dim;
28
+ case "initializing": return chalk.yellow;
29
+ default: return (s: string) => s;
30
+ }
31
+ }
32
+
33
+ function statusColor(status: string): (s: string) => string {
34
+ switch (status) {
35
+ case "idle": return chalk.dim;
36
+ case "working": return chalk.green;
37
+ case "running": return chalk.green;
38
+ case "error": return chalk.red;
39
+ case "in-progress": return chalk.yellow;
40
+ case "ready": return chalk.green;
41
+ default: return (s: string) => s;
42
+ }
43
+ }
44
+
45
+ // ── Table helpers ──────────────────────────────────────
46
+
47
+ function pad(s: string, n: number): string {
48
+ return s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length);
49
+ }
50
+
51
+ function row(cols: [string, number][]): string {
52
+ return cols.map(([s, n]) => pad(s, n)).join(" ");
53
+ }
54
+
55
+ // ── Formatters ─────────────────────────────────────────
56
+
57
+ export function formatRepos(repos: Repo[]): string {
58
+ if (!repos.length) return chalk.dim(" No repositories found.");
59
+ const lines = [
60
+ chalk.dim(row([
61
+ ["NAME", 20], ["PATH", 45], ["BRANCH", 10], ["ID", 36],
62
+ ])),
63
+ ];
64
+ for (const r of repos) {
65
+ lines.push(row([
66
+ [chalk.cyan(r.name), 20],
67
+ [chalk.dim(r.root_path ?? "—"), 45],
68
+ [r.default_branch, 10],
69
+ [chalk.dim(r.id), 36],
70
+ ]));
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+
75
+ export function formatWorkspaces(workspaces: Workspace[], filter: string): string {
76
+ if (!workspaces.length) return chalk.dim(" No workspaces found.");
77
+ const lines = [
78
+ chalk.dim(row([
79
+ ["ID", 8], ["REPO", 14], ["BRANCH", 28], ["STATE", 10],
80
+ ["STATUS", 12], ["SESSION", 28], ["UPDATED", 10],
81
+ ])),
82
+ ];
83
+ for (const w of workspaces) {
84
+ const sc = stateColor(w.state);
85
+ const stc = statusColor(w.derived_status);
86
+ lines.push(row([
87
+ [chalk.dim(w.id.slice(0, 8)), 8],
88
+ [chalk.cyan(truncate(w.repo_name ?? "—", 14)), 14],
89
+ [truncate(w.branch ?? "—", 28), 28],
90
+ [sc(w.state), 10],
91
+ [stc(w.derived_status ?? "—"), 12],
92
+ [truncate(w.session_title ?? "—", 28), 28],
93
+ [chalk.dim(relativeTime(w.updated_at)), 10],
94
+ ]));
95
+ }
96
+ return lines.join("\n");
97
+ }
98
+
99
+ export function formatWorkspaceDetail(w: Workspace): string {
100
+ const lines: string[] = [];
101
+ lines.push(chalk.bold(`Workspace ${w.id}`));
102
+ lines.push("");
103
+ lines.push(` ${chalk.dim("Repo:")} ${chalk.cyan(w.repo_name ?? "—")}`);
104
+ lines.push(` ${chalk.dim("Path:")} ${w.repo_path ?? "—"}`);
105
+ lines.push(` ${chalk.dim("Branch:")} ${w.branch ?? "—"}`);
106
+ lines.push(` ${chalk.dim("Target:")} ${w.intended_target_branch ?? "—"}`);
107
+ lines.push(` ${chalk.dim("State:")} ${stateColor(w.state)(w.state)}`);
108
+ lines.push(` ${chalk.dim("Status:")} ${statusColor(w.derived_status)(w.derived_status)}`);
109
+ lines.push(` ${chalk.dim("Session:")} ${w.session_title ?? "—"} ${w.session_status ? chalk.dim(`(${w.session_status})`) : ""}`);
110
+ if (w.pr_title) lines.push(` ${chalk.dim("PR:")} ${w.pr_title}`);
111
+ if (w.notes) lines.push(` ${chalk.dim("Notes:")} ${truncate(w.notes, 60)}`);
112
+ if (w.pinned_at) lines.push(` ${chalk.dim("Pinned:")} ${relativeTime(w.pinned_at)}`);
113
+ lines.push(` ${chalk.dim("Created:")} ${relativeTime(w.created_at)}`);
114
+ lines.push(` ${chalk.dim("Updated:")} ${relativeTime(w.updated_at)}`);
115
+ return lines.join("\n");
116
+ }
117
+
118
+ export function formatSessions(sessions: Session[]): string {
119
+ if (!sessions.length) return chalk.dim(" No sessions found.");
120
+ const lines = [
121
+ chalk.dim(row([
122
+ ["ID", 8], ["TITLE", 30], ["STATUS", 9], ["AGENT", 7],
123
+ ["MODEL", 10], ["MODE", 8], ["BRANCH", 24], ["UPDATED", 10],
124
+ ])),
125
+ ];
126
+ for (const s of sessions) {
127
+ const sc = statusColor(s.status);
128
+ lines.push(row([
129
+ [chalk.dim(s.id.slice(0, 8)), 8],
130
+ [truncate(s.title ?? "Untitled", 30), 30],
131
+ [sc(s.status), 9],
132
+ [s.agent_type ?? "—", 7],
133
+ [chalk.magenta(s.model ?? "—"), 10],
134
+ [s.permission_mode ?? "—", 8],
135
+ [chalk.dim(truncate(s.branch ?? "—", 24)), 24],
136
+ [chalk.dim(relativeTime(s.updated_at)), 10],
137
+ ]));
138
+ }
139
+ return lines.join("\n");
140
+ }
141
+
142
+ export function formatSessionDetail(s: Session): string {
143
+ const lines: string[] = [];
144
+ lines.push(chalk.bold(`Session ${s.id}`));
145
+ lines.push("");
146
+ lines.push(` ${chalk.dim("Title:")} ${s.title}`);
147
+ lines.push(` ${chalk.dim("Status:")} ${statusColor(s.status)(s.status)}`);
148
+ lines.push(` ${chalk.dim("Agent:")} ${s.agent_type ?? "—"}`);
149
+ lines.push(` ${chalk.dim("Model:")} ${chalk.magenta(s.model ?? "—")}`);
150
+ lines.push(` ${chalk.dim("Mode:")} ${s.permission_mode}`);
151
+ lines.push(` ${chalk.dim("Thinking:")} ${s.thinking_enabled ? "on" : "off"}${s.fast_mode ? " (fast)" : ""}`);
152
+ lines.push(` ${chalk.dim("Repo:")} ${chalk.cyan(s.repo_name ?? "—")}`);
153
+ lines.push(` ${chalk.dim("Branch:")} ${s.branch ?? "—"}`);
154
+ if (s.context_used_percent != null) {
155
+ lines.push(` ${chalk.dim("Context:")} ${Math.round(s.context_used_percent)}% (${s.context_token_count.toLocaleString()} tokens)`);
156
+ }
157
+ lines.push(` ${chalk.dim("Unread:")} ${s.unread_count}`);
158
+ lines.push(` ${chalk.dim("Last message:")} ${s.last_user_message_at ? relativeTime(s.last_user_message_at) : "—"}`);
159
+ lines.push(` ${chalk.dim("Created:")} ${relativeTime(s.created_at)}`);
160
+ lines.push(` ${chalk.dim("Updated:")} ${relativeTime(s.updated_at)}`);
161
+ return lines.join("\n");
162
+ }
163
+
164
+ function extractMessageText(raw: string | null): string | null {
165
+ if (!raw) return null;
166
+ try {
167
+ const parsed = JSON.parse(raw);
168
+ // Claude session protocol: {"type":"user"|"assistant", "message": {...}}
169
+ if (parsed.type === "user" && parsed.message?.content) {
170
+ const content = parsed.message.content;
171
+ if (typeof content === "string") return content;
172
+ if (Array.isArray(content)) {
173
+ const text = content
174
+ .filter((b: any) => b.type === "text")
175
+ .map((b: any) => b.text)
176
+ .join("\n");
177
+ if (text) return text;
178
+ // tool results
179
+ const toolResults = content.filter((b: any) => b.type === "tool_result");
180
+ if (toolResults.length) return `[${toolResults.length} tool result(s)]`;
181
+ }
182
+ }
183
+ if (parsed.type === "assistant" && parsed.message?.content) {
184
+ const content = parsed.message.content;
185
+ if (typeof content === "string") return content;
186
+ if (Array.isArray(content)) {
187
+ const parts: string[] = [];
188
+ for (const b of content) {
189
+ if (b.type === "text") parts.push(b.text);
190
+ else if (b.type === "tool_use") parts.push(`[tool: ${b.name}]`);
191
+ }
192
+ return parts.join(" ") || null;
193
+ }
194
+ }
195
+ if (parsed.type === "result") {
196
+ const dur = parsed.duration_ms ? `${(parsed.duration_ms / 1000).toFixed(1)}s` : "";
197
+ return `[${parsed.subtype}${dur ? ` · ${dur}` : ""}]`;
198
+ }
199
+ // fallback: try to get something readable
200
+ if (typeof parsed === "string") return parsed;
201
+ } catch {
202
+ // not JSON, use raw
203
+ }
204
+ return raw;
205
+ }
206
+
207
+ export function formatMessages(messages: SessionMessage[]): string {
208
+ if (!messages.length) return chalk.dim(" No messages.");
209
+ const reversed = [...messages].reverse();
210
+ const lines: string[] = [];
211
+ for (const m of reversed) {
212
+ const text = extractMessageText(m.content);
213
+ // Determine effective role from content
214
+ let role = m.role;
215
+ try {
216
+ const parsed = JSON.parse(m.content ?? "");
217
+ if (parsed.type === "user") role = "user";
218
+ else if (parsed.type === "assistant") role = "assistant";
219
+ else if (parsed.type === "result") role = "system";
220
+ } catch {}
221
+
222
+ const roleColor = role === "user" ? chalk.green : role === "system" ? chalk.yellow : chalk.blue;
223
+ const header = `${roleColor(role)} ${chalk.dim(relativeTime(m.created_at))}${m.model ? chalk.dim(` · ${m.model}`) : ""}`;
224
+ lines.push(header);
225
+ if (text) {
226
+ const preview = truncate(text.replace(/\n/g, " "), 120);
227
+ lines.push(` ${preview}`);
228
+ }
229
+ lines.push("");
230
+ }
231
+ return lines.join("\n");
232
+ }
233
+
234
+ export function formatStats(stats: {
235
+ repos: number;
236
+ activeWorkspaces: number;
237
+ archivedWorkspaces: number;
238
+ sessions: number;
239
+ messages: number;
240
+ }): string {
241
+ return [
242
+ ` ${chalk.dim("Repositories:")} ${chalk.cyan(stats.repos)}`,
243
+ ` ${chalk.dim("Active workspaces:")} ${chalk.green(stats.activeWorkspaces)}`,
244
+ ` ${chalk.dim("Archived:")} ${chalk.dim(stats.archivedWorkspaces)}`,
245
+ ` ${chalk.dim("Sessions:")} ${stats.sessions}`,
246
+ ` ${chalk.dim("Messages:")} ${stats.messages}`,
247
+ ].join("\n");
248
+ }
package/src/server.ts ADDED
@@ -0,0 +1,161 @@
1
+ import {
2
+ listRepos,
3
+ listWorkspaces,
4
+ getWorkspace,
5
+ listSessions,
6
+ getSession,
7
+ listMessages,
8
+ searchWorkspaces,
9
+ getStats,
10
+ } from "./db.js";
11
+ import { openPrompt, openLinear, openPlan } from "./deeplink.js";
12
+
13
+ function json(data: unknown, status = 200) {
14
+ return new Response(JSON.stringify(data), {
15
+ status,
16
+ headers: { "Content-Type": "application/json" },
17
+ });
18
+ }
19
+
20
+ function err(message: string, status = 400) {
21
+ return json({ error: message }, status);
22
+ }
23
+
24
+ function resolveRepo(name: string): string | null {
25
+ const repos = listRepos();
26
+ const match = repos.find(
27
+ (r) => r.name.toLowerCase() === name.toLowerCase()
28
+ );
29
+ return match?.root_path ?? null;
30
+ }
31
+
32
+ async function body(req: Request): Promise<Record<string, any>> {
33
+ try {
34
+ return await req.json();
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ function route(req: Request): Response | Promise<Response> {
41
+ const url = new URL(req.url);
42
+ const path = url.pathname;
43
+ const method = req.method;
44
+
45
+ // ── GET routes (read-only queries) ──────────────────
46
+
47
+ if (method === "GET") {
48
+ if (path === "/status") {
49
+ return json(getStats());
50
+ }
51
+
52
+ if (path === "/repos") {
53
+ return json(listRepos());
54
+ }
55
+
56
+ if (path === "/workspaces") {
57
+ const filter = (url.searchParams.get("filter") ?? "active") as
58
+ | "active"
59
+ | "archived"
60
+ | "pinned"
61
+ | "all";
62
+ const limit = parseInt(url.searchParams.get("limit") ?? "30");
63
+ return json(listWorkspaces(filter).slice(0, limit));
64
+ }
65
+
66
+ if (path.startsWith("/workspaces/")) {
67
+ const id = path.slice("/workspaces/".length);
68
+ const ws = getWorkspace(id);
69
+ return ws ? json(ws) : err("Workspace not found", 404);
70
+ }
71
+
72
+ if (path === "/sessions") {
73
+ const workspaceId = url.searchParams.get("workspace") ?? undefined;
74
+ const limit = parseInt(url.searchParams.get("limit") ?? "20");
75
+ return json(listSessions(workspaceId).slice(0, limit));
76
+ }
77
+
78
+ if (path.startsWith("/sessions/") && path.endsWith("/messages")) {
79
+ const id = path.slice("/sessions/".length, -"/messages".length);
80
+ const limit = parseInt(url.searchParams.get("limit") ?? "20");
81
+ return json(listMessages(id, limit));
82
+ }
83
+
84
+ if (path.startsWith("/sessions/")) {
85
+ const id = path.slice("/sessions/".length);
86
+ const s = getSession(id);
87
+ return s ? json(s) : err("Session not found", 404);
88
+ }
89
+
90
+ if (path === "/search") {
91
+ const q = url.searchParams.get("q");
92
+ if (!q) return err("Missing ?q= parameter");
93
+ return json(searchWorkspaces(q));
94
+ }
95
+ }
96
+
97
+ // ── POST routes (trigger deep links) ────────────────
98
+
99
+ if (method === "POST") {
100
+ if (path === "/prompt") {
101
+ return (async () => {
102
+ const b = await body(req);
103
+ const message = b.message ?? b.prompt;
104
+ if (!message) return err("Missing 'message' field");
105
+ const dir = b.repo ? resolveRepo(b.repo) : b.dir;
106
+ await openPrompt(message, dir ?? undefined);
107
+ return json({ ok: true, action: "prompt", message, dir });
108
+ })();
109
+ }
110
+
111
+ if (path === "/linear") {
112
+ return (async () => {
113
+ const b = await body(req);
114
+ const issueId = b.issueId ?? b.issue_id ?? b.id;
115
+ if (!issueId) return err("Missing 'issueId' field");
116
+ const prompt = b.prompt ?? b.message;
117
+ const repoPath = b.repo ? resolveRepo(b.repo) : undefined;
118
+ await openLinear(issueId, prompt, repoPath ?? undefined);
119
+ return json({ ok: true, action: "linear", issueId, prompt, repo: repoPath });
120
+ })();
121
+ }
122
+
123
+ if (path === "/plan") {
124
+ return (async () => {
125
+ const b = await body(req);
126
+ if (!b.repo) return err("Missing 'repo' field");
127
+ if (!b.plan && !b.content) return err("Missing 'plan' or 'content' field");
128
+ const content = b.plan ?? b.content;
129
+ await openPlan(b.repo, content);
130
+ return json({ ok: true, action: "plan", repo: b.repo });
131
+ })();
132
+ }
133
+ }
134
+
135
+ return err("Not found", 404);
136
+ }
137
+
138
+ export function startServer(port: number) {
139
+ const server = Bun.serve({
140
+ port,
141
+ fetch: route,
142
+ });
143
+
144
+ console.log(`conductorcli server listening on http://localhost:${server.port}`);
145
+ console.log();
146
+ console.log(" GET /status");
147
+ console.log(" GET /repos");
148
+ console.log(" GET /workspaces?filter=active&limit=30");
149
+ console.log(" GET /workspaces/:id");
150
+ console.log(" GET /sessions?workspace=:id&limit=20");
151
+ console.log(" GET /sessions/:id");
152
+ console.log(" GET /sessions/:id/messages?limit=20");
153
+ console.log(" GET /search?q=query");
154
+ console.log();
155
+ console.log(" POST /prompt { message, repo?, dir? }");
156
+ console.log(" POST /linear { issueId, prompt?, repo? }");
157
+ console.log(" POST /plan { repo, plan }");
158
+ console.log();
159
+
160
+ return server;
161
+ }