@colinmollenhour/occtl 1.0.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.
Files changed (53) hide show
  1. package/README.md +290 -0
  2. package/SKILL.md +692 -0
  3. package/dist/client.d.ts +4 -0
  4. package/dist/client.js +64 -0
  5. package/dist/commands/session-abort.d.ts +2 -0
  6. package/dist/commands/session-abort.js +16 -0
  7. package/dist/commands/session-children.d.ts +2 -0
  8. package/dist/commands/session-children.js +30 -0
  9. package/dist/commands/session-create.d.ts +2 -0
  10. package/dist/commands/session-create.js +39 -0
  11. package/dist/commands/session-diff.d.ts +2 -0
  12. package/dist/commands/session-diff.js +35 -0
  13. package/dist/commands/session-get.d.ts +2 -0
  14. package/dist/commands/session-get.js +24 -0
  15. package/dist/commands/session-last.d.ts +2 -0
  16. package/dist/commands/session-last.js +39 -0
  17. package/dist/commands/session-list.d.ts +2 -0
  18. package/dist/commands/session-list.js +91 -0
  19. package/dist/commands/session-messages.d.ts +2 -0
  20. package/dist/commands/session-messages.js +44 -0
  21. package/dist/commands/session-respond.d.ts +2 -0
  22. package/dist/commands/session-respond.js +78 -0
  23. package/dist/commands/session-send.d.ts +2 -0
  24. package/dist/commands/session-send.js +114 -0
  25. package/dist/commands/session-share.d.ts +3 -0
  26. package/dist/commands/session-share.js +53 -0
  27. package/dist/commands/session-status.d.ts +2 -0
  28. package/dist/commands/session-status.js +45 -0
  29. package/dist/commands/session-summary.d.ts +2 -0
  30. package/dist/commands/session-summary.js +87 -0
  31. package/dist/commands/session-todo.d.ts +2 -0
  32. package/dist/commands/session-todo.js +41 -0
  33. package/dist/commands/session-wait-for-text.d.ts +2 -0
  34. package/dist/commands/session-wait-for-text.js +119 -0
  35. package/dist/commands/session-wait.d.ts +4 -0
  36. package/dist/commands/session-wait.js +85 -0
  37. package/dist/commands/session-watch.d.ts +2 -0
  38. package/dist/commands/session-watch.js +101 -0
  39. package/dist/commands/skill.d.ts +3 -0
  40. package/dist/commands/skill.js +55 -0
  41. package/dist/commands/worktree.d.ts +5 -0
  42. package/dist/commands/worktree.js +359 -0
  43. package/dist/format.d.ts +19 -0
  44. package/dist/format.js +115 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +63 -0
  47. package/dist/resolve.d.ts +6 -0
  48. package/dist/resolve.js +47 -0
  49. package/dist/sse.d.ts +40 -0
  50. package/dist/sse.js +128 -0
  51. package/dist/wait-util.d.ts +23 -0
  52. package/dist/wait-util.js +118 -0
  53. package/package.json +49 -0
@@ -0,0 +1,359 @@
1
+ import { Command } from "commander";
2
+ import { execFileSync } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import { resolve, basename } from "path";
5
+ import { ensureServer } from "../client.js";
6
+ import { formatJSON, formatMessage } from "../format.js";
7
+ import { startStream } from "../sse.js";
8
+ import { waitForIdle } from "../wait-util.js";
9
+ function getRepoRoot() {
10
+ try {
11
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
12
+ encoding: "utf-8",
13
+ timeout: 5000,
14
+ }).trim();
15
+ }
16
+ catch {
17
+ console.error("Error: Not inside a git repository.");
18
+ process.exit(1);
19
+ }
20
+ }
21
+ function parseWorktreeList() {
22
+ try {
23
+ const output = execFileSync("git", ["worktree", "list", "--porcelain"], {
24
+ encoding: "utf-8",
25
+ timeout: 5000,
26
+ });
27
+ const worktrees = [];
28
+ let current = {};
29
+ for (const line of output.split("\n")) {
30
+ if (line.startsWith("worktree ")) {
31
+ if (current.path)
32
+ worktrees.push(current);
33
+ current = { path: line.slice(9) };
34
+ }
35
+ else if (line.startsWith("HEAD ")) {
36
+ current.head = line.slice(5);
37
+ }
38
+ else if (line.startsWith("branch ")) {
39
+ current.branch = line.slice(7).replace("refs/heads/", "");
40
+ }
41
+ else if (line === "bare") {
42
+ current.bare = true;
43
+ }
44
+ else if (line === "detached") {
45
+ current.branch = "(detached)";
46
+ }
47
+ }
48
+ if (current.path)
49
+ worktrees.push(current);
50
+ return worktrees;
51
+ }
52
+ catch {
53
+ console.error("Error: Failed to list git worktrees.");
54
+ process.exit(1);
55
+ }
56
+ }
57
+ function getWorktreeDir() {
58
+ const root = getRepoRoot();
59
+ return resolve(root, ".occtl", "worktrees");
60
+ }
61
+ /**
62
+ * Create a git worktree. Uses execFileSync (no shell) to prevent injection.
63
+ */
64
+ function createWorktree(wtPath, branch, base) {
65
+ try {
66
+ execFileSync("git", ["worktree", "add", wtPath, "-b", branch, base], {
67
+ encoding: "utf-8",
68
+ stdio: "pipe",
69
+ timeout: 30000,
70
+ });
71
+ }
72
+ catch (err) {
73
+ const msg = err instanceof Error ? err.message : String(err);
74
+ if (msg.includes("already exists")) {
75
+ try {
76
+ execFileSync("git", ["worktree", "add", wtPath, branch], {
77
+ encoding: "utf-8",
78
+ stdio: "pipe",
79
+ timeout: 30000,
80
+ });
81
+ }
82
+ catch (err2) {
83
+ const msg2 = err2 instanceof Error ? err2.message : String(err2);
84
+ console.error(`Failed to create worktree: ${msg2}`);
85
+ process.exit(1);
86
+ }
87
+ }
88
+ else {
89
+ console.error(`Failed to create worktree: ${msg}`);
90
+ process.exit(1);
91
+ }
92
+ }
93
+ }
94
+ // ─── list ──────────────────────────────────────────────
95
+ export function worktreeListCommand() {
96
+ return new Command("list")
97
+ .alias("ls")
98
+ .description("List git worktrees and any associated sessions")
99
+ .option("-j, --json", "Output as JSON")
100
+ .action(async (opts) => {
101
+ const worktrees = parseWorktreeList();
102
+ if (opts.json) {
103
+ console.log(formatJSON(worktrees));
104
+ return;
105
+ }
106
+ if (worktrees.length === 0) {
107
+ console.log("No worktrees found.");
108
+ return;
109
+ }
110
+ console.log("PATH\tBRANCH\tHEAD");
111
+ for (const wt of worktrees) {
112
+ console.log(`${wt.path}\t${wt.branch || "(bare)"}\t${(wt.head || "").slice(0, 8)}`);
113
+ }
114
+ });
115
+ }
116
+ // ─── create ────────────────────────────────────────────
117
+ export function worktreeCreateCommand() {
118
+ return new Command("create")
119
+ .description("Create a git worktree and optionally a session in it")
120
+ .argument("<name>", "Worktree name (used as directory and branch name)")
121
+ .option("-b, --branch <branch>", "Branch name (defaults to worktree-<name>)")
122
+ .option("--base <ref>", "Base ref to branch from (defaults to HEAD)")
123
+ .option("--no-session", "Don't create a session in the worktree")
124
+ .option("-j, --json", "Output as JSON")
125
+ .option("-q, --quiet", "Only output the worktree path")
126
+ .action(async (name, opts) => {
127
+ const wtDir = getWorktreeDir();
128
+ const wtPath = resolve(wtDir, name);
129
+ const branch = opts.branch || `worktree-${name}`;
130
+ const base = opts.base || "HEAD";
131
+ if (existsSync(wtPath)) {
132
+ console.error(`Worktree already exists at ${wtPath}`);
133
+ process.exit(1);
134
+ }
135
+ createWorktree(wtPath, branch, base);
136
+ const result = {
137
+ path: wtPath,
138
+ branch,
139
+ name,
140
+ };
141
+ // Optionally create a session in the worktree directory
142
+ if (opts.session !== false) {
143
+ try {
144
+ const client = await ensureServer();
145
+ const session = await client.session.create({
146
+ body: { title: `worktree: ${name}` },
147
+ query: { directory: wtPath },
148
+ });
149
+ if (session.data) {
150
+ result.sessionID = session.data.id;
151
+ }
152
+ }
153
+ catch {
154
+ // Server might not be running — that's OK, just skip session
155
+ }
156
+ }
157
+ if (opts.quiet) {
158
+ console.log(wtPath);
159
+ return;
160
+ }
161
+ if (opts.json) {
162
+ console.log(formatJSON(result));
163
+ return;
164
+ }
165
+ console.log(`Created worktree: ${wtPath}`);
166
+ console.log(`Branch: ${branch}`);
167
+ if (result.sessionID) {
168
+ console.log(`Session: ${result.sessionID}`);
169
+ }
170
+ });
171
+ }
172
+ // ─── remove ────────────────────────────────────────────
173
+ export function worktreeRemoveCommand() {
174
+ return new Command("remove")
175
+ .alias("rm")
176
+ .description("Remove a git worktree")
177
+ .argument("<name>", "Worktree name or path")
178
+ .option("-f, --force", "Force removal even if dirty")
179
+ .action(async (name, opts) => {
180
+ let wtPath;
181
+ const candidate = resolve(getWorktreeDir(), name);
182
+ if (existsSync(candidate)) {
183
+ wtPath = candidate;
184
+ }
185
+ else if (existsSync(name)) {
186
+ wtPath = resolve(name);
187
+ }
188
+ else {
189
+ const worktrees = parseWorktreeList();
190
+ const match = worktrees.find((wt) => basename(wt.path) === name || wt.path.endsWith(`/${name}`));
191
+ if (match) {
192
+ wtPath = match.path;
193
+ }
194
+ else {
195
+ console.error(`Worktree not found: ${name}`);
196
+ process.exit(1);
197
+ }
198
+ }
199
+ const args = ["worktree", "remove", wtPath];
200
+ if (opts.force)
201
+ args.push("--force");
202
+ try {
203
+ execFileSync("git", args, {
204
+ encoding: "utf-8",
205
+ stdio: "pipe",
206
+ timeout: 30000,
207
+ });
208
+ console.log(`Removed worktree: ${wtPath}`);
209
+ }
210
+ catch (err) {
211
+ const msg = err instanceof Error ? err.message : String(err);
212
+ console.error(`Failed to remove worktree: ${msg}`);
213
+ process.exit(1);
214
+ }
215
+ });
216
+ }
217
+ // ─── run ───────────────────────────────────────────────
218
+ export function worktreeRunCommand() {
219
+ return new Command("run")
220
+ .description("Create a worktree, start a session, send a prompt, and optionally wait")
221
+ .argument("<name>", "Worktree name")
222
+ .argument("<message...>", "Prompt message to send")
223
+ .option("-b, --branch <branch>", "Branch name (defaults to worktree-<name>)")
224
+ .option("--base <ref>", "Base ref to branch from (defaults to HEAD)")
225
+ .option("-w, --wait", "Block until the session goes idle, then show the result")
226
+ .option("--auto-approve", "Auto-approve all permission requests")
227
+ .option("--model <model>", "Model to use (format: provider/model)")
228
+ .option("--agent <agent>", "Agent to use")
229
+ .option("-j, --json", "Output as JSON")
230
+ .option("-t, --text-only", "Show only text content")
231
+ .option("--stdin", "Read message from stdin")
232
+ .action(async (name, messageParts, opts) => {
233
+ const client = await ensureServer();
234
+ // Create the worktree
235
+ const wtDir = getWorktreeDir();
236
+ const wtPath = resolve(wtDir, name);
237
+ const branch = opts.branch || `worktree-${name}`;
238
+ const base = opts.base || "HEAD";
239
+ if (!existsSync(wtPath)) {
240
+ createWorktree(wtPath, branch, base);
241
+ console.error(`Created worktree: ${wtPath} (branch: ${branch})`);
242
+ }
243
+ else {
244
+ console.error(`Using existing worktree: ${wtPath}`);
245
+ }
246
+ // Create a session in the worktree directory
247
+ const session = await client.session.create({
248
+ body: { title: `worktree: ${name}` },
249
+ query: { directory: wtPath },
250
+ });
251
+ if (!session.data) {
252
+ console.error("Failed to create session.");
253
+ process.exit(1);
254
+ }
255
+ const sid = session.data.id;
256
+ console.error(`Session: ${sid}`);
257
+ // Build the message
258
+ let messageText;
259
+ if (opts.stdin) {
260
+ const chunks = [];
261
+ for await (const chunk of process.stdin) {
262
+ chunks.push(chunk);
263
+ }
264
+ messageText = Buffer.concat(chunks).toString("utf-8").trim();
265
+ }
266
+ else {
267
+ messageText = messageParts.join(" ");
268
+ }
269
+ // Parse model
270
+ let model;
271
+ if (opts.model) {
272
+ const parts = opts.model.split("/");
273
+ if (parts.length === 2 && parts[0] && parts[1]) {
274
+ model = { providerID: parts[0], modelID: parts[1] };
275
+ }
276
+ }
277
+ const body = {
278
+ parts: [{ type: "text", text: messageText }],
279
+ ...(model && { model }),
280
+ ...(opts.agent && { agent: opts.agent }),
281
+ };
282
+ // Start auto-approve in background if requested.
283
+ // Uses startStream which returns a cancel handle.
284
+ let approveHandle;
285
+ if (opts.autoApprove) {
286
+ approveHandle = startStream(sid, async (event) => {
287
+ if (event.type !== "permission.updated")
288
+ return;
289
+ const props = event.properties;
290
+ if (props.status && props.status !== "pending")
291
+ return;
292
+ try {
293
+ await client.postSessionIdPermissionsPermissionId({
294
+ path: { id: sid, permissionID: props.id },
295
+ body: { response: "once" },
296
+ });
297
+ console.error(`Auto-approved: ${props.id}`);
298
+ }
299
+ catch (err) {
300
+ console.error(`Failed to auto-approve ${props.id}: ${err instanceof Error ? err.message : String(err)}`);
301
+ }
302
+ });
303
+ }
304
+ // Send the prompt
305
+ await client.session.promptAsync({
306
+ path: { id: sid },
307
+ body,
308
+ });
309
+ console.error("Prompt sent.");
310
+ if (!opts.wait) {
311
+ const output = { sessionID: sid, worktree: wtPath, branch };
312
+ if (opts.json) {
313
+ console.log(formatJSON(output));
314
+ }
315
+ else {
316
+ console.log(`Session ${sid} started in ${wtPath}`);
317
+ if (approveHandle) {
318
+ console.log("Auto-approve is active. Press Ctrl+C to stop.");
319
+ // Keep running — the auto-approve stream keeps the event loop alive
320
+ return;
321
+ }
322
+ }
323
+ // Clean up auto-approve if not keeping it running
324
+ approveHandle?.cancel();
325
+ return;
326
+ }
327
+ // --wait: use race-safe waitForIdle
328
+ const waitResult = await waitForIdle(client, sid);
329
+ // Clean up auto-approve
330
+ approveHandle?.cancel();
331
+ if (!waitResult.idle) {
332
+ if (waitResult.reason === "disconnected") {
333
+ console.error("Error: lost connection to OpenCode server.");
334
+ }
335
+ process.exit(1);
336
+ }
337
+ // Fetch the last assistant message
338
+ const msgs = await client.session.messages({
339
+ path: { id: sid },
340
+ });
341
+ const messages = msgs.data ?? [];
342
+ const last = messages.filter((m) => m.info.role === "assistant").pop();
343
+ if (!last) {
344
+ console.error("No assistant response.");
345
+ process.exit(1);
346
+ }
347
+ if (opts.json) {
348
+ console.log(formatJSON({
349
+ sessionID: sid,
350
+ worktree: wtPath,
351
+ branch,
352
+ response: last,
353
+ }));
354
+ return;
355
+ }
356
+ const textOnly = opts.textOnly !== false;
357
+ console.log(formatMessage(last.info, last.parts, { textOnly }));
358
+ });
359
+ }
@@ -0,0 +1,19 @@
1
+ import type { Session, Message, Part, AssistantMessage, UserMessage } from "@opencode-ai/sdk";
2
+ export declare function formatTime(ts: number): string;
3
+ export declare function formatTimeAgo(ts: number): string;
4
+ export declare function truncate(str: string, max: number): string;
5
+ export declare function formatSession(session: Session): string;
6
+ export declare function formatSessionDetailed(session: Session): string;
7
+ export declare function isUserMessage(msg: Message): msg is UserMessage;
8
+ export declare function isAssistantMessage(msg: Message): msg is AssistantMessage;
9
+ export declare function extractText(parts: Part[]): string;
10
+ export declare function extractToolCalls(parts: Part[]): Array<{
11
+ tool: string;
12
+ status: string;
13
+ title?: string;
14
+ }>;
15
+ export declare function formatMessage(msg: Message, parts: Part[], opts?: {
16
+ verbose?: boolean;
17
+ textOnly?: boolean;
18
+ }): string;
19
+ export declare function formatJSON(data: unknown): string;
package/dist/format.js ADDED
@@ -0,0 +1,115 @@
1
+ export function formatTime(ts) {
2
+ return new Date(ts).toLocaleString();
3
+ }
4
+ export function formatTimeAgo(ts) {
5
+ const diff = Math.max(0, Date.now() - ts);
6
+ const seconds = Math.floor(diff / 1000);
7
+ if (seconds < 60)
8
+ return `${seconds}s ago`;
9
+ const minutes = Math.floor(seconds / 60);
10
+ if (minutes < 60)
11
+ return `${minutes}m ago`;
12
+ const hours = Math.floor(minutes / 60);
13
+ if (hours < 24)
14
+ return `${hours}h ago`;
15
+ const days = Math.floor(hours / 24);
16
+ return `${days}d ago`;
17
+ }
18
+ export function truncate(str, max) {
19
+ if (str.length <= max)
20
+ return str;
21
+ return str.slice(0, max - 3) + "...";
22
+ }
23
+ export function formatSession(session) {
24
+ const parts = [
25
+ session.id,
26
+ truncate(session.title || "(untitled)", 50),
27
+ formatTimeAgo(session.time.updated),
28
+ ];
29
+ return parts.join("\t");
30
+ }
31
+ export function formatSessionDetailed(session) {
32
+ const lines = [];
33
+ lines.push(`ID: ${session.id}`);
34
+ lines.push(`Title: ${session.title || "(untitled)"}`);
35
+ lines.push(`Directory: ${session.directory}`);
36
+ lines.push(`Created: ${formatTime(session.time.created)}`);
37
+ lines.push(`Updated: ${formatTime(session.time.updated)}`);
38
+ if (session.parentID) {
39
+ lines.push(`Parent: ${session.parentID}`);
40
+ }
41
+ if (session.share?.url) {
42
+ lines.push(`Share URL: ${session.share.url}`);
43
+ }
44
+ if (session.summary) {
45
+ lines.push(`Changes: +${session.summary.additions} -${session.summary.deletions} (${session.summary.files} files)`);
46
+ }
47
+ return lines.join("\n");
48
+ }
49
+ export function isUserMessage(msg) {
50
+ return msg.role === "user";
51
+ }
52
+ export function isAssistantMessage(msg) {
53
+ return msg.role === "assistant";
54
+ }
55
+ export function extractText(parts) {
56
+ const textParts = parts.filter((p) => p.type === "text");
57
+ return textParts.map((p) => p.text).join("\n");
58
+ }
59
+ export function extractToolCalls(parts) {
60
+ return parts
61
+ .filter((p) => p.type === "tool")
62
+ .map((p) => {
63
+ const toolPart = p;
64
+ return {
65
+ tool: toolPart.tool,
66
+ status: toolPart.state?.status ?? "unknown",
67
+ title: toolPart.state?.title,
68
+ };
69
+ });
70
+ }
71
+ export function formatMessage(msg, parts, opts = {}) {
72
+ const lines = [];
73
+ const role = msg.role.toUpperCase();
74
+ const time = formatTime(msg.time.created);
75
+ if (opts.textOnly) {
76
+ const text = extractText(parts);
77
+ if (text)
78
+ lines.push(text);
79
+ return lines.join("\n");
80
+ }
81
+ lines.push(`--- ${role} [${time}] ---`);
82
+ if (isAssistantMessage(msg)) {
83
+ lines.push(`Model: ${msg.providerID}/${msg.modelID}`);
84
+ if (msg.cost > 0) {
85
+ lines.push(`Cost: $${msg.cost.toFixed(6)}`);
86
+ }
87
+ if (msg.tokens) {
88
+ lines.push(`Tokens: in=${msg.tokens.input} out=${msg.tokens.output}` +
89
+ (msg.tokens.reasoning ? ` reasoning=${msg.tokens.reasoning}` : "") +
90
+ (msg.tokens.cache.read ? ` cache_read=${msg.tokens.cache.read}` : ""));
91
+ }
92
+ if (msg.error) {
93
+ lines.push(`Error: ${msg.error.name}`);
94
+ }
95
+ }
96
+ const text = extractText(parts);
97
+ if (text) {
98
+ lines.push("");
99
+ lines.push(text);
100
+ }
101
+ if (opts.verbose) {
102
+ const tools = extractToolCalls(parts);
103
+ if (tools.length > 0) {
104
+ lines.push("");
105
+ lines.push("Tool calls:");
106
+ for (const t of tools) {
107
+ lines.push(` - ${t.tool} [${t.status}]${t.title ? ` ${t.title}` : ""}`);
108
+ }
109
+ }
110
+ }
111
+ return lines.join("\n");
112
+ }
113
+ export function formatJSON(data) {
114
+ return JSON.stringify(data, null, 2);
115
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { sessionListCommand } from "./commands/session-list.js";
4
+ import { sessionGetCommand } from "./commands/session-get.js";
5
+ import { sessionMessagesCommand } from "./commands/session-messages.js";
6
+ import { sessionLastCommand } from "./commands/session-last.js";
7
+ import { sessionStatusCommand } from "./commands/session-status.js";
8
+ import { sessionWatchCommand } from "./commands/session-watch.js";
9
+ import { sessionSendCommand } from "./commands/session-send.js";
10
+ import { sessionRespondCommand } from "./commands/session-respond.js";
11
+ import { sessionTodoCommand } from "./commands/session-todo.js";
12
+ import { sessionAbortCommand } from "./commands/session-abort.js";
13
+ import { sessionDiffCommand } from "./commands/session-diff.js";
14
+ import { sessionChildrenCommand } from "./commands/session-children.js";
15
+ import { sessionCreateCommand } from "./commands/session-create.js";
16
+ import { sessionShareCommand, sessionUnshareCommand } from "./commands/session-share.js";
17
+ import { sessionWaitForTextCommand } from "./commands/session-wait-for-text.js";
18
+ import { sessionWaitForIdleCommand, sessionWaitAnyCommand, sessionIsIdleCommand, } from "./commands/session-wait.js";
19
+ import { sessionSummaryCommand } from "./commands/session-summary.js";
20
+ import { worktreeListCommand, worktreeCreateCommand, worktreeRemoveCommand, worktreeRunCommand, } from "./commands/worktree.js";
21
+ import { installSkillCommand, viewSkillCommand } from "./commands/skill.js";
22
+ const program = new Command();
23
+ program
24
+ .name("occtl")
25
+ .description("Extended CLI for managing OpenCode sessions.\n\n" +
26
+ "Auto-detects running OpenCode server, or set:\n" +
27
+ " OPENCODE_SERVER_HOST (default: 127.0.0.1)\n" +
28
+ " OPENCODE_SERVER_PORT (default: 4096)")
29
+ .version("1.0.0");
30
+ // Session commands (top-level)
31
+ program.addCommand(sessionListCommand());
32
+ program.addCommand(sessionCreateCommand());
33
+ program.addCommand(sessionGetCommand());
34
+ program.addCommand(sessionMessagesCommand());
35
+ program.addCommand(sessionLastCommand());
36
+ program.addCommand(sessionStatusCommand());
37
+ program.addCommand(sessionWatchCommand());
38
+ program.addCommand(sessionSendCommand());
39
+ program.addCommand(sessionRespondCommand());
40
+ program.addCommand(sessionTodoCommand());
41
+ program.addCommand(sessionAbortCommand());
42
+ program.addCommand(sessionDiffCommand());
43
+ program.addCommand(sessionChildrenCommand());
44
+ program.addCommand(sessionShareCommand());
45
+ program.addCommand(sessionUnshareCommand());
46
+ program.addCommand(sessionWaitForTextCommand());
47
+ program.addCommand(sessionWaitForIdleCommand());
48
+ program.addCommand(sessionWaitAnyCommand());
49
+ program.addCommand(sessionIsIdleCommand());
50
+ program.addCommand(sessionSummaryCommand());
51
+ // Worktree subcommand group
52
+ const worktree = program
53
+ .command("worktree")
54
+ .alias("wt")
55
+ .description("Manage git worktrees for parallel session isolation");
56
+ worktree.addCommand(worktreeListCommand());
57
+ worktree.addCommand(worktreeCreateCommand());
58
+ worktree.addCommand(worktreeRemoveCommand());
59
+ worktree.addCommand(worktreeRunCommand());
60
+ // Skill management (top-level)
61
+ program.addCommand(installSkillCommand());
62
+ program.addCommand(viewSkillCommand());
63
+ program.parse();
@@ -0,0 +1,6 @@
1
+ import type { OpencodeClient } from "@opencode-ai/sdk";
2
+ /**
3
+ * Resolve a session ID. If none is provided, returns the most recently updated session.
4
+ * Also supports partial ID matching.
5
+ */
6
+ export declare function resolveSession(client: OpencodeClient, sessionId?: string): Promise<string>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Resolve a session ID. If none is provided, returns the most recently updated session.
3
+ * Also supports partial ID matching.
4
+ */
5
+ export async function resolveSession(client, sessionId) {
6
+ if (!sessionId) {
7
+ // Get most recent session
8
+ const result = await client.session.list();
9
+ const sessions = (result.data ?? []).filter((s) => !s.parentID);
10
+ if (sessions.length === 0) {
11
+ console.error("No sessions found.");
12
+ process.exit(1);
13
+ }
14
+ // Sessions are sorted by most recently updated
15
+ return sessions[0].id;
16
+ }
17
+ // Try exact match first
18
+ try {
19
+ const result = await client.session.get({
20
+ path: { id: sessionId },
21
+ });
22
+ if (result.data) {
23
+ return result.data.id;
24
+ }
25
+ }
26
+ catch {
27
+ // Fall through to partial match
28
+ }
29
+ // Try partial match
30
+ const result = await client.session.list();
31
+ const sessions = result.data ?? [];
32
+ const matches = sessions.filter((s) => s.id.startsWith(sessionId) ||
33
+ s.id.includes(sessionId) ||
34
+ (s.title && s.title.toLowerCase().includes(sessionId.toLowerCase())));
35
+ if (matches.length === 0) {
36
+ console.error(`No session found matching: ${sessionId}`);
37
+ process.exit(1);
38
+ }
39
+ if (matches.length > 1) {
40
+ console.error(`Ambiguous session ID "${sessionId}", matches:`);
41
+ for (const m of matches.slice(0, 5)) {
42
+ console.error(` ${m.id} ${m.title || "(untitled)"}`);
43
+ }
44
+ process.exit(1);
45
+ }
46
+ return matches[0].id;
47
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Event } from "@opencode-ai/sdk";
2
+ export type StreamResult = "stopped" | "disconnected";
3
+ /**
4
+ * Extract the session ID from an event, checking all known locations.
5
+ */
6
+ export declare function getEventSessionId(event: Event): string | undefined;
7
+ /**
8
+ * Check if an SSE event belongs to a given session.
9
+ */
10
+ export declare function isSessionEvent(event: Event, sessionId: string): boolean;
11
+ /**
12
+ * Connect to the OpenCode SSE event stream and invoke a callback for each
13
+ * parsed event that matches the given session.
14
+ *
15
+ * Returns "stopped" if the callback returned "stop", or "disconnected" if
16
+ * the stream ended unexpectedly.
17
+ */
18
+ export declare function streamEvents(sessionId: string, onEvent: (event: Event) => void | "stop" | Promise<void | "stop">): Promise<StreamResult>;
19
+ export interface StreamHandle {
20
+ /** Promise that resolves when the stream ends. */
21
+ result: Promise<StreamResult>;
22
+ /** Cancel the stream. */
23
+ cancel: () => void;
24
+ /** Resolves when the SSE connection is established. */
25
+ connected: Promise<void>;
26
+ }
27
+ /**
28
+ * Connect to the SSE stream and return a handle with cancel + connected signal.
29
+ * This is the low-level version for callers that need to coordinate startup.
30
+ */
31
+ export declare function startStream(sessionId: string, onEvent: (event: Event) => void | "stop" | Promise<void | "stop">): StreamHandle;
32
+ /**
33
+ * Start an SSE stream (unfiltered) and return a handle with cancel + connected signal.
34
+ */
35
+ export declare function startAllStream(onEvent: (event: Event) => void | "stop" | Promise<void | "stop">): StreamHandle;
36
+ /**
37
+ * Connect to the SSE stream and invoke callback for ALL events (unfiltered).
38
+ * Returns "stopped" or "disconnected".
39
+ */
40
+ export declare function streamAllEvents(onEvent: (event: Event) => void | "stop" | Promise<void | "stop">): Promise<StreamResult>;