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 +1 -0
- package/README.md +165 -0
- package/bin/conductorcli.js +2 -0
- package/package.json +52 -0
- package/src/cli.ts +248 -0
- package/src/db.ts +259 -0
- package/src/deeplink.ts +31 -0
- package/src/format.ts +248 -0
- package/src/server.ts +161 -0
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
|
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
|
+
}
|
package/src/deeplink.ts
ADDED
|
@@ -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
|
+
}
|