@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.
- package/README.md +290 -0
- package/SKILL.md +692 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +64 -0
- package/dist/commands/session-abort.d.ts +2 -0
- package/dist/commands/session-abort.js +16 -0
- package/dist/commands/session-children.d.ts +2 -0
- package/dist/commands/session-children.js +30 -0
- package/dist/commands/session-create.d.ts +2 -0
- package/dist/commands/session-create.js +39 -0
- package/dist/commands/session-diff.d.ts +2 -0
- package/dist/commands/session-diff.js +35 -0
- package/dist/commands/session-get.d.ts +2 -0
- package/dist/commands/session-get.js +24 -0
- package/dist/commands/session-last.d.ts +2 -0
- package/dist/commands/session-last.js +39 -0
- package/dist/commands/session-list.d.ts +2 -0
- package/dist/commands/session-list.js +91 -0
- package/dist/commands/session-messages.d.ts +2 -0
- package/dist/commands/session-messages.js +44 -0
- package/dist/commands/session-respond.d.ts +2 -0
- package/dist/commands/session-respond.js +78 -0
- package/dist/commands/session-send.d.ts +2 -0
- package/dist/commands/session-send.js +114 -0
- package/dist/commands/session-share.d.ts +3 -0
- package/dist/commands/session-share.js +53 -0
- package/dist/commands/session-status.d.ts +2 -0
- package/dist/commands/session-status.js +45 -0
- package/dist/commands/session-summary.d.ts +2 -0
- package/dist/commands/session-summary.js +87 -0
- package/dist/commands/session-todo.d.ts +2 -0
- package/dist/commands/session-todo.js +41 -0
- package/dist/commands/session-wait-for-text.d.ts +2 -0
- package/dist/commands/session-wait-for-text.js +119 -0
- package/dist/commands/session-wait.d.ts +4 -0
- package/dist/commands/session-wait.js +85 -0
- package/dist/commands/session-watch.d.ts +2 -0
- package/dist/commands/session-watch.js +101 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +55 -0
- package/dist/commands/worktree.d.ts +5 -0
- package/dist/commands/worktree.js +359 -0
- package/dist/format.d.ts +19 -0
- package/dist/format.js +115 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -0
- package/dist/resolve.d.ts +6 -0
- package/dist/resolve.js +47 -0
- package/dist/sse.d.ts +40 -0
- package/dist/sse.js +128 -0
- package/dist/wait-util.d.ts +23 -0
- package/dist/wait-util.js +118 -0
- 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,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,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,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,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,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,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,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
|
+
}
|