@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,53 @@
1
+ import { Command } from "commander";
2
+ import { ensureServer } from "../client.js";
3
+ import { resolveSession } from "../resolve.js";
4
+ import { formatJSON } from "../format.js";
5
+ export function sessionShareCommand() {
6
+ return new Command("share")
7
+ .description("Share a session and get a public URL")
8
+ .argument("[session-id]", "Session ID (defaults to most recent)")
9
+ .option("-j, --json", "Output as JSON")
10
+ .action(async (sessionId, opts) => {
11
+ const client = await ensureServer();
12
+ const resolved = await resolveSession(client, sessionId);
13
+ const result = await client.session.share({
14
+ path: { id: resolved },
15
+ });
16
+ if (!result.data) {
17
+ console.error("Failed to share session.");
18
+ process.exit(1);
19
+ }
20
+ if (opts.json) {
21
+ console.log(formatJSON(result.data));
22
+ return;
23
+ }
24
+ if (result.data.share?.url) {
25
+ console.log(result.data.share.url);
26
+ }
27
+ else {
28
+ console.log(`Session ${resolved} shared.`);
29
+ }
30
+ });
31
+ }
32
+ export function sessionUnshareCommand() {
33
+ return new Command("unshare")
34
+ .description("Remove sharing from a session")
35
+ .argument("[session-id]", "Session ID (defaults to most recent)")
36
+ .option("-j, --json", "Output as JSON")
37
+ .action(async (sessionId, opts) => {
38
+ const client = await ensureServer();
39
+ const resolved = await resolveSession(client, sessionId);
40
+ const result = await client.session.unshare({
41
+ path: { id: resolved },
42
+ });
43
+ if (!result.data) {
44
+ console.error("Failed to unshare session.");
45
+ process.exit(1);
46
+ }
47
+ if (opts.json) {
48
+ console.log(formatJSON(result.data));
49
+ return;
50
+ }
51
+ console.log(`Session ${resolved} unshared.`);
52
+ });
53
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function sessionStatusCommand(): Command;
@@ -0,0 +1,45 @@
1
+ import { Command } from "commander";
2
+ import { ensureServer } from "../client.js";
3
+ import { formatJSON } from "../format.js";
4
+ import { resolveSession } from "../resolve.js";
5
+ export function sessionStatusCommand() {
6
+ return new Command("status")
7
+ .description("Get the status of sessions (idle, busy, retry)")
8
+ .argument("[session-id]", "Session ID (defaults to showing all statuses)")
9
+ .option("-j, --json", "Output as JSON")
10
+ .action(async (sessionId, opts) => {
11
+ const client = await ensureServer();
12
+ const result = await client.session.status();
13
+ const statuses = result.data ?? {};
14
+ if (opts.json) {
15
+ if (sessionId) {
16
+ const resolved = await resolveSession(client, sessionId);
17
+ console.log(formatJSON(statuses[resolved] ?? { type: "idle" }));
18
+ }
19
+ else {
20
+ console.log(formatJSON(statuses));
21
+ }
22
+ return;
23
+ }
24
+ if (sessionId) {
25
+ const resolved = await resolveSession(client, sessionId);
26
+ const status = statuses[resolved];
27
+ if (!status) {
28
+ console.log(`${resolved}: idle`);
29
+ }
30
+ else {
31
+ console.log(`${resolved}: ${status.type}`);
32
+ }
33
+ return;
34
+ }
35
+ const entries = Object.entries(statuses);
36
+ if (entries.length === 0) {
37
+ console.log("No active session statuses.");
38
+ return;
39
+ }
40
+ console.log("SESSION\tSTATUS");
41
+ for (const [id, status] of entries) {
42
+ console.log(`${id}\t${status.type}`);
43
+ }
44
+ });
45
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function sessionSummaryCommand(): Command;
@@ -0,0 +1,87 @@
1
+ import { Command } from "commander";
2
+ import { ensureServer } from "../client.js";
3
+ import { resolveSession } from "../resolve.js";
4
+ import { formatJSON, extractText, truncate, formatTimeAgo, } from "../format.js";
5
+ export function sessionSummaryCommand() {
6
+ return new Command("summary")
7
+ .description("Compact summary of a session: status, todo progress, last message snippet, cost")
8
+ .argument("[session-id]", "Session ID (defaults to most recent)")
9
+ .option("-j, --json", "Output as JSON")
10
+ .option("-n, --snippet-length <chars>", "Max characters for last message snippet", parseInt, 200)
11
+ .action(async (sessionId, opts) => {
12
+ const client = await ensureServer();
13
+ const resolved = await resolveSession(client, sessionId);
14
+ // Fetch session info, status, messages, and todos in parallel
15
+ const [sessionResult, statusResult, messagesResult, todoResult] = await Promise.all([
16
+ client.session.get({ path: { id: resolved } }),
17
+ client.session.status(),
18
+ client.session.messages({ path: { id: resolved } }),
19
+ client.session.todo({ path: { id: resolved } }),
20
+ ]);
21
+ const session = sessionResult.data;
22
+ const statuses = statusResult.data ?? {};
23
+ const messages = messagesResult.data ?? [];
24
+ const todos = todoResult.data ?? [];
25
+ // Compute status
26
+ const statusEntry = statuses[resolved];
27
+ const status = statusEntry?.type ?? "idle";
28
+ // Compute todo progress
29
+ const todoTotal = todos.length;
30
+ const todoCompleted = todos.filter((t) => t.status === "completed").length;
31
+ const todoInProgress = todos.filter((t) => t.status === "in_progress").length;
32
+ // Get last assistant message snippet
33
+ const assistantMsgs = messages.filter((m) => m.info.role === "assistant");
34
+ const lastAssistant = assistantMsgs[assistantMsgs.length - 1];
35
+ let lastSnippet = "";
36
+ if (lastAssistant) {
37
+ const text = extractText(lastAssistant.parts).trim();
38
+ lastSnippet = truncate(text, opts.snippetLength);
39
+ }
40
+ // Compute total cost
41
+ let totalCost = 0;
42
+ for (const m of messages) {
43
+ if (m.info.role === "assistant") {
44
+ const aMsg = m.info;
45
+ if (aMsg.cost)
46
+ totalCost += aMsg.cost;
47
+ }
48
+ }
49
+ // Compute changes
50
+ const changes = session?.summary
51
+ ? `+${session.summary.additions} -${session.summary.deletions} (${session.summary.files} files)`
52
+ : "none";
53
+ const summary = {
54
+ sessionID: resolved,
55
+ title: session?.title || "(untitled)",
56
+ status,
57
+ updated: session?.time.updated
58
+ ? formatTimeAgo(session.time.updated)
59
+ : "unknown",
60
+ todos: {
61
+ total: todoTotal,
62
+ completed: todoCompleted,
63
+ inProgress: todoInProgress,
64
+ pending: todoTotal - todoCompleted - todoInProgress,
65
+ },
66
+ cost: `$${totalCost.toFixed(4)}`,
67
+ changes,
68
+ lastMessage: lastSnippet,
69
+ };
70
+ if (opts.json) {
71
+ console.log(formatJSON(summary));
72
+ return;
73
+ }
74
+ console.log(`Session: ${summary.sessionID}`);
75
+ console.log(`Title: ${summary.title}`);
76
+ console.log(`Status: ${summary.status}`);
77
+ console.log(`Updated: ${summary.updated}`);
78
+ if (todoTotal > 0) {
79
+ console.log(`Todos: ${todoCompleted}/${todoTotal} done, ${todoInProgress} in progress`);
80
+ }
81
+ console.log(`Cost: ${summary.cost}`);
82
+ console.log(`Changes: ${summary.changes}`);
83
+ if (lastSnippet) {
84
+ console.log(`Last msg: ${lastSnippet}`);
85
+ }
86
+ });
87
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function sessionTodoCommand(): Command;
@@ -0,0 +1,41 @@
1
+ import { Command } from "commander";
2
+ import { ensureServer } from "../client.js";
3
+ import { resolveSession } from "../resolve.js";
4
+ import { formatJSON } from "../format.js";
5
+ export function sessionTodoCommand() {
6
+ return new Command("todo")
7
+ .description("Get the todo list for a session")
8
+ .argument("[session-id]", "Session ID (defaults to most recent)")
9
+ .option("-j, --json", "Output as JSON")
10
+ .action(async (sessionId, opts) => {
11
+ const client = await ensureServer();
12
+ const resolved = await resolveSession(client, sessionId);
13
+ const result = await client.session.todo({
14
+ path: { id: resolved },
15
+ });
16
+ const todos = result.data ?? [];
17
+ if (opts.json) {
18
+ console.log(formatJSON(todos));
19
+ return;
20
+ }
21
+ if (todos.length === 0) {
22
+ console.log("No todos in session.");
23
+ return;
24
+ }
25
+ for (const todo of todos) {
26
+ const icon = todo.status === "completed"
27
+ ? "[x]"
28
+ : todo.status === "in_progress"
29
+ ? "[>]"
30
+ : todo.status === "cancelled"
31
+ ? "[-]"
32
+ : "[ ]";
33
+ const priority = todo.priority === "high"
34
+ ? "!"
35
+ : todo.priority === "low"
36
+ ? " "
37
+ : " ";
38
+ console.log(`${icon}${priority} ${todo.content}`);
39
+ }
40
+ });
41
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function sessionWaitForTextCommand(): Command;
@@ -0,0 +1,119 @@
1
+ import { Command } from "commander";
2
+ import { ensureServer, getClient } from "../client.js";
3
+ import { resolveSession } from "../resolve.js";
4
+ import { startStream } from "../sse.js";
5
+ import { extractText } from "../format.js";
6
+ export function sessionWaitForTextCommand() {
7
+ return new Command("wait-for-text")
8
+ .description("Silently wait until a message contains the given text, then output everything after it and exit 0")
9
+ .argument("<text>", "Text to wait for")
10
+ .argument("[session-id]", "Session ID (defaults to most recent)")
11
+ .option("-t, --timeout <seconds>", "Timeout in seconds (exit 1 if not found)", parseInt)
12
+ .option("--no-check-existing", "Skip checking existing messages (only watch for new ones)")
13
+ .action(async (text, sessionId, opts) => {
14
+ const client = await ensureServer();
15
+ const resolved = await resolveSession(client, sessionId);
16
+ // Shared found flag to prevent duplicate output from concurrent paths
17
+ let found = false;
18
+ const emitAndExit = (after) => {
19
+ if (found)
20
+ return;
21
+ found = true;
22
+ if (timer)
23
+ clearTimeout(timer);
24
+ process.stdout.write(after);
25
+ process.exit(0);
26
+ };
27
+ // Set up timeout if requested
28
+ let timer;
29
+ if (opts.timeout && opts.timeout > 0) {
30
+ timer = setTimeout(() => {
31
+ if (!found) {
32
+ console.error("Timeout: text not found.");
33
+ process.exit(1);
34
+ }
35
+ }, opts.timeout * 1000);
36
+ }
37
+ // Check existing messages first (default) to avoid race conditions.
38
+ if (opts.checkExisting !== false) {
39
+ const result = await client.session.messages({
40
+ path: { id: resolved },
41
+ });
42
+ const messages = result.data ?? [];
43
+ for (let i = messages.length - 1; i >= 0; i--) {
44
+ const fullText = extractText(messages[i].parts);
45
+ const idx = fullText.indexOf(text);
46
+ if (idx !== -1) {
47
+ emitAndExit(fullText.slice(idx + text.length).trimStart());
48
+ return;
49
+ }
50
+ }
51
+ }
52
+ // Track how many times the session has gone idle without finding the text.
53
+ // After the session goes idle and a full message check finds nothing,
54
+ // exit with failure rather than hanging forever.
55
+ let idleCheckPending = false;
56
+ // Accumulate text per message to detect markers split across deltas
57
+ const messageBuffers = new Map();
58
+ const handle = startStream(resolved, (event) => {
59
+ if (found)
60
+ return "stop";
61
+ if (event.type === "message.part.updated") {
62
+ const props = event.properties;
63
+ if (props.part.type !== "text" || !props.delta)
64
+ return;
65
+ const msgId = props.part.messageID;
66
+ const current = (messageBuffers.get(msgId) ?? "") + props.delta;
67
+ messageBuffers.set(msgId, current);
68
+ const idx = current.indexOf(text);
69
+ if (idx !== -1) {
70
+ emitAndExit(current.slice(idx + text.length).trimStart());
71
+ return "stop";
72
+ }
73
+ }
74
+ // When the session goes idle, do a full API check as fallback
75
+ if (event.type === "session.idle" && !idleCheckPending) {
76
+ idleCheckPending = true;
77
+ checkAllMessages(resolved, text).then((after) => {
78
+ idleCheckPending = false;
79
+ if (after !== null) {
80
+ emitAndExit(after);
81
+ }
82
+ // If text not found after idle, the session might restart.
83
+ // If it doesn't, the stream will eventually end or timeout
84
+ // will fire.
85
+ }).catch(() => {
86
+ idleCheckPending = false;
87
+ });
88
+ }
89
+ });
90
+ const streamResult = await handle.result;
91
+ if (!found) {
92
+ // Stream ended without finding the text
93
+ if (streamResult === "disconnected") {
94
+ console.error("Error: lost connection to OpenCode server.");
95
+ }
96
+ else {
97
+ console.error("Stream ended without finding text.");
98
+ }
99
+ if (timer)
100
+ clearTimeout(timer);
101
+ process.exit(1);
102
+ }
103
+ });
104
+ }
105
+ async function checkAllMessages(sessionId, text) {
106
+ const client = getClient();
107
+ const result = await client.session.messages({
108
+ path: { id: sessionId },
109
+ });
110
+ const messages = result.data ?? [];
111
+ for (let i = messages.length - 1; i >= 0; i--) {
112
+ const fullText = extractText(messages[i].parts);
113
+ const idx = fullText.indexOf(text);
114
+ if (idx !== -1) {
115
+ return fullText.slice(idx + text.length).trimStart();
116
+ }
117
+ }
118
+ return null;
119
+ }
@@ -0,0 +1,4 @@
1
+ import { Command } from "commander";
2
+ export declare function sessionWaitForIdleCommand(): Command;
3
+ export declare function sessionWaitAnyCommand(): Command;
4
+ export declare function sessionIsIdleCommand(): Command;
@@ -0,0 +1,85 @@
1
+ import { Command } from "commander";
2
+ import { ensureServer } from "../client.js";
3
+ import { resolveSession } from "../resolve.js";
4
+ import { waitForIdle, waitForAnyIdle } from "../wait-util.js";
5
+ import { formatJSON } from "../format.js";
6
+ // ─── wait-for-idle ─────────────────────────────────────
7
+ export function sessionWaitForIdleCommand() {
8
+ return new Command("wait-for-idle")
9
+ .description("Block until a session goes idle. Exits 0 when idle, 1 on timeout.")
10
+ .argument("[session-id]", "Session ID (defaults to most recent)")
11
+ .option("-t, --timeout <seconds>", "Timeout in seconds (exit 1 if not idle in time)", parseInt)
12
+ .action(async (sessionId, opts) => {
13
+ const client = await ensureServer();
14
+ const resolved = await resolveSession(client, sessionId);
15
+ const timeoutMs = opts.timeout ? opts.timeout * 1000 : undefined;
16
+ const result = await waitForIdle(client, resolved, timeoutMs);
17
+ if (result.idle) {
18
+ process.exit(0);
19
+ }
20
+ if (result.reason === "timeout") {
21
+ console.error("Timeout: session did not go idle in time.");
22
+ }
23
+ else if (result.reason === "disconnected") {
24
+ console.error("Error: lost connection to OpenCode server.");
25
+ }
26
+ process.exit(1);
27
+ });
28
+ }
29
+ // ─── wait-any ──────────────────────────────────────────
30
+ export function sessionWaitAnyCommand() {
31
+ return new Command("wait-any")
32
+ .description("Wait for the first of multiple sessions to go idle. Outputs the session ID that finished.")
33
+ .argument("<session-ids...>", "Two or more session IDs to watch")
34
+ .option("-t, --timeout <seconds>", "Timeout in seconds (exit 1 if none finish)", parseInt)
35
+ .option("-j, --json", "Output as JSON")
36
+ .action(async (sessionIds, opts) => {
37
+ const client = await ensureServer();
38
+ // Resolve all session IDs
39
+ const resolved = [];
40
+ for (const sid of sessionIds) {
41
+ resolved.push(await resolveSession(client, sid));
42
+ }
43
+ const timeoutMs = opts.timeout ? opts.timeout * 1000 : undefined;
44
+ const result = await waitForAnyIdle(client, resolved, timeoutMs);
45
+ if (result.sessionID) {
46
+ if (opts.json) {
47
+ console.log(formatJSON({ sessionID: result.sessionID, reason: result.reason }));
48
+ }
49
+ else {
50
+ console.log(result.sessionID);
51
+ }
52
+ process.exit(0);
53
+ }
54
+ if (result.reason === "timeout") {
55
+ console.error("Timeout: no session went idle in time.");
56
+ }
57
+ else if (result.reason === "disconnected") {
58
+ console.error("Error: lost connection to OpenCode server.");
59
+ }
60
+ process.exit(1);
61
+ });
62
+ }
63
+ // ─── is-idle ───────────────────────────────────────────
64
+ export function sessionIsIdleCommand() {
65
+ return new Command("is-idle")
66
+ .description("Check if a session is idle (non-blocking). Exit 0 = idle, exit 1 = busy.")
67
+ .argument("[session-id]", "Session ID (defaults to most recent)")
68
+ .option("-j, --json", "Output status as JSON")
69
+ .action(async (sessionId, opts) => {
70
+ const client = await ensureServer();
71
+ const resolved = await resolveSession(client, sessionId);
72
+ const statusResult = await client.session.status();
73
+ const statuses = statusResult.data ?? {};
74
+ const current = statuses[resolved];
75
+ const isIdle = !current || current.type === "idle";
76
+ if (opts.json) {
77
+ console.log(formatJSON({
78
+ sessionID: resolved,
79
+ idle: isIdle,
80
+ status: current?.type ?? "idle",
81
+ }));
82
+ }
83
+ process.exit(isIdle ? 0 : 1);
84
+ });
85
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function sessionWatchCommand(): Command;
@@ -0,0 +1,101 @@
1
+ import { Command } from "commander";
2
+ import { ensureServer } from "../client.js";
3
+ import { resolveSession } from "../resolve.js";
4
+ import { streamEvents } from "../sse.js";
5
+ export function sessionWatchCommand() {
6
+ return new Command("watch")
7
+ .description("Watch a session for real-time updates")
8
+ .argument("[session-id]", "Session ID (defaults to most recent)")
9
+ .option("-j, --json", "Output events as JSON")
10
+ .option("-e, --events <types>", "Comma-separated event types to filter (e.g. message.updated,session.idle)")
11
+ .option("-t, --text-only", "Only show text content as it streams")
12
+ .action(async (sessionId, opts) => {
13
+ const client = await ensureServer();
14
+ const resolved = await resolveSession(client, sessionId);
15
+ const filterTypes = opts.events
16
+ ? opts.events.split(",").map((s) => s.trim())
17
+ : null;
18
+ console.error(`Watching session ${resolved}...`);
19
+ console.error("Press Ctrl+C to stop.\n");
20
+ const result = await streamEvents(resolved, (event) => {
21
+ if (filterTypes && !filterTypes.includes(event.type))
22
+ return;
23
+ if (opts.json) {
24
+ console.log(JSON.stringify(event));
25
+ return;
26
+ }
27
+ if (opts.textOnly) {
28
+ handleTextOnly(event);
29
+ return;
30
+ }
31
+ handleEvent(event);
32
+ });
33
+ if (result === "disconnected") {
34
+ console.error("\nConnection lost.");
35
+ process.exit(1);
36
+ }
37
+ });
38
+ }
39
+ function handleTextOnly(event) {
40
+ if (event.type === "message.part.updated") {
41
+ const props = event.properties;
42
+ if (props.part.type === "text" && props.delta) {
43
+ process.stdout.write(props.delta);
44
+ }
45
+ }
46
+ }
47
+ function handleEvent(event) {
48
+ const time = new Date().toLocaleTimeString();
49
+ switch (event.type) {
50
+ case "message.updated": {
51
+ const props = event.properties;
52
+ console.log(`[${time}] message.updated: ${props.info.role} ${props.info.id}`);
53
+ break;
54
+ }
55
+ case "message.part.updated": {
56
+ const props = event.properties;
57
+ if (props.part.type === "text" && props.delta) {
58
+ process.stdout.write(props.delta);
59
+ }
60
+ else if (props.part.type === "tool") {
61
+ console.log(`[${time}] tool: ${props.part.tool} [${props.part.state?.status}]`);
62
+ }
63
+ break;
64
+ }
65
+ case "session.status": {
66
+ const props = event.properties;
67
+ console.log(`[${time}] session.status: ${props.status.type}`);
68
+ break;
69
+ }
70
+ case "session.idle": {
71
+ console.log(`\n[${time}] session.idle`);
72
+ break;
73
+ }
74
+ case "permission.updated": {
75
+ const props = event.properties;
76
+ console.log(`\n[${time}] PERMISSION REQUEST: ${props.title} (id: ${props.id}, type: ${props.type})`);
77
+ break;
78
+ }
79
+ case "todo.updated": {
80
+ const props = event.properties;
81
+ console.log(`[${time}] todo.updated:`);
82
+ for (const todo of props.todos) {
83
+ const icon = todo.status === "completed"
84
+ ? "[x]"
85
+ : todo.status === "in_progress"
86
+ ? "[>]"
87
+ : "[ ]";
88
+ console.log(` ${icon} ${todo.content}`);
89
+ }
90
+ break;
91
+ }
92
+ case "session.error": {
93
+ const props = event.properties;
94
+ console.log(`[${time}] ERROR: ${props.error?.name || "unknown"}`);
95
+ break;
96
+ }
97
+ default: {
98
+ console.log(`[${time}] ${event.type}`);
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function installSkillCommand(): Command;
3
+ export declare function viewSkillCommand(): Command;
@@ -0,0 +1,55 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync, mkdirSync, copyFileSync, existsSync } from "fs";
3
+ import { dirname, join, resolve } from "path";
4
+ import { fileURLToPath } from "url";
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ function getSkillPath() {
8
+ // SKILL.md lives at the package root, two levels up from dist/commands/
9
+ return resolve(__dirname, "..", "..", "SKILL.md");
10
+ }
11
+ function getInstallDir() {
12
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
13
+ return join(home, ".config", "opencode", "skills", "occtl");
14
+ }
15
+ export function installSkillCommand() {
16
+ return new Command("install-skill")
17
+ .description("Install the occtl skill as an OpenCode user-level skill")
18
+ .option("-f, --force", "Overwrite existing skill if present")
19
+ .action(async (opts) => {
20
+ const src = getSkillPath();
21
+ if (!existsSync(src)) {
22
+ console.error(`SKILL.md not found at ${src}`);
23
+ process.exit(1);
24
+ }
25
+ const destDir = getInstallDir();
26
+ const dest = join(destDir, "SKILL.md");
27
+ if (existsSync(dest) && !opts.force) {
28
+ console.error(`Skill already installed at ${destDir}`);
29
+ console.error("Use --force to overwrite.");
30
+ process.exit(1);
31
+ }
32
+ mkdirSync(destDir, { recursive: true });
33
+ copyFileSync(src, dest);
34
+ console.log(`Installed occtl skill to ${destDir}`);
35
+ console.log("Restart OpenCode to pick up the new skill.");
36
+ });
37
+ }
38
+ export function viewSkillCommand() {
39
+ return new Command("view-skill")
40
+ .description("Display the occtl SKILL.md contents")
41
+ .option("--path", "Only print the path to SKILL.md")
42
+ .action(async (opts) => {
43
+ const src = getSkillPath();
44
+ if (!existsSync(src)) {
45
+ console.error(`SKILL.md not found at ${src}`);
46
+ process.exit(1);
47
+ }
48
+ if (opts.path) {
49
+ console.log(src);
50
+ return;
51
+ }
52
+ const content = readFileSync(src, "utf-8");
53
+ console.log(content);
54
+ });
55
+ }
@@ -0,0 +1,5 @@
1
+ import { Command } from "commander";
2
+ export declare function worktreeListCommand(): Command;
3
+ export declare function worktreeCreateCommand(): Command;
4
+ export declare function worktreeRemoveCommand(): Command;
5
+ export declare function worktreeRunCommand(): Command;