@nseng-ai/areg 0.1.1
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 +29 -0
- package/src/cli.ts +109 -0
- package/src/context.ts +59 -0
- package/src/fake-gateways.ts +1009 -0
- package/src/gateways/command-constants.ts +1 -0
- package/src/gateways/errors.ts +5 -0
- package/src/gateways/fs-utils.ts +26 -0
- package/src/gateways/github-gateway.ts +111 -0
- package/src/gateways/host-gateway.ts +35 -0
- package/src/gateways/mutation-policy.ts +94 -0
- package/src/gateways/npx-skills-gateway.ts +53 -0
- package/src/gateways/project-fs.ts +320 -0
- package/src/gateways/project-gateway.ts +801 -0
- package/src/gateways/prompt-gateway.ts +21 -0
- package/src/gateways/skill-kind-classification.ts +39 -0
- package/src/gateways/skillx-workspace-gateway.ts +220 -0
- package/src/gateways.ts +361 -0
- package/src/index.ts +48 -0
- package/src/operations/check.ts +559 -0
- package/src/operations/doctor-skills-report.ts +109 -0
- package/src/operations/doctor-skills-severity.ts +9 -0
- package/src/operations/doctor-skills.ts +471 -0
- package/src/operations/file-state.ts +77 -0
- package/src/operations/frontmatter.ts +151 -0
- package/src/operations/init.ts +548 -0
- package/src/operations/lockfile.ts +107 -0
- package/src/operations/managed-markdown-block.ts +47 -0
- package/src/operations/pi-replacement.ts +33 -0
- package/src/operations/pi-settings.ts +60 -0
- package/src/operations/project-agents.ts +115 -0
- package/src/operations/project-inspection.ts +165 -0
- package/src/operations/project-mutations.ts +383 -0
- package/src/operations/project-resolution.ts +53 -0
- package/src/operations/skill-find.ts +295 -0
- package/src/operations/skill-kind-apply-plan.ts +531 -0
- package/src/operations/skill-kind-frontmatter.ts +55 -0
- package/src/operations/skill-kind-inference.ts +391 -0
- package/src/operations/skill-kind.ts +525 -0
- package/src/operations/skill-mirror-conventions.ts +126 -0
- package/src/operations/skillx.ts +305 -0
- package/src/operations/toml-section.ts +53 -0
- package/src/operations/update-skills.ts +325 -0
- package/src/real-gateways.ts +6 -0
- package/src/skill-lookup.ts +254 -0
- package/src/sort.ts +7 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { failure, negative, ok, type ClinkrExit, ClinkrGroup } from "@nseng-ai/clinkr";
|
|
2
|
+
import { optionalEntry } from "@nseng-ai/foundation/primitives";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import type { AregCliContext } from "../context.ts";
|
|
6
|
+
import type { AregSkillxInstalledSkill } from "../gateways.ts";
|
|
7
|
+
import { sortStrings } from "../sort.ts";
|
|
8
|
+
|
|
9
|
+
const SKILLX_FORMAT_VALUES = ["url", "skill-flag", "plain", "repo-only"] as const;
|
|
10
|
+
const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
11
|
+
|
|
12
|
+
export const skillxParseRequestSchema = z.object({
|
|
13
|
+
inputText: z.string().describe("GitHub URL, owner/repo, or owner/repo plus skill selector."),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const skillxListRequestSchema = z.object({
|
|
17
|
+
repo: z.string().describe("GitHub repository in owner/repo form."),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const skillxFetchRequestSchema = z.object({
|
|
21
|
+
repo: z.string().describe("GitHub repository in owner/repo form."),
|
|
22
|
+
skill: z.string().optional().describe("Specific skill name to select after installation."),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const skillxCleanupRequestSchema = z.object({
|
|
26
|
+
dir: z.string().describe("Transient skillx workspace directory to remove."),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const skillxFormatSchema = z.enum(SKILLX_FORMAT_VALUES);
|
|
30
|
+
|
|
31
|
+
const parseSuccessSchema = z.object({
|
|
32
|
+
success: z.literal(true),
|
|
33
|
+
repo: z.string(),
|
|
34
|
+
skill: z.string().nullable(),
|
|
35
|
+
format: skillxFormatSchema,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const parseFailureSchema = z.object({
|
|
39
|
+
success: z.literal(false),
|
|
40
|
+
error: z.string(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const skillxParseResultSchema = z.union([parseSuccessSchema, parseFailureSchema]);
|
|
44
|
+
|
|
45
|
+
const listSuccessSchema = z.object({
|
|
46
|
+
success: z.literal(true),
|
|
47
|
+
repo: z.string(),
|
|
48
|
+
skills: z.array(z.string()),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const listFailureSchema = z.object({
|
|
52
|
+
success: z.literal(false),
|
|
53
|
+
error: z.string(),
|
|
54
|
+
hint: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const skillxListResultSchema = z.union([listSuccessSchema, listFailureSchema]);
|
|
58
|
+
|
|
59
|
+
const fetchSelectedSuccessSchema = z.object({
|
|
60
|
+
success: z.literal(true),
|
|
61
|
+
repo: z.string(),
|
|
62
|
+
skill: z.string(),
|
|
63
|
+
tmpDir: z.string(),
|
|
64
|
+
skillDir: z.string(),
|
|
65
|
+
skillMd: z.string(),
|
|
66
|
+
files: z.array(z.string()),
|
|
67
|
+
needsSelection: z.literal(false).optional(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const fetchSelectionSuccessSchema = z.object({
|
|
71
|
+
success: z.literal(true),
|
|
72
|
+
repo: z.string(),
|
|
73
|
+
skill: z.null(),
|
|
74
|
+
tmpDir: z.string(),
|
|
75
|
+
skillDir: z.null(),
|
|
76
|
+
skillMd: z.null(),
|
|
77
|
+
files: z.null(),
|
|
78
|
+
needsSelection: z.literal(true),
|
|
79
|
+
availableSkills: z.array(z.string()),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const fetchFailureSchema = z.object({
|
|
83
|
+
success: z.literal(false),
|
|
84
|
+
error: z.string(),
|
|
85
|
+
tmpDir: z.null(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const skillxFetchResultSchema = z.union([
|
|
89
|
+
fetchSelectedSuccessSchema,
|
|
90
|
+
fetchSelectionSuccessSchema,
|
|
91
|
+
fetchFailureSchema,
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const cleanupSuccessSchema = z.object({
|
|
95
|
+
success: z.literal(true),
|
|
96
|
+
removed: z.string(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const cleanupFailureSchema = z.object({
|
|
100
|
+
success: z.literal(false),
|
|
101
|
+
error: z.string(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const skillxCleanupResultSchema = z.union([cleanupSuccessSchema, cleanupFailureSchema]);
|
|
105
|
+
|
|
106
|
+
export type SkillxParseRequest = z.infer<typeof skillxParseRequestSchema>;
|
|
107
|
+
export type SkillxListRequest = z.infer<typeof skillxListRequestSchema>;
|
|
108
|
+
export type SkillxFetchRequest = z.infer<typeof skillxFetchRequestSchema>;
|
|
109
|
+
export type SkillxCleanupRequest = z.infer<typeof skillxCleanupRequestSchema>;
|
|
110
|
+
export type SkillxParseResult = z.infer<typeof skillxParseResultSchema>;
|
|
111
|
+
export type SkillxListResult = z.infer<typeof skillxListResultSchema>;
|
|
112
|
+
export type SkillxFetchResult = z.infer<typeof skillxFetchResultSchema>;
|
|
113
|
+
export type SkillxCleanupResult = z.infer<typeof skillxCleanupResultSchema>;
|
|
114
|
+
|
|
115
|
+
export function buildSkillxGroup(): ClinkrGroup<AregCliContext> {
|
|
116
|
+
const group = new ClinkrGroup<AregCliContext>({
|
|
117
|
+
name: "skillx",
|
|
118
|
+
description: "Skillx helper operations.",
|
|
119
|
+
});
|
|
120
|
+
group.command({
|
|
121
|
+
name: "parse",
|
|
122
|
+
description: "Parse a skill source selector.",
|
|
123
|
+
schema: skillxParseRequestSchema,
|
|
124
|
+
positionals: { inputText: { position: 0 } },
|
|
125
|
+
resultSchema: skillxParseResultSchema,
|
|
126
|
+
handler: runSkillxParse,
|
|
127
|
+
});
|
|
128
|
+
group.command({
|
|
129
|
+
name: "list",
|
|
130
|
+
description: "List skills in a GitHub repository skills/ directory.",
|
|
131
|
+
schema: skillxListRequestSchema,
|
|
132
|
+
resultSchema: skillxListResultSchema,
|
|
133
|
+
handler: runSkillxList,
|
|
134
|
+
});
|
|
135
|
+
group.command({
|
|
136
|
+
name: "fetch",
|
|
137
|
+
description: "Fetch skills into a transient workspace for agent reading.",
|
|
138
|
+
schema: skillxFetchRequestSchema,
|
|
139
|
+
resultSchema: skillxFetchResultSchema,
|
|
140
|
+
handler: runSkillxFetch,
|
|
141
|
+
});
|
|
142
|
+
group.command({
|
|
143
|
+
name: "cleanup",
|
|
144
|
+
description: "Remove a transient skillx workspace.",
|
|
145
|
+
schema: skillxCleanupRequestSchema,
|
|
146
|
+
resultSchema: skillxCleanupResultSchema,
|
|
147
|
+
handler: runSkillxCleanup,
|
|
148
|
+
});
|
|
149
|
+
return group;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parseSkillInput(raw: string): SkillxParseResult {
|
|
153
|
+
const input = raw.trim();
|
|
154
|
+
if (input.length === 0) return parseError("Empty input");
|
|
155
|
+
const urlResult = parseGithubUrl(input);
|
|
156
|
+
if (urlResult !== undefined) return urlResult;
|
|
157
|
+
const parts = input.split(/\s+/u);
|
|
158
|
+
const [repo, second, third, ...rest] = parts;
|
|
159
|
+
if (repo === undefined || !isRepo(repo))
|
|
160
|
+
return parseError(`Could not extract owner/repo from input: ${JSON.stringify(input)}`);
|
|
161
|
+
if ((second === "--skill" || second === "-s") && third !== undefined && rest.length === 0) {
|
|
162
|
+
return { success: true, repo, skill: third, format: "skill-flag" };
|
|
163
|
+
}
|
|
164
|
+
if (second === undefined) return { success: true, repo, skill: null, format: "repo-only" };
|
|
165
|
+
if (third === undefined && isSkillName(second))
|
|
166
|
+
return { success: true, repo, skill: second, format: "plain" };
|
|
167
|
+
return parseError(`Could not extract owner/repo from input: ${JSON.stringify(input)}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function runSkillxParse(
|
|
171
|
+
_ctx: AregCliContext,
|
|
172
|
+
request: SkillxParseRequest,
|
|
173
|
+
): Promise<ClinkrExit<SkillxParseResult>> {
|
|
174
|
+
const result = parseSkillInput(request.inputText);
|
|
175
|
+
if (result.success) return ok(result);
|
|
176
|
+
return negative(result.error, { data: result });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function runSkillxList(
|
|
180
|
+
ctx: AregCliContext,
|
|
181
|
+
request: SkillxListRequest,
|
|
182
|
+
): Promise<ClinkrExit<SkillxListResult>> {
|
|
183
|
+
const tool = await ctx.host.checkTool({ tool: "gh", cwd: ctx.cwd, env: ctx.env });
|
|
184
|
+
if (tool.type === "missing") return failure("missing-tool", tool.message);
|
|
185
|
+
const result = await ctx.github.listSkillDirectoryNames({ repo: request.repo, env: ctx.env });
|
|
186
|
+
if (result.type === "ok") {
|
|
187
|
+
return ok({ success: true, repo: request.repo, skills: sortStrings(result.skillNames) });
|
|
188
|
+
}
|
|
189
|
+
if (result.type === "missing") {
|
|
190
|
+
const error = `No skills directory found in ${request.repo}`;
|
|
191
|
+
return negative(error, {
|
|
192
|
+
data: {
|
|
193
|
+
success: false,
|
|
194
|
+
error,
|
|
195
|
+
hint: "Check that the repo exists and has a skills/ directory",
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (result.type === "auth-error") {
|
|
200
|
+
return failure("github-auth-failed", `Authentication error accessing ${request.repo}`);
|
|
201
|
+
}
|
|
202
|
+
return failure("github-gateway-failed", result.error.message);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function runSkillxFetch(
|
|
206
|
+
ctx: AregCliContext,
|
|
207
|
+
request: SkillxFetchRequest,
|
|
208
|
+
): Promise<ClinkrExit<SkillxFetchResult>> {
|
|
209
|
+
const tool = await ctx.host.checkTool({ tool: "npx", cwd: ctx.cwd, env: ctx.env });
|
|
210
|
+
if (tool.type === "missing") return failure("missing-tool", tool.message);
|
|
211
|
+
const install = await ctx.skillxWorkspace.installIntoWorkspace({
|
|
212
|
+
sourceRepo: request.repo,
|
|
213
|
+
...optionalEntry("skillName", request.skill),
|
|
214
|
+
cwd: ctx.cwd,
|
|
215
|
+
env: ctx.env,
|
|
216
|
+
});
|
|
217
|
+
if (install.type === "error") return failure("skill-install-failed", install.error.message);
|
|
218
|
+
const workspaceRoot = install.workspace.workspaceRoot;
|
|
219
|
+
const installedSkills = sortedInstalledSkills(install.workspace.installedSkills);
|
|
220
|
+
if (installedSkills.length === 0) return fetchNegative("No skills were installed");
|
|
221
|
+
if (request.skill === undefined && installedSkills.length > 1) {
|
|
222
|
+
return ok({
|
|
223
|
+
success: true,
|
|
224
|
+
repo: request.repo,
|
|
225
|
+
skill: null,
|
|
226
|
+
tmpDir: workspaceRoot,
|
|
227
|
+
skillDir: null,
|
|
228
|
+
skillMd: null,
|
|
229
|
+
files: null,
|
|
230
|
+
needsSelection: true,
|
|
231
|
+
availableSkills: installedSkills.map((skill) => skill.name),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const selected =
|
|
235
|
+
request.skill === undefined
|
|
236
|
+
? installedSkills[0]
|
|
237
|
+
: installedSkills.find((skill) => skill.name === request.skill);
|
|
238
|
+
if (selected === undefined) {
|
|
239
|
+
await ctx.skillxWorkspace.cleanupWorkspace({ workspaceRoot });
|
|
240
|
+
const base = `Skill '${request.skill}' was not found in installed skills`;
|
|
241
|
+
return fetchNegative(base);
|
|
242
|
+
}
|
|
243
|
+
return ok({
|
|
244
|
+
success: true,
|
|
245
|
+
repo: request.repo,
|
|
246
|
+
skill: selected.name,
|
|
247
|
+
tmpDir: workspaceRoot,
|
|
248
|
+
skillDir: selected.directory,
|
|
249
|
+
skillMd: selected.skillFile,
|
|
250
|
+
files: sortStrings(selected.relativeFiles),
|
|
251
|
+
needsSelection: false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function runSkillxCleanup(
|
|
256
|
+
ctx: AregCliContext,
|
|
257
|
+
request: SkillxCleanupRequest,
|
|
258
|
+
): Promise<ClinkrExit<SkillxCleanupResult>> {
|
|
259
|
+
const cleanup = await ctx.skillxWorkspace.cleanupWorkspace({ workspaceRoot: request.dir });
|
|
260
|
+
if (cleanup.ok) return ok({ success: true, removed: request.dir });
|
|
261
|
+
return failure("cleanup-failed", cleanup.error.message);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseGithubUrl(input: string): z.infer<typeof parseSuccessSchema> | undefined {
|
|
265
|
+
let url: URL;
|
|
266
|
+
try {
|
|
267
|
+
url = new URL(input);
|
|
268
|
+
} catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
|
|
272
|
+
if (url.hostname !== "github.com" && url.hostname !== "www.github.com") return undefined;
|
|
273
|
+
const segments = url.pathname.split("/").filter((part) => part.length > 0);
|
|
274
|
+
const owner = segments[0];
|
|
275
|
+
const repoName = segments[1];
|
|
276
|
+
if (owner === undefined || repoName === undefined || !isRepo(`${owner}/${repoName}`))
|
|
277
|
+
return undefined;
|
|
278
|
+
const skillsIndex = segments.indexOf("skills");
|
|
279
|
+
const skill = skillsIndex === -1 ? null : (segments[skillsIndex + 1] ?? null);
|
|
280
|
+
return { success: true, repo: `${owner}/${repoName}`, skill, format: "url" };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isRepo(value: string): boolean {
|
|
284
|
+
return REPO_PATTERN.test(value);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isSkillName(value: string): boolean {
|
|
288
|
+
return value.length > 0 && !value.includes("/") && !value.includes("@") && !value.startsWith("-");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseError(error: string): SkillxParseResult {
|
|
292
|
+
return { success: false, error };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function fetchNegative(error: string): ClinkrExit<SkillxFetchResult> {
|
|
296
|
+
return negative(error, { data: { success: false, error, tmpDir: null } });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sortedInstalledSkills(
|
|
300
|
+
skills: readonly AregSkillxInstalledSkill[],
|
|
301
|
+
): AregSkillxInstalledSkill[] {
|
|
302
|
+
return skills
|
|
303
|
+
.map((skill) => ({ ...skill, relativeFiles: sortStrings(skill.relativeFiles) }))
|
|
304
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
305
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function renderAregSection(agents: readonly string[]): string {
|
|
2
|
+
return `[areg]\nagents = ${JSON.stringify([...agents])}\n`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function replaceOrAppendAregSection(content: string, agents: readonly string[]): string {
|
|
6
|
+
const lines = content.split(/(?<=\n)/u);
|
|
7
|
+
if (lines.length === 1 && lines[0] === "") lines.pop();
|
|
8
|
+
const start = aregSectionStart(lines);
|
|
9
|
+
if (start === undefined) return appendTomlSection(content, renderAregSection(agents));
|
|
10
|
+
const end = tomlSectionEnd(lines, start);
|
|
11
|
+
let replacement = renderAregSection(agents);
|
|
12
|
+
if (end < lines.length) replacement += "\n";
|
|
13
|
+
lines.splice(
|
|
14
|
+
start,
|
|
15
|
+
end - start,
|
|
16
|
+
...(replacement.match(/.*(?:\n|$)/gu)?.filter((line) => line.length > 0) ?? []),
|
|
17
|
+
);
|
|
18
|
+
return lines.join("");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function appendTomlSection(content: string, section: string): string {
|
|
22
|
+
if (content.length === 0) return section;
|
|
23
|
+
if (content.endsWith("\n\n")) return `${content}${section}`;
|
|
24
|
+
if (content.endsWith("\n")) return `${content}\n${section}`;
|
|
25
|
+
return `${content}\n\n${section}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function aregSectionStart(lines: readonly string[]): number | undefined {
|
|
29
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
30
|
+
if (tomlTableName(lines[index] ?? "") === "areg") return index;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tomlSectionEnd(lines: readonly string[], start: number): number {
|
|
36
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
37
|
+
if (tomlTableName(lines[index] ?? "") !== null) return index;
|
|
38
|
+
}
|
|
39
|
+
return lines.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tomlTableName(line: string): string | null {
|
|
43
|
+
const stripped = line.trim();
|
|
44
|
+
if (stripped.startsWith("[[")) {
|
|
45
|
+
const closingIndex = stripped.indexOf("]]", 2);
|
|
46
|
+
if (closingIndex < 0) return null;
|
|
47
|
+
return stripped.slice(2, closingIndex).trim();
|
|
48
|
+
}
|
|
49
|
+
if (!stripped.startsWith("[")) return null;
|
|
50
|
+
const closingIndex = stripped.indexOf("]");
|
|
51
|
+
if (closingIndex < 0) return null;
|
|
52
|
+
return stripped.slice(1, closingIndex).trim();
|
|
53
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { failure, ok, type ClinkrExit } from "@nseng-ai/clinkr";
|
|
2
|
+
import type { Result } from "@nseng-ai/foundation/result";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import type { AregCliContext } from "../context.ts";
|
|
6
|
+
import type { AregErrorInfo, AregGithubSkillFileResult } from "../gateways.ts";
|
|
7
|
+
import { sortStrings } from "../sort.ts";
|
|
8
|
+
import { parseInspectedLockfile, type LockfileSkill } from "./lockfile.ts";
|
|
9
|
+
import { resolveProjectAgents } from "./project-agents.ts";
|
|
10
|
+
|
|
11
|
+
const updateStatusSchema = z.enum(["planned", "updated", "failed"]);
|
|
12
|
+
|
|
13
|
+
const selectedUpdateSchema = z.object({
|
|
14
|
+
skill: z.string(),
|
|
15
|
+
source: z.string(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const attemptedUpdateSchema = selectedUpdateSchema.extend({
|
|
19
|
+
status: updateStatusSchema,
|
|
20
|
+
error: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const updateSkillsRequestSchema = z.object({
|
|
24
|
+
path: z.string().default(".").describe("Project directory containing skills-lock.json."),
|
|
25
|
+
skill: z.array(z.string()).default([]).describe("Skill name to update; repeatable."),
|
|
26
|
+
source: z
|
|
27
|
+
.array(z.string())
|
|
28
|
+
.default([])
|
|
29
|
+
.describe("Only update skills whose lockfile source matches; repeatable."),
|
|
30
|
+
agent: z.array(z.string()).default([]).describe("Agent directory to populate; repeatable."),
|
|
31
|
+
dryRun: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.default(false)
|
|
34
|
+
.describe("Print planned updates without source preflight or npx calls."),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const updateSkillsResultSchema = z.object({
|
|
38
|
+
ok: z.boolean(),
|
|
39
|
+
projectDir: z.string(),
|
|
40
|
+
agents: z.array(z.string()),
|
|
41
|
+
dryRun: z.boolean(),
|
|
42
|
+
selectedUpdates: z.array(selectedUpdateSchema),
|
|
43
|
+
attemptedUpdates: z.array(attemptedUpdateSchema),
|
|
44
|
+
failureCount: z.number().int().nonnegative(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type UpdateSkillsRequest = z.infer<typeof updateSkillsRequestSchema>;
|
|
48
|
+
export type UpdateSkillsResult = z.infer<typeof updateSkillsResultSchema>;
|
|
49
|
+
interface SelectedUpdate extends z.infer<typeof selectedUpdateSchema> {
|
|
50
|
+
skillPath?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type AttemptedUpdate = z.infer<typeof attemptedUpdateSchema>;
|
|
54
|
+
|
|
55
|
+
type PreflightOutcome = { ok: true } | { ok: false; code: string; message: string };
|
|
56
|
+
type GithubPreflightFailure =
|
|
57
|
+
| { type: "missing" | "auth-error"; message: string }
|
|
58
|
+
| { type: "error"; error: AregErrorInfo };
|
|
59
|
+
|
|
60
|
+
export async function runUpdateSkills(
|
|
61
|
+
ctx: AregCliContext,
|
|
62
|
+
request: UpdateSkillsRequest,
|
|
63
|
+
): Promise<ClinkrExit<UpdateSkillsResult>> {
|
|
64
|
+
const inspection = await ctx.project.inspectProjectBase({
|
|
65
|
+
cwd: ctx.cwd,
|
|
66
|
+
projectPath: request.path,
|
|
67
|
+
env: ctx.env,
|
|
68
|
+
});
|
|
69
|
+
if (inspection.projectPathState.type !== "directory") {
|
|
70
|
+
return failure("invalid-project", `${inspection.projectDir} is not a directory`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lockfileResult = parseInspectedLockfile(inspection);
|
|
74
|
+
if (!lockfileResult.ok) return failure("lockfile-invalid", lockfileResult.error.message);
|
|
75
|
+
|
|
76
|
+
const selection = selectGithubUpdates(lockfileResult.value.skills, request);
|
|
77
|
+
if (!selection.ok) return failure("invalid-selection", selection.error.message);
|
|
78
|
+
const selectedUpdates = selection.value;
|
|
79
|
+
|
|
80
|
+
if (selectedUpdates.length === 0)
|
|
81
|
+
return ok(emptyReport(inspection.projectDir, request.dryRun, true));
|
|
82
|
+
|
|
83
|
+
const agentsResult = resolveProjectAgents({
|
|
84
|
+
explicitAgents: request.agent,
|
|
85
|
+
nsToml: inspection.nsToml,
|
|
86
|
+
aregJson: inspection.aregJson,
|
|
87
|
+
});
|
|
88
|
+
if (!agentsResult.ok) return failure("agent-resolution-failed", agentsResult.error.message);
|
|
89
|
+
const agents = agentsResult.value;
|
|
90
|
+
|
|
91
|
+
if (!request.dryRun) {
|
|
92
|
+
const gh = await ctx.host.checkTool({ tool: "gh", cwd: inspection.projectDir, env: ctx.env });
|
|
93
|
+
if (gh.type === "missing") return failure("missing-tool", gh.message);
|
|
94
|
+
const preflight = await preflightSelectedUpdates(ctx, selectedUpdates);
|
|
95
|
+
if (!preflight.ok) return failure("skill-source-preflight-failed", preflight.error.message);
|
|
96
|
+
const npx = await ctx.host.checkTool({ tool: "npx", cwd: inspection.projectDir, env: ctx.env });
|
|
97
|
+
if (npx.type === "missing") return failure("missing-tool", npx.message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const attemptedUpdates: AttemptedUpdate[] = [];
|
|
101
|
+
for (const update of selectedUpdates) {
|
|
102
|
+
if (request.dryRun) {
|
|
103
|
+
attemptedUpdates.push({ ...publicSelectedUpdate(update), status: "planned" });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const result = await ctx.npxSkills.addSkills({
|
|
107
|
+
sourceRepo: update.source,
|
|
108
|
+
skillNames: [update.skill],
|
|
109
|
+
targetAgents: agents,
|
|
110
|
+
cwd: inspection.projectDir,
|
|
111
|
+
env: ctx.env,
|
|
112
|
+
});
|
|
113
|
+
if (result.type === "ok") {
|
|
114
|
+
attemptedUpdates.push({ ...publicSelectedUpdate(update), status: "updated" });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
attemptedUpdates.push({
|
|
118
|
+
...publicSelectedUpdate(update),
|
|
119
|
+
status: "failed",
|
|
120
|
+
error: result.error.message,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const finalReport = report({
|
|
125
|
+
projectDir: inspection.projectDir,
|
|
126
|
+
agents,
|
|
127
|
+
dryRun: request.dryRun,
|
|
128
|
+
selectedUpdates,
|
|
129
|
+
attemptedUpdates,
|
|
130
|
+
});
|
|
131
|
+
if (finalReport.failureCount > 0)
|
|
132
|
+
return failure("skill-update-failed", formatFailureMessage(finalReport));
|
|
133
|
+
return ok(finalReport);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function renderUpdateSkills(result: UpdateSkillsResult): string {
|
|
137
|
+
if (result.selectedUpdates.length === 0)
|
|
138
|
+
return "No github-sourced skills match. Nothing to update.";
|
|
139
|
+
const suffix = result.dryRun ? " [dry-run]" : "";
|
|
140
|
+
const lines = [
|
|
141
|
+
`Updating ${result.selectedUpdates.length} skill(s) with agents ${result.agents.join(", ")}${suffix}:`,
|
|
142
|
+
];
|
|
143
|
+
for (const update of result.attemptedUpdates) {
|
|
144
|
+
lines.push(` ${update.skill} <- ${update.source}`);
|
|
145
|
+
if (update.status === "failed") lines.push(` FAILED: ${update.error ?? "unknown error"}`);
|
|
146
|
+
}
|
|
147
|
+
if (result.dryRun)
|
|
148
|
+
lines.push("", `Planned: ${result.attemptedUpdates.length} skill(s). No changes made.`);
|
|
149
|
+
else lines.push("", `Updated ${result.attemptedUpdates.length} skill(s).`);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function selectGithubUpdates(
|
|
154
|
+
skills: readonly LockfileSkill[],
|
|
155
|
+
request: UpdateSkillsRequest,
|
|
156
|
+
): Result<readonly SelectedUpdate[]> {
|
|
157
|
+
const githubEntries = new Map<string, { source: string; skillPath?: string }>();
|
|
158
|
+
for (const skill of skills) {
|
|
159
|
+
if (skill.sourceType === "github") {
|
|
160
|
+
githubEntries.set(skill.name, {
|
|
161
|
+
source: skill.source,
|
|
162
|
+
...(skill.skillPath === undefined ? {} : { skillPath: skill.skillPath }),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const requestedSkills = new Set(request.skill);
|
|
168
|
+
if (requestedSkills.size > 0) {
|
|
169
|
+
const unknown = sortStrings([...requestedSkills].filter((skill) => !githubEntries.has(skill)));
|
|
170
|
+
if (unknown.length > 0)
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
error: {
|
|
174
|
+
code: "skill_not_found",
|
|
175
|
+
message: `Skill(s) not found in lockfile (or not github-sourced): ${unknown.join(", ")}`,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const requestedSources = new Set(request.source);
|
|
181
|
+
const updates: SelectedUpdate[] = [];
|
|
182
|
+
for (const skill of sortStrings([...githubEntries.keys()])) {
|
|
183
|
+
const entry = githubEntries.get(skill);
|
|
184
|
+
if (entry === undefined) continue;
|
|
185
|
+
if (requestedSkills.size > 0 && !requestedSkills.has(skill)) continue;
|
|
186
|
+
if (requestedSources.size > 0 && !requestedSources.has(entry.source)) continue;
|
|
187
|
+
updates.push({
|
|
188
|
+
skill,
|
|
189
|
+
source: entry.source,
|
|
190
|
+
...(entry.skillPath === undefined ? {} : { skillPath: entry.skillPath }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, value: updates };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function preflightSelectedUpdates(
|
|
197
|
+
ctx: AregCliContext,
|
|
198
|
+
selectedUpdates: readonly SelectedUpdate[],
|
|
199
|
+
): Promise<Result<undefined>> {
|
|
200
|
+
const failures: string[] = [];
|
|
201
|
+
for (const update of selectedUpdates) {
|
|
202
|
+
const result = await preflightSelectedUpdate(ctx, update);
|
|
203
|
+
if (!result.ok) failures.push(result.error.message);
|
|
204
|
+
}
|
|
205
|
+
if (failures.length === 0) return { ok: true, value: undefined };
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: {
|
|
209
|
+
code: "skill_source_preflight_failed",
|
|
210
|
+
message: `Source preflight failed before updating skills:\n${failures
|
|
211
|
+
.map((message) => ` - ${message}`)
|
|
212
|
+
.join("\n")}`,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function preflightSelectedUpdate(
|
|
218
|
+
ctx: AregCliContext,
|
|
219
|
+
update: SelectedUpdate,
|
|
220
|
+
): Promise<Result<undefined>> {
|
|
221
|
+
if (update.skillPath !== undefined) {
|
|
222
|
+
const result = await ctx.github.checkSkillFile({
|
|
223
|
+
repo: update.source,
|
|
224
|
+
path: update.skillPath,
|
|
225
|
+
env: ctx.env,
|
|
226
|
+
});
|
|
227
|
+
return preflightOutcomeResult(update, normalizeSkillFilePreflightResult(result));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result = await ctx.github.listSkillDirectoryNames({ repo: update.source, env: ctx.env });
|
|
231
|
+
if (result.type === "ok") {
|
|
232
|
+
if (result.skillNames.includes(update.skill)) return { ok: true, value: undefined };
|
|
233
|
+
return preflightOutcomeResult(update, {
|
|
234
|
+
ok: false,
|
|
235
|
+
code: "skill_source_preflight_failed",
|
|
236
|
+
message: "not found in skills directory before update",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return preflightOutcomeResult(update, normalizeGithubPreflightFailure(result));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeSkillFilePreflightResult(result: AregGithubSkillFileResult): PreflightOutcome {
|
|
243
|
+
if (result.type === "found") return { ok: true };
|
|
244
|
+
return normalizeGithubPreflightFailure(result);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function normalizeGithubPreflightFailure(result: GithubPreflightFailure): PreflightOutcome {
|
|
248
|
+
switch (result.type) {
|
|
249
|
+
case "missing":
|
|
250
|
+
case "auth-error":
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
code: "skill_source_preflight_failed",
|
|
254
|
+
message: result.message,
|
|
255
|
+
};
|
|
256
|
+
case "error":
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
code: result.error.code,
|
|
260
|
+
message: result.error.message,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function preflightOutcomeResult(
|
|
266
|
+
update: SelectedUpdate,
|
|
267
|
+
outcome: PreflightOutcome,
|
|
268
|
+
): Result<undefined> {
|
|
269
|
+
if (outcome.ok) return { ok: true, value: undefined };
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
error: {
|
|
273
|
+
code: outcome.code,
|
|
274
|
+
message: `${update.skill} from ${update.source}: ${outcome.message}`,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function report(input: {
|
|
280
|
+
ok?: boolean;
|
|
281
|
+
projectDir: string;
|
|
282
|
+
agents: readonly string[];
|
|
283
|
+
dryRun: boolean;
|
|
284
|
+
selectedUpdates: readonly SelectedUpdate[];
|
|
285
|
+
attemptedUpdates: readonly AttemptedUpdate[];
|
|
286
|
+
}): UpdateSkillsResult {
|
|
287
|
+
const failures = input.attemptedUpdates.filter((update) => update.status === "failed");
|
|
288
|
+
return {
|
|
289
|
+
ok: input.ok ?? failures.length === 0,
|
|
290
|
+
projectDir: input.projectDir,
|
|
291
|
+
agents: [...input.agents],
|
|
292
|
+
dryRun: input.dryRun,
|
|
293
|
+
selectedUpdates: input.selectedUpdates.map(publicSelectedUpdate),
|
|
294
|
+
attemptedUpdates: input.attemptedUpdates.map((update) => ({ ...update })),
|
|
295
|
+
failureCount: failures.length,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function publicSelectedUpdate(update: SelectedUpdate): z.infer<typeof selectedUpdateSchema> {
|
|
300
|
+
return { skill: update.skill, source: update.source };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function emptyReport(projectDir: string, dryRun: boolean, isOk: boolean): UpdateSkillsResult {
|
|
304
|
+
return report({
|
|
305
|
+
ok: isOk,
|
|
306
|
+
projectDir,
|
|
307
|
+
agents: [],
|
|
308
|
+
dryRun,
|
|
309
|
+
selectedUpdates: [],
|
|
310
|
+
attemptedUpdates: [],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function formatFailureMessage(result: UpdateSkillsResult): string {
|
|
315
|
+
const failed = result.attemptedUpdates.filter((update) => update.status === "failed");
|
|
316
|
+
const lines = [
|
|
317
|
+
`${failed.length} skill(s) failed to update: ${failed.map((update) => update.skill).join(", ")}`,
|
|
318
|
+
];
|
|
319
|
+
for (const update of failed)
|
|
320
|
+
lines.push(
|
|
321
|
+
` ${update.skill} <- ${update.source}`,
|
|
322
|
+
` FAILED: ${update.error ?? "unknown error"}`,
|
|
323
|
+
);
|
|
324
|
+
return lines.join("\n");
|
|
325
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { RealAregProjectGateway } from "./gateways/project-gateway.ts";
|
|
2
|
+
export { RealAregGithubGateway } from "./gateways/github-gateway.ts";
|
|
3
|
+
export { RealAregHostGateway } from "./gateways/host-gateway.ts";
|
|
4
|
+
export { buildNpxSkillsAddArgs, RealAregNpxSkillsGateway } from "./gateways/npx-skills-gateway.ts";
|
|
5
|
+
export { RealAregPromptGateway } from "./gateways/prompt-gateway.ts";
|
|
6
|
+
export { RealAregSkillxWorkspaceGateway } from "./gateways/skillx-workspace-gateway.ts";
|