@nt-ai-lab/opencode-skillz 0.2.8 → 0.3.2
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/AGENTS.md +1 -0
- package/dist/commands/dont-stop/hooks.d.ts +2 -0
- package/dist/commands/dont-stop/hooks.js +217 -0
- package/dist/commands/dont-stop/index.d.ts +1 -0
- package/dist/commands/dont-stop/index.js +1 -0
- package/dist/commands/dont-stop/register.d.ts +4 -0
- package/dist/commands/dont-stop/register.js +17 -0
- package/dist/commands/dont-stop/state.d.ts +13 -0
- package/dist/commands/dont-stop/state.js +25 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/plugin-registry/agents.d.ts +2 -0
- package/dist/plugin-registry/agents.js +61 -0
- package/dist/plugin-registry/command-names.d.ts +2 -0
- package/dist/plugin-registry/command-names.js +4 -0
- package/dist/plugin-registry/commands.d.ts +2 -0
- package/dist/plugin-registry/commands.js +59 -0
- package/dist/plugin-registry/index.d.ts +2 -0
- package/dist/plugin-registry/index.js +26 -0
- package/dist/plugin-registry/markdown.d.ts +7 -0
- package/dist/plugin-registry/markdown.js +48 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +1 -0
- package/package.json +16 -4
- package/index.js +0 -237
package/AGENTS.md
CHANGED
|
@@ -15,6 +15,7 @@ Purpose: package OpenCode workflow assets as a plugin.
|
|
|
15
15
|
- Put voice and persona behavior in agent prompts.
|
|
16
16
|
- Treat reusable skill content as command templates in `commands/`.
|
|
17
17
|
- Commands should be manually invoked by default.
|
|
18
|
+
- All plugin-provided commands must use the `nt-skillz:` prefix, including code-backed commands.
|
|
18
19
|
- Prefer minimal additions; only add new commands when needed.
|
|
19
20
|
- Do not add `agent:` in command frontmatter unless the command must force a specific agent.
|
|
20
21
|
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { CLEAR_DONT_STOP_COMMAND_NAME, DONT_STOP_COMMAND_NAME, } from "./register.js";
|
|
2
|
+
import { createDontStopState } from "./state.js";
|
|
3
|
+
function unwrapResponse(result) {
|
|
4
|
+
return typeof result === "object" && result !== null && "data" in result ? result.data : result;
|
|
5
|
+
}
|
|
6
|
+
function normalizeCriteria(value) {
|
|
7
|
+
return value
|
|
8
|
+
.split(/\n|;/)
|
|
9
|
+
.map((item) => item.replace(/^\s*[-*]\s*/, "").trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
function buildSystemInstruction(criteria) {
|
|
13
|
+
return [
|
|
14
|
+
"# dont-stop",
|
|
15
|
+
"",
|
|
16
|
+
"Acceptance criteria:",
|
|
17
|
+
...criteria.map((item, index) => `${index + 1}. ${item}`),
|
|
18
|
+
"",
|
|
19
|
+
"Rules:",
|
|
20
|
+
"- Continue working until every acceptance criterion is achieved or a concrete blocker exists.",
|
|
21
|
+
"- Do not stop at partial progress.",
|
|
22
|
+
"- Do not ask for confirmation between obvious non-destructive next steps.",
|
|
23
|
+
"- dont-stop stays active until the user explicitly runs /nt-skillz:clear-dont-stop.",
|
|
24
|
+
"- You must never declare the work complete or permanently blocked.",
|
|
25
|
+
"- You may only request completion or request blocked review with a strong and compelling reason.",
|
|
26
|
+
"- End every final response with exactly one machine-readable status block using this format:",
|
|
27
|
+
"",
|
|
28
|
+
"<dont-stop-status>",
|
|
29
|
+
"state: continue|completion-requested|blocked-requested",
|
|
30
|
+
...criteria.map((item, index) => `criterion-${index + 1}: achieved|not-achieved|unknown - ${item}`),
|
|
31
|
+
"justification: concise evidence-based justification",
|
|
32
|
+
"reason: none|short compelling reason",
|
|
33
|
+
"next: short next action",
|
|
34
|
+
"</dont-stop-status>",
|
|
35
|
+
"",
|
|
36
|
+
"State rules:",
|
|
37
|
+
"- continue = more work should be done now",
|
|
38
|
+
"- completion-requested = you believe the criteria are met and request user approval to stop",
|
|
39
|
+
"- blocked-requested = you believe progress is blocked and request user review of the blocker",
|
|
40
|
+
"- A request does not stop dont-stop. Only the user can clear it.",
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
function parseAssistantStatus(text) {
|
|
44
|
+
const match = text.match(/<dont-stop-status>([\s\S]*?)<\/dont-stop-status>/i);
|
|
45
|
+
if (!match)
|
|
46
|
+
return null;
|
|
47
|
+
const body = match[1];
|
|
48
|
+
const stateMatch = body.match(/^\s*state\s*:\s*(continue|completion-requested|blocked-requested)\s*$/im);
|
|
49
|
+
const justificationMatch = body.match(/^\s*justification\s*:\s*(.+)\s*$/im);
|
|
50
|
+
const reasonMatch = body.match(/^\s*reason\s*:\s*(.+)\s*$/im);
|
|
51
|
+
return {
|
|
52
|
+
state: stateMatch?.[1] ?? "continue",
|
|
53
|
+
justification: justificationMatch?.[1]?.trim() ?? "",
|
|
54
|
+
reason: reasonMatch?.[1]?.trim() ?? "",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function showToast(client, body) {
|
|
58
|
+
try {
|
|
59
|
+
await client.tui.showToast({ body });
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
62
|
+
}
|
|
63
|
+
async function getLatestAssistantStatus(client, sessionID) {
|
|
64
|
+
const result = unwrapResponse(await client.session.messages({ path: { id: sessionID } }));
|
|
65
|
+
const messages = Array.isArray(result) ? result : [];
|
|
66
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
67
|
+
const entry = messages[index];
|
|
68
|
+
if (entry.info?.role !== "assistant")
|
|
69
|
+
continue;
|
|
70
|
+
const text = (entry.parts ?? [])
|
|
71
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
72
|
+
.map((part) => part.text)
|
|
73
|
+
.join("\n");
|
|
74
|
+
return parseAssistantStatus(text);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function buildContinuationPrompt(criteria, status) {
|
|
79
|
+
const reportedState = status?.state ?? "missing";
|
|
80
|
+
const reason = status?.reason || "none";
|
|
81
|
+
return [
|
|
82
|
+
"dont-stop remains active.",
|
|
83
|
+
"",
|
|
84
|
+
"Acceptance criteria:",
|
|
85
|
+
...criteria.map((item, index) => `${index + 1}. ${item}`),
|
|
86
|
+
"",
|
|
87
|
+
`Last reported state: ${reportedState}`,
|
|
88
|
+
`Last reported reason: ${reason}`,
|
|
89
|
+
"",
|
|
90
|
+
"If the work seems done, request completion with the required status block.",
|
|
91
|
+
"A completion request must include a clear justification that all acceptance criteria have been achieved.",
|
|
92
|
+
"If a concrete blocker exists, request blocked review with the required status block.",
|
|
93
|
+
"Otherwise continue working immediately on the unmet criteria and end with the required status block.",
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
96
|
+
function buildReviewRequestKey(status) {
|
|
97
|
+
return `${status.state}:${status.reason}`;
|
|
98
|
+
}
|
|
99
|
+
async function notifyReviewRequested(client, state, sessionID, status) {
|
|
100
|
+
const session = state.get(sessionID);
|
|
101
|
+
if (!session)
|
|
102
|
+
return;
|
|
103
|
+
const reviewKey = buildReviewRequestKey(status);
|
|
104
|
+
if (session.pendingReviewKey === reviewKey)
|
|
105
|
+
return;
|
|
106
|
+
session.pendingReviewKey = reviewKey;
|
|
107
|
+
const message = status.state === "completion-requested"
|
|
108
|
+
? "dont-stop completion requested; run /nt-skillz:clear-dont-stop to accept or reply to continue"
|
|
109
|
+
: "dont-stop blocker review requested; run /nt-skillz:clear-dont-stop to accept or reply to continue";
|
|
110
|
+
await showToast(client, {
|
|
111
|
+
message,
|
|
112
|
+
variant: "info",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function getDeletedSessionID(event) {
|
|
116
|
+
if (event.type !== "session.deleted")
|
|
117
|
+
return undefined;
|
|
118
|
+
const properties = event.properties;
|
|
119
|
+
if (!properties || typeof properties !== "object")
|
|
120
|
+
return undefined;
|
|
121
|
+
const info = properties.info;
|
|
122
|
+
return typeof info?.id === "string" ? info.id : undefined;
|
|
123
|
+
}
|
|
124
|
+
function getIdleSessionID(event) {
|
|
125
|
+
if (event.type !== "session.idle")
|
|
126
|
+
return undefined;
|
|
127
|
+
const properties = event.properties;
|
|
128
|
+
if (!properties || typeof properties !== "object")
|
|
129
|
+
return undefined;
|
|
130
|
+
const sessionID = properties.sessionID;
|
|
131
|
+
return typeof sessionID === "string" ? sessionID : undefined;
|
|
132
|
+
}
|
|
133
|
+
async function handleCommand(client, state, input, output) {
|
|
134
|
+
if (input.command === CLEAR_DONT_STOP_COMMAND_NAME) {
|
|
135
|
+
state.clear(input.sessionID);
|
|
136
|
+
output.parts = [];
|
|
137
|
+
await showToast(client, {
|
|
138
|
+
message: "dont-stop cleared for this session",
|
|
139
|
+
variant: "success",
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (input.command !== DONT_STOP_COMMAND_NAME)
|
|
144
|
+
return;
|
|
145
|
+
const criteria = normalizeCriteria(input.arguments);
|
|
146
|
+
output.parts = [];
|
|
147
|
+
if (!criteria.length) {
|
|
148
|
+
await showToast(client, {
|
|
149
|
+
message: "/nt-skillz:dont-stop requires acceptance criteria",
|
|
150
|
+
variant: "error",
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
state.set(input.sessionID, { criteria });
|
|
155
|
+
await showToast(client, {
|
|
156
|
+
message: `dont-stop enabled for this session (${criteria.length} criteria)`,
|
|
157
|
+
variant: "success",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function handleSystemTransform(state, input, output) {
|
|
161
|
+
if (!input.sessionID)
|
|
162
|
+
return;
|
|
163
|
+
const session = state.get(input.sessionID);
|
|
164
|
+
if (!session)
|
|
165
|
+
return;
|
|
166
|
+
output.system.push(buildSystemInstruction(session.criteria));
|
|
167
|
+
}
|
|
168
|
+
async function handleEvent(client, state, event) {
|
|
169
|
+
const deletedSessionID = getDeletedSessionID(event);
|
|
170
|
+
if (deletedSessionID) {
|
|
171
|
+
state.clear(deletedSessionID);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const sessionID = getIdleSessionID(event);
|
|
175
|
+
if (!sessionID)
|
|
176
|
+
return;
|
|
177
|
+
const session = state.get(sessionID);
|
|
178
|
+
if (!session || state.isBusy(sessionID))
|
|
179
|
+
return;
|
|
180
|
+
state.startBusy(sessionID);
|
|
181
|
+
try {
|
|
182
|
+
const latestStatus = await getLatestAssistantStatus(client, sessionID);
|
|
183
|
+
if (latestStatus?.state === "completion-requested" || latestStatus?.state === "blocked-requested") {
|
|
184
|
+
await notifyReviewRequested(client, state, sessionID, latestStatus);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
session.pendingReviewKey = undefined;
|
|
188
|
+
await client.session.prompt({
|
|
189
|
+
path: { id: sessionID },
|
|
190
|
+
body: {
|
|
191
|
+
parts: [
|
|
192
|
+
{
|
|
193
|
+
type: "text",
|
|
194
|
+
text: buildContinuationPrompt(session.criteria, latestStatus),
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
state.finishBusy(sessionID);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export function createDontStopHooks(client) {
|
|
205
|
+
const state = createDontStopState();
|
|
206
|
+
return {
|
|
207
|
+
"command.execute.before": async (input, output) => {
|
|
208
|
+
await handleCommand(client, state, input, output);
|
|
209
|
+
},
|
|
210
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
211
|
+
await handleSystemTransform(state, input, output);
|
|
212
|
+
},
|
|
213
|
+
event: async ({ event }) => {
|
|
214
|
+
await handleEvent(client, state, event);
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDontStopHooks } from "./hooks.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDontStopHooks } from "./hooks.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CommandDefinition } from "../../types.js";
|
|
2
|
+
export declare const DONT_STOP_COMMAND_NAME: string;
|
|
3
|
+
export declare const CLEAR_DONT_STOP_COMMAND_NAME: string;
|
|
4
|
+
export declare function registerDontStopCommands(commandConfig: Record<string, CommandDefinition>): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { buildCommandName } from "../../plugin-registry/command-names.js";
|
|
2
|
+
export const DONT_STOP_COMMAND_NAME = buildCommandName("dont-stop");
|
|
3
|
+
export const CLEAR_DONT_STOP_COMMAND_NAME = buildCommandName("clear-dont-stop");
|
|
4
|
+
export function registerDontStopCommands(commandConfig) {
|
|
5
|
+
if (!commandConfig[DONT_STOP_COMMAND_NAME]) {
|
|
6
|
+
commandConfig[DONT_STOP_COMMAND_NAME] = {
|
|
7
|
+
description: "Activate idle continuation for this session",
|
|
8
|
+
template: "",
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
if (!commandConfig[CLEAR_DONT_STOP_COMMAND_NAME]) {
|
|
12
|
+
commandConfig[CLEAR_DONT_STOP_COMMAND_NAME] = {
|
|
13
|
+
description: "Disable idle continuation for this session",
|
|
14
|
+
template: "",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface DontStopSessionState {
|
|
2
|
+
criteria: string[];
|
|
3
|
+
pendingReviewKey?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface DontStopState {
|
|
6
|
+
get(sessionID: string): DontStopSessionState | undefined;
|
|
7
|
+
set(sessionID: string, state: DontStopSessionState): void;
|
|
8
|
+
clear(sessionID: string): void;
|
|
9
|
+
isBusy(sessionID: string): boolean;
|
|
10
|
+
startBusy(sessionID: string): void;
|
|
11
|
+
finishBusy(sessionID: string): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createDontStopState(): DontStopState;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function createDontStopState() {
|
|
2
|
+
const sessions = new Map();
|
|
3
|
+
const busySessions = new Set();
|
|
4
|
+
return {
|
|
5
|
+
get(sessionID) {
|
|
6
|
+
return sessions.get(sessionID);
|
|
7
|
+
},
|
|
8
|
+
set(sessionID, state) {
|
|
9
|
+
sessions.set(sessionID, state);
|
|
10
|
+
},
|
|
11
|
+
clear(sessionID) {
|
|
12
|
+
sessions.delete(sessionID);
|
|
13
|
+
busySessions.delete(sessionID);
|
|
14
|
+
},
|
|
15
|
+
isBusy(sessionID) {
|
|
16
|
+
return busySessions.has(sessionID);
|
|
17
|
+
},
|
|
18
|
+
startBusy(sessionID) {
|
|
19
|
+
busySessions.add(sessionID);
|
|
20
|
+
},
|
|
21
|
+
finishBusy(sessionID) {
|
|
22
|
+
busySessions.delete(sessionID);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { createPluginRegistry } from "./plugin-registry/index.js";
|
|
4
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const pluginRoot = path.resolve(__dirname, "..");
|
|
6
|
+
export const OpencodeSkillzPlugin = async (input) => createPluginRegistry(input, pluginRoot);
|
|
7
|
+
export default OpencodeSkillzPlugin;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readMarkdownEntries } from "./markdown.js";
|
|
2
|
+
function parseCsvList(value) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
return [];
|
|
5
|
+
return value
|
|
6
|
+
.split(",")
|
|
7
|
+
.map((item) => item.trim())
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
function materializePreloadedTemplate(template) {
|
|
11
|
+
return template.replace(/\$ARGUMENTS/g, "all relevant current work in this session");
|
|
12
|
+
}
|
|
13
|
+
export function registerAgents(agentConfig, pluginRoot, commands) {
|
|
14
|
+
const rawAgents = readMarkdownEntries(pluginRoot, "agents");
|
|
15
|
+
function collectPreloadedCommands(agentName, stack = new Set()) {
|
|
16
|
+
const rawAgent = rawAgents[agentName];
|
|
17
|
+
if (!rawAgent)
|
|
18
|
+
return [];
|
|
19
|
+
if (stack.has(agentName))
|
|
20
|
+
return parseCsvList(rawAgent.meta.preload_commands);
|
|
21
|
+
stack.add(agentName);
|
|
22
|
+
const merged = [];
|
|
23
|
+
const parentAgentName = typeof rawAgent.meta.extends === "string" ? rawAgent.meta.extends.trim() : "";
|
|
24
|
+
if (parentAgentName && rawAgents[parentAgentName]) {
|
|
25
|
+
merged.push(...collectPreloadedCommands(parentAgentName, stack));
|
|
26
|
+
}
|
|
27
|
+
merged.push(...parseCsvList(rawAgent.meta.preload_commands));
|
|
28
|
+
stack.delete(agentName);
|
|
29
|
+
return [...new Set(merged)];
|
|
30
|
+
}
|
|
31
|
+
for (const [name, rawAgent] of Object.entries(rawAgents)) {
|
|
32
|
+
if (agentConfig[name])
|
|
33
|
+
continue;
|
|
34
|
+
const promptParts = [];
|
|
35
|
+
const parentAgentName = typeof rawAgent.meta.extends === "string" ? rawAgent.meta.extends.trim() : "";
|
|
36
|
+
if (parentAgentName && rawAgents[parentAgentName]?.body) {
|
|
37
|
+
promptParts.push(rawAgents[parentAgentName].body);
|
|
38
|
+
}
|
|
39
|
+
if (rawAgent.body) {
|
|
40
|
+
promptParts.push(rawAgent.body);
|
|
41
|
+
}
|
|
42
|
+
for (const commandName of collectPreloadedCommands(name)) {
|
|
43
|
+
const command = commands[commandName];
|
|
44
|
+
if (!command?.template)
|
|
45
|
+
continue;
|
|
46
|
+
promptParts.push(`[Preloaded command /${commandName}]\n${materializePreloadedTemplate(command.template)}`);
|
|
47
|
+
}
|
|
48
|
+
const agent = {
|
|
49
|
+
prompt: promptParts.join("\n\n").trim(),
|
|
50
|
+
};
|
|
51
|
+
if (typeof rawAgent.meta.description === "string")
|
|
52
|
+
agent.description = rawAgent.meta.description;
|
|
53
|
+
if (typeof rawAgent.meta.mode === "string")
|
|
54
|
+
agent.mode = rawAgent.meta.mode;
|
|
55
|
+
if (typeof rawAgent.meta.model === "string")
|
|
56
|
+
agent.model = rawAgent.meta.model;
|
|
57
|
+
if (typeof rawAgent.meta.color === "string")
|
|
58
|
+
agent.color = rawAgent.meta.color;
|
|
59
|
+
agentConfig[name] = agent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { registerDontStopCommands } from "../commands/dont-stop/register.js";
|
|
2
|
+
import { buildCommandName } from "./command-names.js";
|
|
3
|
+
import { readMarkdownEntries } from "./markdown.js";
|
|
4
|
+
function normalizeCommandReference(value) {
|
|
5
|
+
if (typeof value !== "string")
|
|
6
|
+
return "";
|
|
7
|
+
return value.trim().replace(/_/g, "-");
|
|
8
|
+
}
|
|
9
|
+
function loadMarkdownCommands(pluginRoot) {
|
|
10
|
+
const rawCommands = readMarkdownEntries(pluginRoot, "commands");
|
|
11
|
+
const commands = {};
|
|
12
|
+
function buildComposedTemplate(name, stack = new Set()) {
|
|
13
|
+
const rawCommand = rawCommands[name];
|
|
14
|
+
if (!rawCommand)
|
|
15
|
+
return "";
|
|
16
|
+
if (stack.has(name))
|
|
17
|
+
return rawCommand.body;
|
|
18
|
+
stack.add(name);
|
|
19
|
+
let template = rawCommand.body;
|
|
20
|
+
const composeAfterName = normalizeCommandReference(rawCommand.meta.compose_after);
|
|
21
|
+
const composedCommand = rawCommands[composeAfterName];
|
|
22
|
+
if (composeAfterName && composedCommand) {
|
|
23
|
+
const composedTemplate = buildComposedTemplate(composeAfterName, stack);
|
|
24
|
+
if (composedTemplate) {
|
|
25
|
+
template = [template, `In addition you must adhere to the following:\n\n${composedTemplate}`]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join("\n\n");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
stack.delete(name);
|
|
31
|
+
return template.trim();
|
|
32
|
+
}
|
|
33
|
+
for (const [name, rawCommand] of Object.entries(rawCommands)) {
|
|
34
|
+
const description = typeof rawCommand.meta.description === "string" ? rawCommand.meta.description : `Run /${name}`;
|
|
35
|
+
const command = {
|
|
36
|
+
description,
|
|
37
|
+
template: buildComposedTemplate(name),
|
|
38
|
+
};
|
|
39
|
+
if (typeof rawCommand.meta.agent === "string")
|
|
40
|
+
command.agent = rawCommand.meta.agent;
|
|
41
|
+
if (typeof rawCommand.meta.model === "string")
|
|
42
|
+
command.model = rawCommand.meta.model;
|
|
43
|
+
if (typeof rawCommand.meta.subtask === "boolean")
|
|
44
|
+
command.subtask = rawCommand.meta.subtask;
|
|
45
|
+
commands[name] = command;
|
|
46
|
+
}
|
|
47
|
+
return commands;
|
|
48
|
+
}
|
|
49
|
+
export function registerCommands(commandConfig, pluginRoot) {
|
|
50
|
+
const markdownCommands = loadMarkdownCommands(pluginRoot);
|
|
51
|
+
for (const [name, command] of Object.entries(markdownCommands)) {
|
|
52
|
+
const commandName = buildCommandName(name);
|
|
53
|
+
if (!commandConfig[commandName]) {
|
|
54
|
+
commandConfig[commandName] = command;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
registerDontStopCommands(commandConfig);
|
|
58
|
+
return markdownCommands;
|
|
59
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createDontStopHooks } from "../commands/dont-stop/index.js";
|
|
2
|
+
import { registerAgents } from "./agents.js";
|
|
3
|
+
import { registerCommands } from "./commands.js";
|
|
4
|
+
export function createPluginRegistry(input, pluginRoot) {
|
|
5
|
+
const dontStopHooks = createDontStopHooks(input.client);
|
|
6
|
+
return {
|
|
7
|
+
...dontStopHooks,
|
|
8
|
+
config: async (config) => {
|
|
9
|
+
config.command ??= {};
|
|
10
|
+
config.agent ??= {};
|
|
11
|
+
const commands = registerCommands(config.command, pluginRoot);
|
|
12
|
+
registerAgents(config.agent, pluginRoot, commands);
|
|
13
|
+
config.agent.build = {
|
|
14
|
+
...(config.agent.build ?? {}),
|
|
15
|
+
disable: true,
|
|
16
|
+
};
|
|
17
|
+
config.agent.plan = {
|
|
18
|
+
...(config.agent.plan ?? {}),
|
|
19
|
+
disable: true,
|
|
20
|
+
};
|
|
21
|
+
if (!config.default_agent && config.agent.default) {
|
|
22
|
+
config.default_agent = "default";
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type FrontmatterValue = string | boolean;
|
|
2
|
+
export interface MarkdownEntry {
|
|
3
|
+
meta: Record<string, FrontmatterValue>;
|
|
4
|
+
body: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function readMarkdownEntries(pluginRoot: string, directoryName: string): Record<string, MarkdownEntry>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function extractFrontmatter(content) {
|
|
4
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
5
|
+
if (!match)
|
|
6
|
+
return { meta: {}, body: content };
|
|
7
|
+
const meta = {};
|
|
8
|
+
for (const rawLine of match[1].split("\n")) {
|
|
9
|
+
const line = rawLine.trim();
|
|
10
|
+
if (!line || line.startsWith("#"))
|
|
11
|
+
continue;
|
|
12
|
+
const separatorIndex = line.indexOf(":");
|
|
13
|
+
if (separatorIndex <= 0)
|
|
14
|
+
continue;
|
|
15
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
16
|
+
let value = line.slice(separatorIndex + 1).trim().replace(/^['\"]|['\"]$/g, "");
|
|
17
|
+
if (value === "true")
|
|
18
|
+
value = true;
|
|
19
|
+
if (value === "false")
|
|
20
|
+
value = false;
|
|
21
|
+
meta[key] = value;
|
|
22
|
+
}
|
|
23
|
+
return { meta, body: match[2] };
|
|
24
|
+
}
|
|
25
|
+
function readMarkdownFiles(directoryPath) {
|
|
26
|
+
if (!fs.existsSync(directoryPath))
|
|
27
|
+
return [];
|
|
28
|
+
return fs
|
|
29
|
+
.readdirSync(directoryPath)
|
|
30
|
+
.filter((file) => file.endsWith(".md"))
|
|
31
|
+
.sort((left, right) => left.localeCompare(right));
|
|
32
|
+
}
|
|
33
|
+
export function readMarkdownEntries(pluginRoot, directoryName) {
|
|
34
|
+
const directoryPath = path.join(pluginRoot, directoryName);
|
|
35
|
+
const files = readMarkdownFiles(directoryPath);
|
|
36
|
+
const entries = {};
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const name = file.replace(/\.md$/, "");
|
|
39
|
+
const fullPath = path.join(directoryPath, file);
|
|
40
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
41
|
+
const entry = extractFrontmatter(content);
|
|
42
|
+
entries[name] = {
|
|
43
|
+
meta: entry.meta,
|
|
44
|
+
body: entry.body.trim(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return entries;
|
|
48
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface CommandDefinition {
|
|
2
|
+
description?: string;
|
|
3
|
+
template: string;
|
|
4
|
+
agent?: string;
|
|
5
|
+
model?: string;
|
|
6
|
+
subtask?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface AgentDefinition {
|
|
9
|
+
prompt: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
mode?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
color?: string;
|
|
14
|
+
disable?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface PluginConfig {
|
|
17
|
+
command?: Record<string, CommandDefinition>;
|
|
18
|
+
agent?: Record<string, AgentDefinition>;
|
|
19
|
+
default_agent?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CommandExecuteBeforeInput {
|
|
22
|
+
command: string;
|
|
23
|
+
sessionID: string;
|
|
24
|
+
arguments: string;
|
|
25
|
+
}
|
|
26
|
+
export interface CommandExecuteBeforeOutput {
|
|
27
|
+
parts: unknown[];
|
|
28
|
+
}
|
|
29
|
+
export interface ChatSystemTransformInput {
|
|
30
|
+
sessionID?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ChatSystemTransformOutput {
|
|
33
|
+
system: string[];
|
|
34
|
+
}
|
|
35
|
+
export interface SessionEvent {
|
|
36
|
+
type: string;
|
|
37
|
+
properties?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
export interface ToastBody {
|
|
40
|
+
title?: string;
|
|
41
|
+
message: string;
|
|
42
|
+
variant: "info" | "success" | "warning" | "error";
|
|
43
|
+
duration?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface OpencodeClient {
|
|
46
|
+
tui: {
|
|
47
|
+
showToast(args: {
|
|
48
|
+
body: ToastBody;
|
|
49
|
+
}): Promise<unknown>;
|
|
50
|
+
};
|
|
51
|
+
session: {
|
|
52
|
+
messages(args: {
|
|
53
|
+
path: {
|
|
54
|
+
id: string;
|
|
55
|
+
};
|
|
56
|
+
}): Promise<unknown>;
|
|
57
|
+
prompt(args: {
|
|
58
|
+
path: {
|
|
59
|
+
id: string;
|
|
60
|
+
};
|
|
61
|
+
body: {
|
|
62
|
+
parts: Array<{
|
|
63
|
+
type: "text";
|
|
64
|
+
text: string;
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
}): Promise<unknown>;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export interface PluginHooks {
|
|
71
|
+
config?: (config: PluginConfig) => Promise<void>;
|
|
72
|
+
"command.execute.before"?: (input: CommandExecuteBeforeInput, output: CommandExecuteBeforeOutput) => Promise<void>;
|
|
73
|
+
"experimental.chat.system.transform"?: (input: ChatSystemTransformInput, output: ChatSystemTransformOutput) => Promise<void>;
|
|
74
|
+
event?: (input: {
|
|
75
|
+
event: SessionEvent;
|
|
76
|
+
}) => Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
export interface PluginInput {
|
|
79
|
+
client: OpencodeClient;
|
|
80
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nt-ai-lab/opencode-skillz",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Bundled OpenCode commands and agents",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"prepack": "npm run build"
|
|
9
17
|
},
|
|
10
18
|
"files": [
|
|
11
|
-
"
|
|
19
|
+
"dist",
|
|
12
20
|
"commands",
|
|
13
21
|
"agents",
|
|
14
22
|
"AGENTS.md",
|
|
15
23
|
"README.md"
|
|
16
24
|
],
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^24.7.2",
|
|
27
|
+
"typescript": "^5.9.3"
|
|
28
|
+
},
|
|
17
29
|
"publishConfig": {
|
|
18
30
|
"access": "public"
|
|
19
31
|
}
|
package/index.js
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
import { fileURLToPath } from "node:url"
|
|
4
|
-
|
|
5
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
6
|
-
const pluginRoot = __dirname
|
|
7
|
-
const commandNamespace = "nt-skillz"
|
|
8
|
-
|
|
9
|
-
function buildCommandName(name) {
|
|
10
|
-
return `${commandNamespace}:${name}`
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function extractFrontmatter(content) {
|
|
14
|
-
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
|
|
15
|
-
if (!match) return { meta: {}, body: content }
|
|
16
|
-
|
|
17
|
-
const meta = {}
|
|
18
|
-
for (const rawLine of match[1].split("\n")) {
|
|
19
|
-
const line = rawLine.trim()
|
|
20
|
-
if (!line || line.startsWith("#")) continue
|
|
21
|
-
const idx = line.indexOf(":")
|
|
22
|
-
if (idx <= 0) continue
|
|
23
|
-
|
|
24
|
-
const key = line.slice(0, idx).trim()
|
|
25
|
-
let value = line.slice(idx + 1).trim()
|
|
26
|
-
value = value.replace(/^['\"]|['\"]$/g, "")
|
|
27
|
-
if (value === "true") value = true
|
|
28
|
-
if (value === "false") value = false
|
|
29
|
-
meta[key] = value
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return { meta, body: match[2] }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function readMarkdownFiles(dirPath) {
|
|
36
|
-
if (!fs.existsSync(dirPath)) return []
|
|
37
|
-
return fs
|
|
38
|
-
.readdirSync(dirPath)
|
|
39
|
-
.filter((file) => file.endsWith(".md"))
|
|
40
|
-
.sort((a, b) => a.localeCompare(b))
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function normalizeCommandReference(value) {
|
|
44
|
-
if (!value || typeof value !== "string") return ""
|
|
45
|
-
return value.trim().replace(/_/g, "-")
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function loadCommands() {
|
|
49
|
-
const commandsDir = path.join(pluginRoot, "commands")
|
|
50
|
-
const files = readMarkdownFiles(commandsDir)
|
|
51
|
-
const rawCommands = {}
|
|
52
|
-
|
|
53
|
-
for (const file of files) {
|
|
54
|
-
const name = file.replace(/\.md$/, "")
|
|
55
|
-
const fullPath = path.join(commandsDir, file)
|
|
56
|
-
const content = fs.readFileSync(fullPath, "utf8")
|
|
57
|
-
const { meta, body } = extractFrontmatter(content)
|
|
58
|
-
|
|
59
|
-
rawCommands[name] = {
|
|
60
|
-
meta,
|
|
61
|
-
body: body.trim(),
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const commands = {}
|
|
66
|
-
|
|
67
|
-
function buildComposedTemplate(name, stack = new Set()) {
|
|
68
|
-
const rawCommand = rawCommands[name]
|
|
69
|
-
if (!rawCommand) return ""
|
|
70
|
-
if (stack.has(name)) return rawCommand.body
|
|
71
|
-
|
|
72
|
-
stack.add(name)
|
|
73
|
-
|
|
74
|
-
let template = rawCommand.body
|
|
75
|
-
const composeAfterName = normalizeCommandReference(rawCommand.meta.compose_after)
|
|
76
|
-
const composedCommand = rawCommands[composeAfterName]
|
|
77
|
-
|
|
78
|
-
if (composeAfterName && composedCommand) {
|
|
79
|
-
const composedTemplate = buildComposedTemplate(composeAfterName, stack)
|
|
80
|
-
if (composedTemplate) {
|
|
81
|
-
template = [
|
|
82
|
-
template,
|
|
83
|
-
`In addition you must adhere to the following:\n\n${composedTemplate}`,
|
|
84
|
-
]
|
|
85
|
-
.filter(Boolean)
|
|
86
|
-
.join("\n\n")
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
stack.delete(name)
|
|
91
|
-
|
|
92
|
-
return template.trim()
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
for (const [name, rawCommand] of Object.entries(rawCommands)) {
|
|
96
|
-
const { meta } = rawCommand
|
|
97
|
-
const command = {
|
|
98
|
-
description: meta.description || `Run /${name}`,
|
|
99
|
-
template: buildComposedTemplate(name),
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (meta.agent) command.agent = meta.agent
|
|
103
|
-
if (meta.model) command.model = meta.model
|
|
104
|
-
if (typeof meta.subtask === "boolean") command.subtask = meta.subtask
|
|
105
|
-
|
|
106
|
-
commands[name] = command
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return commands
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function parseCsvList(value) {
|
|
113
|
-
if (!value || typeof value !== "string") return []
|
|
114
|
-
return value
|
|
115
|
-
.split(",")
|
|
116
|
-
.map((item) => item.trim())
|
|
117
|
-
.filter(Boolean)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function materializePreloadedTemplate(template) {
|
|
121
|
-
return template.replace(/\$ARGUMENTS/g, "all relevant current work in this session")
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function loadAgents(commands) {
|
|
125
|
-
const agentsDir = path.join(pluginRoot, "agents")
|
|
126
|
-
const files = readMarkdownFiles(agentsDir)
|
|
127
|
-
const rawAgents = {}
|
|
128
|
-
|
|
129
|
-
for (const file of files) {
|
|
130
|
-
const name = file.replace(/\.md$/, "")
|
|
131
|
-
const fullPath = path.join(agentsDir, file)
|
|
132
|
-
const content = fs.readFileSync(fullPath, "utf8")
|
|
133
|
-
const { meta, body } = extractFrontmatter(content)
|
|
134
|
-
|
|
135
|
-
rawAgents[name] = {
|
|
136
|
-
meta,
|
|
137
|
-
body: body.trim(),
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const agents = {}
|
|
142
|
-
|
|
143
|
-
function collectPreloadedCommands(agentName, stack = new Set()) {
|
|
144
|
-
const rawAgent = rawAgents[agentName]
|
|
145
|
-
if (!rawAgent) return []
|
|
146
|
-
if (stack.has(agentName)) return parseCsvList(rawAgent.meta.preload_commands)
|
|
147
|
-
|
|
148
|
-
stack.add(agentName)
|
|
149
|
-
|
|
150
|
-
const merged = []
|
|
151
|
-
const parentAgentName = typeof rawAgent.meta.extends === "string" ? rawAgent.meta.extends.trim() : ""
|
|
152
|
-
if (parentAgentName && rawAgents[parentAgentName]) {
|
|
153
|
-
merged.push(...collectPreloadedCommands(parentAgentName, stack))
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
merged.push(...parseCsvList(rawAgent.meta.preload_commands))
|
|
157
|
-
stack.delete(agentName)
|
|
158
|
-
|
|
159
|
-
return [...new Set(merged)]
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
for (const [name, raw] of Object.entries(rawAgents)) {
|
|
163
|
-
const { meta, body } = raw
|
|
164
|
-
const promptParts = []
|
|
165
|
-
|
|
166
|
-
const parentAgentName = typeof meta.extends === "string" ? meta.extends.trim() : ""
|
|
167
|
-
if (parentAgentName && rawAgents[parentAgentName]) {
|
|
168
|
-
const parentPrompt = rawAgents[parentAgentName].body
|
|
169
|
-
if (parentPrompt) promptParts.push(parentPrompt)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (body) {
|
|
173
|
-
promptParts.push(body)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const preloadedCommands = collectPreloadedCommands(name)
|
|
177
|
-
for (const commandName of preloadedCommands) {
|
|
178
|
-
const command = commands[commandName]
|
|
179
|
-
if (!command || !command.template) continue
|
|
180
|
-
const rendered = materializePreloadedTemplate(command.template)
|
|
181
|
-
promptParts.push(`[Preloaded command /${commandName}]\n${rendered}`)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const agent = {
|
|
185
|
-
prompt: promptParts.join("\n\n").trim(),
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (meta.description) agent.description = meta.description
|
|
189
|
-
if (meta.mode) agent.mode = meta.mode
|
|
190
|
-
if (meta.model) agent.model = meta.model
|
|
191
|
-
if (meta.color) agent.color = meta.color
|
|
192
|
-
|
|
193
|
-
agents[name] = agent
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return agents
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export const OpencodeSkillzPlugin = async () => {
|
|
200
|
-
return {
|
|
201
|
-
config: async (config) => {
|
|
202
|
-
config.command = config.command || {}
|
|
203
|
-
config.agent = config.agent || {}
|
|
204
|
-
|
|
205
|
-
const commands = loadCommands()
|
|
206
|
-
for (const [name, command] of Object.entries(commands)) {
|
|
207
|
-
const commandName = buildCommandName(name)
|
|
208
|
-
if (!config.command[commandName]) {
|
|
209
|
-
config.command[commandName] = command
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const agents = loadAgents(commands)
|
|
214
|
-
for (const [name, agent] of Object.entries(agents)) {
|
|
215
|
-
if (!config.agent[name]) {
|
|
216
|
-
config.agent[name] = agent
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
config.agent.build = {
|
|
221
|
-
...(config.agent.build || {}),
|
|
222
|
-
disable: true,
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
config.agent.plan = {
|
|
226
|
-
...(config.agent.plan || {}),
|
|
227
|
-
disable: true,
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (!config.default_agent && config.agent.default) {
|
|
231
|
-
config.default_agent = "default"
|
|
232
|
-
}
|
|
233
|
-
},
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export default OpencodeSkillzPlugin
|