@tyvm/knowhow 0.0.129 → 0.0.131
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/package.json +4 -4
- package/src/agents/base/base.ts +40 -5
- package/src/agents/configurable/ConfigAgent.ts +0 -1
- package/src/agents/tools/index.ts +1 -0
- package/src/agents/tools/list.ts +2 -0
- package/src/agents/tools/skills.ts +79 -0
- package/src/agents/tools/startAgentTask.ts +17 -13
- package/src/ai.ts +55 -24
- package/src/chat/CliChatService.ts +7 -1
- package/src/chat/modules/AgentModule.ts +156 -15
- package/src/chat/renderer/PlainRenderer.ts +218 -0
- package/src/chat/renderer/index.ts +1 -0
- package/src/chat/renderer/loadRenderer.ts +2 -0
- package/src/chat.ts +2 -2
- package/src/cli.ts +10 -0
- package/src/clients/anthropic.ts +68 -2
- package/src/clients/fireworks.ts +10 -1
- package/src/clients/index.ts +5 -3
- package/src/clients/meta.ts +9 -0
- package/src/clients/openai.ts +28 -5
- package/src/clients/pricing/anthropic.ts +11 -0
- package/src/clients/pricing/fireworks.ts +12 -0
- package/src/clients/pricing/openai.ts +3 -0
- package/src/clients/types.ts +27 -4
- package/src/clients/xai.ts +2 -2
- package/src/commands/agent.ts +70 -18
- package/src/commands/agents.ts +556 -0
- package/src/commands/behaviors.ts +47 -0
- package/src/commands/hash.ts +124 -0
- package/src/commands/misc.ts +157 -3
- package/src/commands/services.ts +6 -2
- package/src/commands/skills.ts +107 -0
- package/src/config.ts +51 -1
- package/src/generate.ts +507 -0
- package/src/index.ts +34 -216
- package/src/plugins/PluginBase.ts +0 -2
- package/src/plugins/SkillsPlugin.ts +11 -124
- package/src/plugins/language.ts +0 -1
- package/src/plugins/plugins.ts +24 -13
- package/src/plugins/types.ts +8 -2
- package/src/services/BehaviorsService.ts +464 -0
- package/src/services/KnowhowClient.ts +61 -0
- package/src/services/SkillsService.ts +820 -0
- package/src/services/Tools.ts +2 -0
- package/src/services/index.ts +8 -2
- package/src/services/modules/types.ts +2 -0
- package/src/types.ts +36 -4
- package/ts_build/package.json +4 -4
- package/ts_build/src/agents/base/base.d.ts +9 -1
- package/ts_build/src/agents/base/base.js +29 -4
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/configurable/ConfigAgent.js.map +1 -1
- package/ts_build/src/agents/tools/index.d.ts +1 -0
- package/ts_build/src/agents/tools/index.js +1 -0
- package/ts_build/src/agents/tools/index.js.map +1 -1
- package/ts_build/src/agents/tools/list.js +2 -0
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/agents/tools/skills.d.ts +33 -0
- package/ts_build/src/agents/tools/skills.js +62 -0
- package/ts_build/src/agents/tools/skills.js.map +1 -0
- package/ts_build/src/agents/tools/startAgentTask.d.ts +2 -2
- package/ts_build/src/agents/tools/startAgentTask.js +13 -12
- package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
- package/ts_build/src/ai.d.ts +10 -4
- package/ts_build/src/ai.js +27 -25
- package/ts_build/src/ai.js.map +1 -1
- package/ts_build/src/chat/CliChatService.d.ts +1 -1
- package/ts_build/src/chat/CliChatService.js +4 -1
- package/ts_build/src/chat/CliChatService.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.d.ts +2 -0
- package/ts_build/src/chat/modules/AgentModule.js +130 -4
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/CustomCommandsModule.d.ts +1 -1
- package/ts_build/src/chat/renderer/PlainRenderer.d.ts +28 -0
- package/ts_build/src/chat/renderer/PlainRenderer.js +169 -0
- package/ts_build/src/chat/renderer/PlainRenderer.js.map +1 -0
- package/ts_build/src/chat/renderer/index.d.ts +1 -0
- package/ts_build/src/chat/renderer/index.js +3 -1
- package/ts_build/src/chat/renderer/index.js.map +1 -1
- package/ts_build/src/chat/renderer/loadRenderer.js +2 -0
- package/ts_build/src/chat/renderer/loadRenderer.js.map +1 -1
- package/ts_build/src/chat.d.ts +1 -1
- package/ts_build/src/chat.js +2 -2
- package/ts_build/src/chat.js.map +1 -1
- package/ts_build/src/cli.js +9 -0
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/clients/anthropic.d.ts +2 -0
- package/ts_build/src/clients/anthropic.js +39 -1
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/fireworks.js +7 -0
- package/ts_build/src/clients/fireworks.js.map +1 -1
- package/ts_build/src/clients/index.js +3 -1
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/meta.d.ts +2 -0
- package/ts_build/src/clients/meta.js +5 -0
- package/ts_build/src/clients/meta.js.map +1 -1
- package/ts_build/src/clients/openai.d.ts +1 -0
- package/ts_build/src/clients/openai.js +25 -5
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/pricing/anthropic.d.ts +3 -0
- package/ts_build/src/clients/pricing/anthropic.js +6 -0
- package/ts_build/src/clients/pricing/anthropic.js.map +1 -1
- package/ts_build/src/clients/pricing/fireworks.d.ts +1 -0
- package/ts_build/src/clients/pricing/fireworks.js +5 -1
- package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
- package/ts_build/src/clients/pricing/openai.js +3 -0
- package/ts_build/src/clients/pricing/openai.js.map +1 -1
- package/ts_build/src/clients/types.d.ts +6 -1
- package/ts_build/src/clients/xai.js +1 -1
- package/ts_build/src/clients/xai.js.map +1 -1
- package/ts_build/src/commands/agent.js +58 -15
- package/ts_build/src/commands/agent.js.map +1 -1
- package/ts_build/src/commands/agents.d.ts +2 -0
- package/ts_build/src/commands/agents.js +460 -0
- package/ts_build/src/commands/agents.js.map +1 -0
- package/ts_build/src/commands/behaviors.d.ts +2 -0
- package/ts_build/src/commands/behaviors.js +43 -0
- package/ts_build/src/commands/behaviors.js.map +1 -0
- package/ts_build/src/commands/hash.d.ts +2 -0
- package/ts_build/src/commands/hash.js +117 -0
- package/ts_build/src/commands/hash.js.map +1 -0
- package/ts_build/src/commands/misc.js +108 -2
- package/ts_build/src/commands/misc.js.map +1 -1
- package/ts_build/src/commands/services.js +4 -2
- package/ts_build/src/commands/services.js.map +1 -1
- package/ts_build/src/commands/skills.d.ts +2 -0
- package/ts_build/src/commands/skills.js +85 -0
- package/ts_build/src/commands/skills.js.map +1 -0
- package/ts_build/src/config.js +34 -0
- package/ts_build/src/config.js.map +1 -1
- package/ts_build/src/generate.d.ts +16 -0
- package/ts_build/src/generate.js +304 -0
- package/ts_build/src/generate.js.map +1 -0
- package/ts_build/src/index.d.ts +3 -3
- package/ts_build/src/index.js +13 -118
- package/ts_build/src/index.js.map +1 -1
- package/ts_build/src/plugins/PluginBase.d.ts +0 -2
- package/ts_build/src/plugins/PluginBase.js.map +1 -1
- package/ts_build/src/plugins/SkillsPlugin.d.ts +1 -6
- package/ts_build/src/plugins/SkillsPlugin.js +6 -135
- package/ts_build/src/plugins/SkillsPlugin.js.map +1 -1
- package/ts_build/src/plugins/language.js.map +1 -1
- package/ts_build/src/plugins/plugins.d.ts +4 -1
- package/ts_build/src/plugins/plugins.js +17 -11
- package/ts_build/src/plugins/plugins.js.map +1 -1
- package/ts_build/src/plugins/types.d.ts +3 -2
- package/ts_build/src/services/BehaviorsService.d.ts +48 -0
- package/ts_build/src/services/BehaviorsService.js +382 -0
- package/ts_build/src/services/BehaviorsService.js.map +1 -0
- package/ts_build/src/services/KnowhowClient.d.ts +8 -0
- package/ts_build/src/services/KnowhowClient.js +20 -0
- package/ts_build/src/services/KnowhowClient.js.map +1 -1
- package/ts_build/src/services/SkillsService.d.ts +60 -0
- package/ts_build/src/services/SkillsService.js +641 -0
- package/ts_build/src/services/SkillsService.js.map +1 -0
- package/ts_build/src/services/Tools.d.ts +2 -0
- package/ts_build/src/services/Tools.js.map +1 -1
- package/ts_build/src/services/index.d.ts +3 -0
- package/ts_build/src/services/index.js +8 -2
- package/ts_build/src/services/index.js.map +1 -1
- package/ts_build/src/services/modules/index.d.ts +4 -2
- package/ts_build/src/services/modules/types.d.ts +2 -0
- package/ts_build/src/types.d.ts +14 -4
- package/ts_build/src/types.js.map +1 -1
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import * as https from "https";
|
|
6
|
+
import { KnowhowSimpleClient } from "./KnowhowClient";
|
|
7
|
+
|
|
8
|
+
// Default local install dir (relative to cwd)
|
|
9
|
+
const LOCAL_SKILLS_DIR = ".knowhow/skills";
|
|
10
|
+
// Lock file paths
|
|
11
|
+
const LOCK_FILE_PRIMARY = ".knowhow/skills/skills-lock.json";
|
|
12
|
+
const LOCK_FILE_LEGACY = "skills-lock.json";
|
|
13
|
+
// Global install dir (absolute)
|
|
14
|
+
const GLOBAL_SKILLS_DIR = path.join(os.homedir(), ".knowhow", "skills");
|
|
15
|
+
const GLOBAL_LOCK_FILE = path.join(os.homedir(), ".knowhow", "skills", "skills-lock.json");
|
|
16
|
+
|
|
17
|
+
const SKILLS_SH_API = "https://skills.sh";
|
|
18
|
+
|
|
19
|
+
export interface SkillLockEntry {
|
|
20
|
+
source: string;
|
|
21
|
+
sourceType: "github" | "git" | "well-known" | string;
|
|
22
|
+
skillPath?: string;
|
|
23
|
+
computedHash: string;
|
|
24
|
+
commitHash?: string;
|
|
25
|
+
ref?: string;
|
|
26
|
+
sourceUrl?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SkillsLock {
|
|
30
|
+
version: number;
|
|
31
|
+
skills: Record<string, SkillLockEntry>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SkillDownloadResponse {
|
|
35
|
+
files: Array<{ path: string; contents: string }>;
|
|
36
|
+
hash: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SkillsServiceOptions {
|
|
40
|
+
/** If true, use global ~/.knowhow/skills/ as install dir and lock file location */
|
|
41
|
+
global?: boolean;
|
|
42
|
+
/** Custom install directory (absolute or relative to cwd). Overrides global. */
|
|
43
|
+
installDir?: string;
|
|
44
|
+
/** Working directory (defaults to process.cwd()) */
|
|
45
|
+
cwd?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createEmptyLock(): SkillsLock {
|
|
49
|
+
return { version: 1, skills: {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute the same hash as `npx skills` `computeSnapshotHash` does:
|
|
54
|
+
* given an array of {path, contents} (as returned by skills.sh API),
|
|
55
|
+
* sort by path, then sha256(path + contents) for each file.
|
|
56
|
+
*/
|
|
57
|
+
export function computeSkillApiHash(
|
|
58
|
+
files: Array<{ path: string; contents: string }>
|
|
59
|
+
): string {
|
|
60
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
61
|
+
const hash = createHash("sha256");
|
|
62
|
+
for (const file of sorted) {
|
|
63
|
+
hash.update(file.path);
|
|
64
|
+
hash.update(Buffer.from(file.contents, "utf-8"));
|
|
65
|
+
}
|
|
66
|
+
return hash.digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compute a content hash for an already-installed skill folder.
|
|
71
|
+
* Sort files by relative path, then hash(relativePath + content) for each.
|
|
72
|
+
*/
|
|
73
|
+
export function computeSkillFolderHash(skillDir: string): string {
|
|
74
|
+
const files: Array<{ relativePath: string; content: Buffer }> = [];
|
|
75
|
+
collectFiles(skillDir, skillDir, files);
|
|
76
|
+
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
77
|
+
const hash = createHash("sha256");
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
hash.update(file.relativePath);
|
|
80
|
+
hash.update(file.content);
|
|
81
|
+
}
|
|
82
|
+
return hash.digest("hex");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function collectFiles(
|
|
86
|
+
baseDir: string,
|
|
87
|
+
currentDir: string,
|
|
88
|
+
results: Array<{ relativePath: string; content: Buffer }>
|
|
89
|
+
): void {
|
|
90
|
+
if (!fs.existsSync(currentDir)) return;
|
|
91
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
96
|
+
collectFiles(baseDir, fullPath, results);
|
|
97
|
+
} else if (entry.isFile()) {
|
|
98
|
+
const content = fs.readFileSync(fullPath);
|
|
99
|
+
const relativePath = path
|
|
100
|
+
.relative(baseDir, fullPath)
|
|
101
|
+
.split("\\")
|
|
102
|
+
.join("/");
|
|
103
|
+
results.push({ relativePath, content });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fetchJson<T>(url: string): Promise<T> {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
https
|
|
111
|
+
.get(url, { headers: { "User-Agent": "knowhow-cli" } }, (res) => {
|
|
112
|
+
const chunks: Buffer[] = [];
|
|
113
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
114
|
+
res.on("end", () => {
|
|
115
|
+
try {
|
|
116
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
117
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
118
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body}`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
resolve(JSON.parse(body) as T);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
reject(e);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
})
|
|
127
|
+
.on("error", reject);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fetchRaw(url: string): Promise<string> {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
https
|
|
134
|
+
.get(url, { headers: { "User-Agent": "knowhow-cli" } }, (res) => {
|
|
135
|
+
const chunks: Buffer[] = [];
|
|
136
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
137
|
+
res.on("end", () => {
|
|
138
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
139
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
140
|
+
reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
resolve(body);
|
|
144
|
+
});
|
|
145
|
+
})
|
|
146
|
+
.on("error", reject);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fetch the latest commit SHA for a file path in a GitHub repo.
|
|
152
|
+
*/
|
|
153
|
+
async function fetchLatestCommitHash(
|
|
154
|
+
owner: string,
|
|
155
|
+
repo: string,
|
|
156
|
+
filePath: string
|
|
157
|
+
): Promise<string | undefined> {
|
|
158
|
+
try {
|
|
159
|
+
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?path=${encodeURIComponent(filePath)}&per_page=1`;
|
|
160
|
+
const commits = await fetchJson<Array<{ sha: string }>>(url);
|
|
161
|
+
if (Array.isArray(commits) && commits.length > 0) {
|
|
162
|
+
return commits[0].sha;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Non-fatal
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse a skill reference. Supports:
|
|
172
|
+
* - "owner/repo@skill-name"
|
|
173
|
+
* - "owner/repo"
|
|
174
|
+
* - "https://github.com/owner/repo"
|
|
175
|
+
* - "https://github.com/owner/repo@skill-name"
|
|
176
|
+
* - GitHub URL with explicit skillName override
|
|
177
|
+
*/
|
|
178
|
+
export function parseSkillRef(
|
|
179
|
+
input: string,
|
|
180
|
+
skillNameOverride?: string
|
|
181
|
+
): {
|
|
182
|
+
owner: string;
|
|
183
|
+
repo: string;
|
|
184
|
+
skill?: string;
|
|
185
|
+
sourceUrl?: string;
|
|
186
|
+
} {
|
|
187
|
+
// Strip trailing .git
|
|
188
|
+
let normalized = input.replace(/\.git$/, "");
|
|
189
|
+
|
|
190
|
+
// Extract @skill suffix before URL parsing
|
|
191
|
+
let skillFromAt: string | undefined;
|
|
192
|
+
const atIdx = normalized.lastIndexOf("@");
|
|
193
|
+
// Only treat @ as skill separator if it's not part of the protocol (https://)
|
|
194
|
+
if (atIdx > 0 && !normalized.slice(0, atIdx).match(/\/\/$/)) {
|
|
195
|
+
const beforeAt = normalized.slice(0, atIdx);
|
|
196
|
+
const afterAt = normalized.slice(atIdx + 1);
|
|
197
|
+
// Make sure the beforeAt part contains a slash (it's a path, not a protocol)
|
|
198
|
+
if (beforeAt.includes("/")) {
|
|
199
|
+
skillFromAt = afterAt;
|
|
200
|
+
normalized = beforeAt;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let owner: string;
|
|
205
|
+
let repo: string;
|
|
206
|
+
let sourceUrl: string | undefined;
|
|
207
|
+
|
|
208
|
+
// GitHub URL format
|
|
209
|
+
const githubMatch = normalized.match(
|
|
210
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/
|
|
211
|
+
);
|
|
212
|
+
if (githubMatch) {
|
|
213
|
+
owner = githubMatch[1];
|
|
214
|
+
repo = githubMatch[2];
|
|
215
|
+
sourceUrl = `https://github.com/${owner}/${repo}`;
|
|
216
|
+
} else {
|
|
217
|
+
// owner/repo format
|
|
218
|
+
const parts = normalized.split("/");
|
|
219
|
+
if (parts.length < 2) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Invalid skill reference "${input}". Expected formats:\n` +
|
|
222
|
+
` owner/repo@skill-name\n` +
|
|
223
|
+
` https://github.com/owner/repo --skill <name>`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
owner = parts[0];
|
|
227
|
+
repo = parts[1];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Precedence: explicit --skill flag > @skill in string
|
|
231
|
+
const skill = skillNameOverride ?? skillFromAt;
|
|
232
|
+
|
|
233
|
+
return { owner, repo, skill, sourceUrl };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export class SkillsService {
|
|
237
|
+
private cwd: string;
|
|
238
|
+
private isGlobal: boolean;
|
|
239
|
+
private customInstallDir?: string;
|
|
240
|
+
public client = new KnowhowSimpleClient();
|
|
241
|
+
|
|
242
|
+
constructor(opts: SkillsServiceOptions = {}) {
|
|
243
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
244
|
+
this.isGlobal = opts.global ?? false;
|
|
245
|
+
this.customInstallDir = opts.installDir;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Absolute path to the skills install directory */
|
|
249
|
+
getInstallDir(): string {
|
|
250
|
+
if (this.customInstallDir) {
|
|
251
|
+
return path.isAbsolute(this.customInstallDir)
|
|
252
|
+
? this.customInstallDir
|
|
253
|
+
: path.join(this.cwd, this.customInstallDir);
|
|
254
|
+
}
|
|
255
|
+
if (this.isGlobal) {
|
|
256
|
+
return GLOBAL_SKILLS_DIR;
|
|
257
|
+
}
|
|
258
|
+
return path.join(this.cwd, LOCAL_SKILLS_DIR);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Absolute path to the lock file */
|
|
262
|
+
getLockPath(): string {
|
|
263
|
+
if (this.isGlobal) {
|
|
264
|
+
return GLOBAL_LOCK_FILE;
|
|
265
|
+
}
|
|
266
|
+
if (this.customInstallDir) {
|
|
267
|
+
return path.join(this.getInstallDir(), "skills-lock.json");
|
|
268
|
+
}
|
|
269
|
+
// Local: prefer primary, fall back to legacy
|
|
270
|
+
const primary = path.join(this.cwd, LOCK_FILE_PRIMARY);
|
|
271
|
+
const legacy = path.join(this.cwd, LOCK_FILE_LEGACY);
|
|
272
|
+
if (!fs.existsSync(primary) && fs.existsSync(legacy)) {
|
|
273
|
+
return legacy;
|
|
274
|
+
}
|
|
275
|
+
return primary;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Absolute path to where we always write the lock file */
|
|
279
|
+
getLockWritePath(): string {
|
|
280
|
+
if (this.isGlobal) return GLOBAL_LOCK_FILE;
|
|
281
|
+
if (this.customInstallDir) {
|
|
282
|
+
return path.join(this.getInstallDir(), "skills-lock.json");
|
|
283
|
+
}
|
|
284
|
+
return path.join(this.cwd, LOCK_FILE_PRIMARY);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
readLock(): SkillsLock {
|
|
288
|
+
const lockPath = this.getLockPath();
|
|
289
|
+
try {
|
|
290
|
+
if (fs.existsSync(lockPath)) {
|
|
291
|
+
const content = fs.readFileSync(lockPath, "utf-8");
|
|
292
|
+
const parsed = JSON.parse(content) as SkillsLock;
|
|
293
|
+
if (typeof parsed.version !== "number" || !parsed.skills) {
|
|
294
|
+
return createEmptyLock();
|
|
295
|
+
}
|
|
296
|
+
return parsed;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// fall through
|
|
300
|
+
}
|
|
301
|
+
return createEmptyLock();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
writeLock(lock: SkillsLock): void {
|
|
305
|
+
const lockPath = this.getLockWritePath();
|
|
306
|
+
const dir = path.dirname(lockPath);
|
|
307
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
308
|
+
|
|
309
|
+
// Sort skills keys for deterministic output
|
|
310
|
+
const sortedSkills: Record<string, SkillLockEntry> = {};
|
|
311
|
+
for (const key of Object.keys(lock.skills).sort()) {
|
|
312
|
+
sortedSkills[key] = lock.skills[key];
|
|
313
|
+
}
|
|
314
|
+
const sorted: SkillsLock = { version: lock.version, skills: sortedSkills };
|
|
315
|
+
fs.writeFileSync(lockPath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Fetch skill from skills.sh CDN API.
|
|
320
|
+
*/
|
|
321
|
+
async fetchSkillFromApi(
|
|
322
|
+
owner: string,
|
|
323
|
+
repo: string,
|
|
324
|
+
skill: string
|
|
325
|
+
): Promise<SkillDownloadResponse> {
|
|
326
|
+
const url = `${SKILLS_SH_API}/api/download/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(skill)}`;
|
|
327
|
+
return fetchJson<SkillDownloadResponse>(url);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Install a skill.
|
|
332
|
+
*
|
|
333
|
+
* @param skillRef - "owner/repo@skill", "owner/repo", or "https://github.com/owner/repo"
|
|
334
|
+
* @param skillNameOverride - explicit skill name (from --skill flag), overrides @skill in ref
|
|
335
|
+
*/
|
|
336
|
+
async add(skillRef: string, skillNameOverride?: string): Promise<void> {
|
|
337
|
+
const { owner, repo, skill, sourceUrl } = parseSkillRef(skillRef, skillNameOverride);
|
|
338
|
+
const source = `${owner}/${repo}`;
|
|
339
|
+
|
|
340
|
+
if (!skill) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`Please specify a skill name:\n` +
|
|
343
|
+
` knowhow skills add ${source}@<skill-name>\n` +
|
|
344
|
+
` knowhow skills add https://github.com/${source} --skill <skill-name>`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const installDir = this.getInstallDir();
|
|
349
|
+
const scope = this.isGlobal ? "globally" : "locally";
|
|
350
|
+
console.log(`📦 Fetching skill "${skill}" from ${source} (installing ${scope})...`);
|
|
351
|
+
|
|
352
|
+
let download: SkillDownloadResponse;
|
|
353
|
+
try {
|
|
354
|
+
download = await this.fetchSkillFromApi(owner, repo, skill);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
357
|
+
throw new Error(
|
|
358
|
+
`Failed to fetch skill "${skill}" from skills.sh API: ${msg}\n` +
|
|
359
|
+
`Make sure the skill exists at: https://skills.sh/${source}/${skill}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!download.files || download.files.length === 0) {
|
|
364
|
+
throw new Error(`No files found for skill "${skill}"`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Write files to install dir
|
|
368
|
+
const skillInstallDir = path.join(installDir, skill);
|
|
369
|
+
fs.mkdirSync(skillInstallDir, { recursive: true });
|
|
370
|
+
|
|
371
|
+
for (const file of download.files) {
|
|
372
|
+
const filePath = path.join(skillInstallDir, file.path);
|
|
373
|
+
const fileDir = path.dirname(filePath);
|
|
374
|
+
fs.mkdirSync(fileDir, { recursive: true });
|
|
375
|
+
fs.writeFileSync(filePath, file.contents, "utf-8");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Compute content hash from downloaded files
|
|
379
|
+
const computedHash = computeSkillApiHash(download.files);
|
|
380
|
+
|
|
381
|
+
if (download.hash && download.hash !== computedHash) {
|
|
382
|
+
console.warn(
|
|
383
|
+
`⚠ Note: API cached hash (${download.hash}) differs from computed hash (${computedHash}). Storing computed hash.`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const skillPath = `skills/${skill}/SKILL.md`;
|
|
388
|
+
const commitHash = await fetchLatestCommitHash(owner, repo, skillPath);
|
|
389
|
+
if (commitHash) {
|
|
390
|
+
console.log(`📎 Pinned to commit ${commitHash.slice(0, 8)}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Update lock
|
|
394
|
+
const lock = this.readLock();
|
|
395
|
+
lock.skills[skill] = {
|
|
396
|
+
source,
|
|
397
|
+
sourceType: "github",
|
|
398
|
+
skillPath,
|
|
399
|
+
computedHash,
|
|
400
|
+
...(commitHash ? { commitHash } : {}),
|
|
401
|
+
...(sourceUrl ? { sourceUrl } : {}),
|
|
402
|
+
};
|
|
403
|
+
this.writeLock(lock);
|
|
404
|
+
|
|
405
|
+
console.log(`✅ Installed skill "${skill}" to ${skillInstallDir}`);
|
|
406
|
+
console.log(`🔒 Updated lock file at ${this.getLockWritePath()}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Remove a skill by name.
|
|
411
|
+
*/
|
|
412
|
+
remove(skillName: string): void {
|
|
413
|
+
const lock = this.readLock();
|
|
414
|
+
|
|
415
|
+
if (!lock.skills[skillName]) {
|
|
416
|
+
console.warn(`⚠ Skill "${skillName}" not found in lock file.`);
|
|
417
|
+
} else {
|
|
418
|
+
delete lock.skills[skillName];
|
|
419
|
+
this.writeLock(lock);
|
|
420
|
+
console.log(`🗑 Removed "${skillName}" from lock file.`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const installDir = path.join(this.getInstallDir(), skillName);
|
|
424
|
+
if (fs.existsSync(installDir)) {
|
|
425
|
+
fs.rmSync(installDir, { recursive: true, force: true });
|
|
426
|
+
console.log(`🗑 Deleted ${installDir}`);
|
|
427
|
+
} else {
|
|
428
|
+
console.warn(`⚠ Skill directory not found: ${installDir}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* List installed skills from lock file.
|
|
434
|
+
*/
|
|
435
|
+
list(): void {
|
|
436
|
+
const lock = this.readLock();
|
|
437
|
+
const skillEntries = Object.entries(lock.skills);
|
|
438
|
+
|
|
439
|
+
if (skillEntries.length === 0) {
|
|
440
|
+
console.log(
|
|
441
|
+
"No skills installed. Run `knowhow skills add <owner/repo@skill>` to install one."
|
|
442
|
+
);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const installDir = this.getInstallDir();
|
|
447
|
+
console.log(`Installed skills (${skillEntries.length}):\n`);
|
|
448
|
+
for (const [name, entry] of skillEntries) {
|
|
449
|
+
const skillDir = path.join(installDir, name);
|
|
450
|
+
const installed = fs.existsSync(skillDir);
|
|
451
|
+
const statusIcon = installed ? "✅" : "❌";
|
|
452
|
+
console.log(` ${statusIcon} ${name}`);
|
|
453
|
+
console.log(` source: ${entry.source}`);
|
|
454
|
+
if (entry.skillPath) console.log(` path: ${entry.skillPath}`);
|
|
455
|
+
console.log(` hash: ${entry.computedHash}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Install all skills from the lock file (restore).
|
|
461
|
+
*/
|
|
462
|
+
async install(): Promise<void> {
|
|
463
|
+
const lock = this.readLock();
|
|
464
|
+
const skillEntries = Object.entries(lock.skills);
|
|
465
|
+
|
|
466
|
+
if (skillEntries.length === 0) {
|
|
467
|
+
console.log("No skills in lock file. Nothing to install.");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const installDir = this.getInstallDir();
|
|
472
|
+
console.log(`📦 Installing ${skillEntries.length} skill(s) from lock file...\n`);
|
|
473
|
+
let installed = 0;
|
|
474
|
+
let skipped = 0;
|
|
475
|
+
let failed = 0;
|
|
476
|
+
|
|
477
|
+
for (const [name, entry] of skillEntries) {
|
|
478
|
+
const skillDir = path.join(installDir, name);
|
|
479
|
+
|
|
480
|
+
// Check if already installed with correct hash
|
|
481
|
+
if (fs.existsSync(skillDir)) {
|
|
482
|
+
const currentHash = computeSkillFolderHash(skillDir);
|
|
483
|
+
if (currentHash === entry.computedHash) {
|
|
484
|
+
console.log(` ⏭ ${name} (already up to date)`);
|
|
485
|
+
skipped++;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const parts = entry.source.split("/");
|
|
491
|
+
if (parts.length < 2 || entry.sourceType !== "github") {
|
|
492
|
+
console.warn(
|
|
493
|
+
` ⚠ ${name}: unsupported sourceType "${entry.sourceType}", skipping`
|
|
494
|
+
);
|
|
495
|
+
failed++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const [owner, repo] = parts;
|
|
500
|
+
let skillSlug = name;
|
|
501
|
+
if (entry.skillPath) {
|
|
502
|
+
const pathParts = entry.skillPath.split("/");
|
|
503
|
+
if (pathParts.length >= 2) {
|
|
504
|
+
skillSlug = pathParts[pathParts.length - 2];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
console.log(` 📥 Installing ${name}...`);
|
|
510
|
+
const download = await this.fetchSkillFromApi(owner, repo, skillSlug);
|
|
511
|
+
const files = download.files;
|
|
512
|
+
|
|
513
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
514
|
+
for (const file of files) {
|
|
515
|
+
const filePath = path.join(skillDir, file.path);
|
|
516
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
517
|
+
fs.writeFileSync(filePath, file.contents, "utf-8");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const actualHash = computeSkillApiHash(files);
|
|
521
|
+
if (actualHash !== entry.computedHash) {
|
|
522
|
+
console.warn(
|
|
523
|
+
` ⚠ Hash mismatch for ${name}: locked ${entry.computedHash.slice(0, 16)}..., got ${actualHash.slice(0, 16)}...`
|
|
524
|
+
);
|
|
525
|
+
} else {
|
|
526
|
+
console.log(` ✓ Hash verified`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
console.log(` ✅ ${name}`);
|
|
530
|
+
installed++;
|
|
531
|
+
} catch (e) {
|
|
532
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
533
|
+
console.error(` ❌ ${name}: ${msg}`);
|
|
534
|
+
failed++;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
console.log(
|
|
539
|
+
`\nDone: ${installed} installed, ${skipped} skipped, ${failed} failed.`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Update installed skills to their latest version.
|
|
545
|
+
*/
|
|
546
|
+
async update(skillNames?: string[]): Promise<void> {
|
|
547
|
+
const lock = this.readLock();
|
|
548
|
+
const toUpdate =
|
|
549
|
+
skillNames && skillNames.length > 0
|
|
550
|
+
? skillNames
|
|
551
|
+
: Object.keys(lock.skills);
|
|
552
|
+
|
|
553
|
+
if (toUpdate.length === 0) {
|
|
554
|
+
console.log("No skills to update.");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const installDir = this.getInstallDir();
|
|
559
|
+
console.log(`🔄 Updating ${toUpdate.length} skill(s)...\n`);
|
|
560
|
+
let updated = 0;
|
|
561
|
+
let unchanged = 0;
|
|
562
|
+
let failed = 0;
|
|
563
|
+
|
|
564
|
+
for (const name of toUpdate) {
|
|
565
|
+
const entry = lock.skills[name];
|
|
566
|
+
if (!entry) {
|
|
567
|
+
console.warn(` ⚠ "${name}" not found in lock file, skipping.`);
|
|
568
|
+
failed++;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const parts = entry.source.split("/");
|
|
573
|
+
if (parts.length < 2 || entry.sourceType !== "github") {
|
|
574
|
+
console.warn(` ⚠ ${name}: unsupported sourceType, skipping`);
|
|
575
|
+
failed++;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const [owner, repo] = parts;
|
|
580
|
+
let skillSlug = name;
|
|
581
|
+
if (entry.skillPath) {
|
|
582
|
+
const pathParts = entry.skillPath.split("/");
|
|
583
|
+
if (pathParts.length >= 2) {
|
|
584
|
+
skillSlug = pathParts[pathParts.length - 2];
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const download = await this.fetchSkillFromApi(owner, repo, skillSlug);
|
|
590
|
+
const newHash = computeSkillApiHash(download.files);
|
|
591
|
+
|
|
592
|
+
if (newHash === entry.computedHash) {
|
|
593
|
+
console.log(` ⏭ ${name} (already up to date)`);
|
|
594
|
+
unchanged++;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const skillDir = path.join(installDir, name);
|
|
599
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
600
|
+
for (const file of download.files) {
|
|
601
|
+
const filePath = path.join(skillDir, file.path);
|
|
602
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
603
|
+
fs.writeFileSync(filePath, file.contents, "utf-8");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
lock.skills[name] = { ...entry, computedHash: newHash };
|
|
607
|
+
console.log(` ✅ ${name} (updated)`);
|
|
608
|
+
updated++;
|
|
609
|
+
} catch (e) {
|
|
610
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
611
|
+
console.error(` ❌ ${name}: ${msg}`);
|
|
612
|
+
failed++;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (updated > 0) {
|
|
617
|
+
this.writeLock(lock);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
console.log(
|
|
621
|
+
`\nDone: ${updated} updated, ${unchanged} unchanged, ${failed} failed.`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Upload locally installed skills (from .knowhow/skills/<name>/SKILL.md) to the backend as behaviors.
|
|
627
|
+
* Each skill is upserted: created if it has no id in frontmatter, updated if it does.
|
|
628
|
+
*/
|
|
629
|
+
async upload(): Promise<void> {
|
|
630
|
+
const installDir = this.getInstallDir();
|
|
631
|
+
|
|
632
|
+
if (!fs.existsSync(installDir)) {
|
|
633
|
+
console.error(
|
|
634
|
+
`No skills directory found at ${installDir}. Run 'knowhow skills add <ref>' first.`
|
|
635
|
+
);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const entries = fs.readdirSync(installDir, { withFileTypes: true });
|
|
640
|
+
const skillDirs = entries.filter((e) => e.isDirectory());
|
|
641
|
+
|
|
642
|
+
if (skillDirs.length === 0) {
|
|
643
|
+
console.log("No skill directories found. Nothing to upload.");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let uploaded = 0;
|
|
648
|
+
let failed = 0;
|
|
649
|
+
|
|
650
|
+
for (const dir of skillDirs) {
|
|
651
|
+
const skillFile = path.join(installDir, dir.name, "SKILL.md");
|
|
652
|
+
if (!fs.existsSync(skillFile)) {
|
|
653
|
+
console.warn(` ⚠ ${dir.name}: no SKILL.md found, skipping.`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
const raw = fs.readFileSync(skillFile, "utf-8");
|
|
659
|
+
const behavior = this.parseSkillMd(raw, dir.name);
|
|
660
|
+
|
|
661
|
+
// Always mark as a skill
|
|
662
|
+
const payload: Record<string, unknown> = {
|
|
663
|
+
...behavior,
|
|
664
|
+
isSkill: true,
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
if (behavior.id) {
|
|
668
|
+
await this.client.updateOrgBehavior(behavior.id as string, payload);
|
|
669
|
+
console.log(` 📝 Updated: ${behavior.name} (${behavior.id as string})`);
|
|
670
|
+
} else {
|
|
671
|
+
const result = await this.client.createOrgBehavior(payload);
|
|
672
|
+
const created = result.data as Record<string, unknown>;
|
|
673
|
+
if (created.id) {
|
|
674
|
+
// Write the assigned id back into the SKILL.md frontmatter so future uploads update instead of create
|
|
675
|
+
const withId = this.injectIdIntoSkillMd(raw, created.id as string);
|
|
676
|
+
fs.writeFileSync(skillFile, withId, "utf-8");
|
|
677
|
+
console.log(` ✨ Created: ${behavior.name} (id: ${created.id})`);
|
|
678
|
+
} else {
|
|
679
|
+
console.log(` ✨ Created: ${behavior.name}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
uploaded++;
|
|
683
|
+
} catch (e) {
|
|
684
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
685
|
+
console.error(` ❌ ${dir.name}: ${msg}`);
|
|
686
|
+
failed++;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
console.log(`\nUploaded ${uploaded} skill(s). Failed: ${failed}.`);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Download skill behaviors from the backend and write them as SKILL.md files
|
|
695
|
+
* into the skills install directory (.knowhow/skills/<name>/SKILL.md).
|
|
696
|
+
*/
|
|
697
|
+
async download(): Promise<void> {
|
|
698
|
+
const installDir = this.getInstallDir();
|
|
699
|
+
const result = await this.client.getOrgSkills(false); // false = exclude platform/internal skills
|
|
700
|
+
const skills = result.data as Record<string, unknown>[];
|
|
701
|
+
|
|
702
|
+
if (skills.length === 0) {
|
|
703
|
+
console.log("No skills found on backend.");
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
let created = 0;
|
|
708
|
+
let updated = 0;
|
|
709
|
+
let unchanged = 0;
|
|
710
|
+
|
|
711
|
+
for (const skill of skills) {
|
|
712
|
+
const name = ((skill.name as string) || "unnamed")
|
|
713
|
+
.toLowerCase()
|
|
714
|
+
.replace(/[^a-z0-9-]+/g, "-");
|
|
715
|
+
const skillDir = path.join(installDir, name);
|
|
716
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
717
|
+
|
|
718
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
719
|
+
const content = this.serializeSkillMd(skill);
|
|
720
|
+
|
|
721
|
+
const hash = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
722
|
+
if (!fs.existsSync(skillFile)) {
|
|
723
|
+
fs.writeFileSync(skillFile, content, "utf-8");
|
|
724
|
+
console.log(` ✨ Created: ${skillFile}`);
|
|
725
|
+
created++;
|
|
726
|
+
} else {
|
|
727
|
+
const existing = fs.readFileSync(skillFile, "utf-8");
|
|
728
|
+
if (hash(existing) !== hash(content)) {
|
|
729
|
+
fs.writeFileSync(skillFile, content, "utf-8");
|
|
730
|
+
console.log(` 📝 Updated: ${skillFile}`);
|
|
731
|
+
updated++;
|
|
732
|
+
} else {
|
|
733
|
+
unchanged++;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
console.log(
|
|
739
|
+
`\nSync complete: ${created} created, ${updated} updated, ${unchanged} unchanged → ${installDir}/`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Parse a SKILL.md file into a behavior object.
|
|
745
|
+
* Falls back to using the directory name if name is missing from frontmatter.
|
|
746
|
+
*/
|
|
747
|
+
private parseSkillMd(content: string, fallbackName: string): Record<string, unknown> {
|
|
748
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
749
|
+
if (!match) {
|
|
750
|
+
return { name: fallbackName, description: "", instructions: content.trim(), isSkill: true };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const meta: Record<string, string> = {};
|
|
754
|
+
for (const line of match[1].split("\n")) {
|
|
755
|
+
const colonIdx = line.indexOf(":");
|
|
756
|
+
if (colonIdx > 0) {
|
|
757
|
+
const key = line.slice(0, colonIdx).trim();
|
|
758
|
+
const val = line.slice(colonIdx + 1).trim();
|
|
759
|
+
meta[key] = val;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
id: meta.id || undefined,
|
|
765
|
+
name: meta.name || fallbackName,
|
|
766
|
+
description: meta.description || "",
|
|
767
|
+
instructions: match[2].trim(),
|
|
768
|
+
textTrigger: meta.textTrigger || undefined,
|
|
769
|
+
semanticTriggerText: meta.semanticTriggerText || undefined,
|
|
770
|
+
model: meta.model || undefined,
|
|
771
|
+
mcpServers: meta.mcpServers || undefined,
|
|
772
|
+
tools: meta.tools || undefined,
|
|
773
|
+
embeddings: meta.embeddings || undefined,
|
|
774
|
+
isSkill: true,
|
|
775
|
+
isPublic: meta.isPublic === "true",
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Serialize a backend behavior record as a SKILL.md string.
|
|
781
|
+
*/
|
|
782
|
+
private serializeSkillMd(skill: Record<string, unknown>): string {
|
|
783
|
+
const lines: string[] = ["---"];
|
|
784
|
+
lines.push(`name: ${skill.name || ""}`);
|
|
785
|
+
lines.push(`description: ${skill.description || ""}`);
|
|
786
|
+
if (skill.model) lines.push(`model: ${skill.model}`);
|
|
787
|
+
if (skill.textTrigger) lines.push(`textTrigger: ${skill.textTrigger}`);
|
|
788
|
+
if (skill.semanticTriggerText) lines.push(`semanticTriggerText: ${skill.semanticTriggerText}`);
|
|
789
|
+
if (skill.mcpServers) lines.push(`mcpServers: ${skill.mcpServers}`);
|
|
790
|
+
if (skill.tools) lines.push(`tools: ${skill.tools}`);
|
|
791
|
+
if (skill.embeddings) lines.push(`embeddings: ${skill.embeddings}`);
|
|
792
|
+
if (skill.isPublic !== undefined) lines.push(`isPublic: ${skill.isPublic}`);
|
|
793
|
+
if (skill.id) lines.push(`id: ${skill.id}`);
|
|
794
|
+
lines.push("---");
|
|
795
|
+
lines.push("");
|
|
796
|
+
lines.push(((skill.instructions as string) || "").trim());
|
|
797
|
+
lines.push("");
|
|
798
|
+
return lines.join("\n");
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Inject or replace the `id:` field in a SKILL.md frontmatter.
|
|
803
|
+
*/
|
|
804
|
+
private injectIdIntoSkillMd(content: string, id: string): string {
|
|
805
|
+
const match = content.match(/^(---\n[\s\S]*?\n---\n?)([\s\S]*)$/);
|
|
806
|
+
if (!match) return content;
|
|
807
|
+
|
|
808
|
+
let frontmatter = match[1];
|
|
809
|
+
const body = match[2];
|
|
810
|
+
|
|
811
|
+
if (/^id:/m.test(frontmatter)) {
|
|
812
|
+
frontmatter = frontmatter.replace(/^id:.*$/m, `id: ${id}`);
|
|
813
|
+
} else {
|
|
814
|
+
// Insert before closing ---
|
|
815
|
+
frontmatter = frontmatter.replace(/\n---\n?$/, `\nid: ${id}\n---\n`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return frontmatter + body;
|
|
819
|
+
}
|
|
820
|
+
}
|