facult 1.0.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/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/facult.cjs +302 -0
- package/package.json +78 -0
- package/src/adapters/claude-cli.ts +18 -0
- package/src/adapters/claude-desktop.ts +15 -0
- package/src/adapters/clawdbot.ts +18 -0
- package/src/adapters/codex.ts +19 -0
- package/src/adapters/cursor.ts +18 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.ts +270 -0
- package/src/adapters/reference.ts +9 -0
- package/src/adapters/skills.ts +47 -0
- package/src/adapters/types.ts +42 -0
- package/src/adapters/version.ts +18 -0
- package/src/audit/agent.ts +1071 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/static.ts +1130 -0
- package/src/audit/tui.ts +704 -0
- package/src/audit/types.ts +68 -0
- package/src/audit/update-index.ts +115 -0
- package/src/conflicts.ts +135 -0
- package/src/consolidate-conflict-action.ts +57 -0
- package/src/consolidate.ts +1637 -0
- package/src/enable-disable.ts +349 -0
- package/src/index-builder.ts +562 -0
- package/src/index.ts +589 -0
- package/src/manage.ts +894 -0
- package/src/migrate.ts +272 -0
- package/src/paths.ts +238 -0
- package/src/quarantine.ts +217 -0
- package/src/query.ts +186 -0
- package/src/remote-manifest-integrity.ts +367 -0
- package/src/remote-providers.ts +905 -0
- package/src/remote-source-policy.ts +237 -0
- package/src/remote-sources.ts +162 -0
- package/src/remote-types.ts +136 -0
- package/src/remote.ts +1970 -0
- package/src/scan.ts +2427 -0
- package/src/schema.ts +39 -0
- package/src/self-update.ts +408 -0
- package/src/snippets-cli.ts +293 -0
- package/src/snippets.ts +706 -0
- package/src/source-trust.ts +203 -0
- package/src/trust-list.ts +232 -0
- package/src/trust.ts +170 -0
- package/src/tui.ts +118 -0
- package/src/util/codex-toml.ts +126 -0
- package/src/util/json.ts +32 -0
- package/src/util/skills.ts +55 -0
package/src/remote.ts
ADDED
|
@@ -0,0 +1,1970 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { buildIndex } from "./index-builder";
|
|
5
|
+
import { facultRootDir } from "./paths";
|
|
6
|
+
import {
|
|
7
|
+
assertManifestIntegrity,
|
|
8
|
+
assertManifestSignature,
|
|
9
|
+
} from "./remote-manifest-integrity";
|
|
10
|
+
import { loadProviderManifest } from "./remote-providers";
|
|
11
|
+
import {
|
|
12
|
+
assertSourceAllowed,
|
|
13
|
+
evaluateSourceTrust,
|
|
14
|
+
sourcesCommand as runSourcesCommand,
|
|
15
|
+
} from "./remote-source-policy";
|
|
16
|
+
import { readIndexSources, resolveKnownIndexSource } from "./remote-sources";
|
|
17
|
+
import {
|
|
18
|
+
BUILTIN_INDEX_NAME,
|
|
19
|
+
BUILTIN_INDEX_URL,
|
|
20
|
+
CLAWHUB_INDEX_NAME,
|
|
21
|
+
GLAMA_INDEX_NAME,
|
|
22
|
+
type IndexSource,
|
|
23
|
+
type LoadManifestHints,
|
|
24
|
+
type RemoteAgentItem,
|
|
25
|
+
type RemoteIndexItem,
|
|
26
|
+
type RemoteIndexManifest,
|
|
27
|
+
type RemoteItemType,
|
|
28
|
+
type RemoteMcpItem,
|
|
29
|
+
type RemoteSkillItem,
|
|
30
|
+
type RemoteSnippetItem,
|
|
31
|
+
SKILLS_SH_INDEX_NAME,
|
|
32
|
+
SMITHERY_INDEX_NAME,
|
|
33
|
+
} from "./remote-types";
|
|
34
|
+
import { validateSnippetMarkerName } from "./snippets";
|
|
35
|
+
import { loadSourceTrustState, type SourceTrustLevel } from "./source-trust";
|
|
36
|
+
import { parseJsonLenient } from "./util/json";
|
|
37
|
+
|
|
38
|
+
const REMOTE_STATE_VERSION = 1;
|
|
39
|
+
const VERSION_TOKEN_RE = /[A-Za-z]+|[0-9]+/g;
|
|
40
|
+
const QUERY_SPLIT_RE = /\s+/;
|
|
41
|
+
const MD_EXT_RE = /\.md$/i;
|
|
42
|
+
|
|
43
|
+
interface InstalledRemoteItem {
|
|
44
|
+
ref: string;
|
|
45
|
+
index: string;
|
|
46
|
+
itemId: string;
|
|
47
|
+
type: RemoteItemType;
|
|
48
|
+
installedAs: string;
|
|
49
|
+
path: string;
|
|
50
|
+
version?: string;
|
|
51
|
+
sourceUrl?: string;
|
|
52
|
+
sourceTrustLevel?: SourceTrustLevel;
|
|
53
|
+
installedAt: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface InstalledRemoteState {
|
|
57
|
+
version: number;
|
|
58
|
+
updatedAt: string;
|
|
59
|
+
items: InstalledRemoteItem[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RemoteCommandContext {
|
|
63
|
+
homeDir?: string;
|
|
64
|
+
rootDir?: string;
|
|
65
|
+
cwd?: string;
|
|
66
|
+
now?: () => Date;
|
|
67
|
+
fetchJson?: (url: string) => Promise<unknown>;
|
|
68
|
+
fetchText?: (url: string) => Promise<string>;
|
|
69
|
+
strictSourceTrust?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface SearchResult {
|
|
73
|
+
index: string;
|
|
74
|
+
item: RemoteIndexItem;
|
|
75
|
+
score: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface InstallResult {
|
|
79
|
+
ref: string;
|
|
80
|
+
type: RemoteItemType;
|
|
81
|
+
installedAs: string;
|
|
82
|
+
path: string;
|
|
83
|
+
sourceTrustLevel: SourceTrustLevel;
|
|
84
|
+
dryRun: boolean;
|
|
85
|
+
changedPaths: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface UpdateCheckResult {
|
|
89
|
+
installed: InstalledRemoteItem;
|
|
90
|
+
latestVersion?: string;
|
|
91
|
+
currentVersion?: string;
|
|
92
|
+
status:
|
|
93
|
+
| "up-to-date"
|
|
94
|
+
| "outdated"
|
|
95
|
+
| "missing-index"
|
|
96
|
+
| "missing-item"
|
|
97
|
+
| "blocked-source"
|
|
98
|
+
| "review-source";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface UpdateReport {
|
|
102
|
+
checkedAt: string;
|
|
103
|
+
checks: UpdateCheckResult[];
|
|
104
|
+
applied: InstallResult[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type VerifyCheckStatus =
|
|
108
|
+
| "passed"
|
|
109
|
+
| "failed"
|
|
110
|
+
| "not-configured"
|
|
111
|
+
| "not-applicable";
|
|
112
|
+
|
|
113
|
+
interface VerifySourceReport {
|
|
114
|
+
checkedAt: string;
|
|
115
|
+
source: {
|
|
116
|
+
name: string;
|
|
117
|
+
url: string;
|
|
118
|
+
kind: IndexSource["kind"];
|
|
119
|
+
};
|
|
120
|
+
trust: {
|
|
121
|
+
level: SourceTrustLevel;
|
|
122
|
+
explicit: boolean;
|
|
123
|
+
note?: string;
|
|
124
|
+
updatedAt?: string;
|
|
125
|
+
};
|
|
126
|
+
checks: {
|
|
127
|
+
fetch: VerifyCheckStatus;
|
|
128
|
+
parse: VerifyCheckStatus;
|
|
129
|
+
integrity: VerifyCheckStatus;
|
|
130
|
+
signature: VerifyCheckStatus;
|
|
131
|
+
items: number;
|
|
132
|
+
};
|
|
133
|
+
error?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const BUILTIN_MANIFEST: RemoteIndexManifest = {
|
|
137
|
+
name: BUILTIN_INDEX_NAME,
|
|
138
|
+
url: BUILTIN_INDEX_URL,
|
|
139
|
+
updatedAt: "2026-02-21T00:00:00.000Z",
|
|
140
|
+
items: [
|
|
141
|
+
{
|
|
142
|
+
id: "skill-template",
|
|
143
|
+
type: "skill",
|
|
144
|
+
title: "Skill Template",
|
|
145
|
+
description:
|
|
146
|
+
"Production-ready SKILL.md scaffold with clear trigger, workflow, and output sections.",
|
|
147
|
+
version: "1.0.0",
|
|
148
|
+
tags: ["template", "dx", "skill"],
|
|
149
|
+
skill: {
|
|
150
|
+
name: "my-skill",
|
|
151
|
+
files: {
|
|
152
|
+
"SKILL.md": `---
|
|
153
|
+
description: "{{name}} workflow skill"
|
|
154
|
+
tags: [template, workflow]
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
# {{name}}
|
|
158
|
+
|
|
159
|
+
## When To Use
|
|
160
|
+
Use this skill when the task repeatedly follows a known workflow and you want consistent, reviewable outputs.
|
|
161
|
+
|
|
162
|
+
## Inputs
|
|
163
|
+
- Goal and expected outcome.
|
|
164
|
+
- Constraints (time, tooling, compatibility).
|
|
165
|
+
- Required artifacts (files, commands, links).
|
|
166
|
+
|
|
167
|
+
## Steps
|
|
168
|
+
1. Confirm scope and assumptions in one short summary.
|
|
169
|
+
2. Gather only the context needed to complete the task.
|
|
170
|
+
3. Execute the workflow incrementally and validate after each major change.
|
|
171
|
+
4. Report results with concrete file/command references and remaining risks.
|
|
172
|
+
|
|
173
|
+
## Output Contract
|
|
174
|
+
- Include what changed and why.
|
|
175
|
+
- Include validation evidence (tests/checks run).
|
|
176
|
+
- Include clear next steps when follow-up work exists.
|
|
177
|
+
`,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: "mcp-stdio-template",
|
|
183
|
+
type: "mcp",
|
|
184
|
+
title: "MCP Stdio Template",
|
|
185
|
+
description:
|
|
186
|
+
"Safe starting MCP server entry with explicit command/args/env placeholders.",
|
|
187
|
+
version: "1.0.0",
|
|
188
|
+
tags: ["template", "dx", "mcp"],
|
|
189
|
+
mcp: {
|
|
190
|
+
name: "example-server",
|
|
191
|
+
definition: {
|
|
192
|
+
command: "node",
|
|
193
|
+
args: ["./servers/{{name}}/index.js"],
|
|
194
|
+
env: {
|
|
195
|
+
API_KEY: "<set-me>",
|
|
196
|
+
},
|
|
197
|
+
enabledFor: [],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "agents-md-template",
|
|
203
|
+
type: "agent",
|
|
204
|
+
title: "AGENTS.md Template",
|
|
205
|
+
description:
|
|
206
|
+
"Project-wide agent instruction template optimized for clarity, quality gates, and DX.",
|
|
207
|
+
version: "1.0.0",
|
|
208
|
+
tags: ["template", "dx", "instructions"],
|
|
209
|
+
agent: {
|
|
210
|
+
fileName: "AGENTS.md",
|
|
211
|
+
content: `# Project Agent Instructions
|
|
212
|
+
|
|
213
|
+
## Mission
|
|
214
|
+
Ship reliable changes quickly while keeping behavior predictable.
|
|
215
|
+
|
|
216
|
+
## Working Rules
|
|
217
|
+
- Prefer small, reviewable diffs.
|
|
218
|
+
- Preserve existing style and architecture unless a refactor is explicitly requested.
|
|
219
|
+
- Validate behavior with tests/checks after meaningful changes.
|
|
220
|
+
- Avoid destructive actions unless explicitly approved.
|
|
221
|
+
|
|
222
|
+
## Engineering Quality
|
|
223
|
+
- Keep implementations simple and observable.
|
|
224
|
+
- Fail with actionable error messages.
|
|
225
|
+
- Prioritize backwards compatibility and data safety.
|
|
226
|
+
|
|
227
|
+
## Delivery Format
|
|
228
|
+
- Summarize what changed.
|
|
229
|
+
- Include file and command references.
|
|
230
|
+
- Call out open risks and next steps.
|
|
231
|
+
`,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "claude-md-template",
|
|
236
|
+
type: "agent",
|
|
237
|
+
title: "CLAUDE.md Template",
|
|
238
|
+
description:
|
|
239
|
+
"Agent-specific instruction template for consistent collaboration and output quality.",
|
|
240
|
+
version: "1.0.0",
|
|
241
|
+
tags: ["template", "dx", "instructions"],
|
|
242
|
+
agent: {
|
|
243
|
+
fileName: "CLAUDE.md",
|
|
244
|
+
content: `# Claude Working Contract
|
|
245
|
+
|
|
246
|
+
## Default Mode
|
|
247
|
+
- Be concise, factual, and implementation-first.
|
|
248
|
+
- Prefer executable steps over abstract advice.
|
|
249
|
+
|
|
250
|
+
## Safety + Correctness
|
|
251
|
+
- Verify assumptions in code or tests before claiming completion.
|
|
252
|
+
- Surface uncertainties explicitly.
|
|
253
|
+
- Never leak secrets or include sensitive raw values in logs/output.
|
|
254
|
+
|
|
255
|
+
## Code Expectations
|
|
256
|
+
- Write readable code with clear intent.
|
|
257
|
+
- Add tests for behavior changes.
|
|
258
|
+
- Keep command usage reproducible.
|
|
259
|
+
|
|
260
|
+
## Response Expectations
|
|
261
|
+
- Lead with outcome.
|
|
262
|
+
- Include concrete references to files and validation.
|
|
263
|
+
- End with the smallest useful next-step list.
|
|
264
|
+
`,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: "snippet-template",
|
|
269
|
+
type: "snippet",
|
|
270
|
+
title: "Snippet Template",
|
|
271
|
+
description:
|
|
272
|
+
"Reusable snippet block template for coding standards and communication style.",
|
|
273
|
+
version: "1.0.0",
|
|
274
|
+
tags: ["template", "dx", "snippet"],
|
|
275
|
+
snippet: {
|
|
276
|
+
marker: "team/codingstyle",
|
|
277
|
+
content: `## Coding Style
|
|
278
|
+
- Prefer explicit, descriptive names over abbreviations.
|
|
279
|
+
- Keep functions focused and side-effect boundaries obvious.
|
|
280
|
+
- Add tests when behavior changes.
|
|
281
|
+
|
|
282
|
+
## Review Checklist
|
|
283
|
+
- Is behavior correct for edge cases?
|
|
284
|
+
- Are failure modes clear and actionable?
|
|
285
|
+
- Is the change minimal for the goal?
|
|
286
|
+
`,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function isSafePathString(p: string): boolean {
|
|
293
|
+
return !p.includes("\0");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
297
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function uniqueSorted(values: string[]): string[] {
|
|
301
|
+
return Array.from(new Set(values)).sort();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function nowIso(now?: () => Date): string {
|
|
305
|
+
return (now ? now() : new Date()).toISOString();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseSourceTrustLevel(raw: unknown): SourceTrustLevel | undefined {
|
|
309
|
+
if (raw === "trusted" || raw === "review" || raw === "blocked") {
|
|
310
|
+
return raw;
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function renderTemplate(text: string, values: Record<string, string>): string {
|
|
316
|
+
let out = text;
|
|
317
|
+
for (const [k, v] of Object.entries(values)) {
|
|
318
|
+
out = out.replaceAll(`{{${k}}}`, v);
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function compareVersions(a: string, b: string): number {
|
|
324
|
+
const aTokens = (a.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
|
|
325
|
+
const bTokens = (b.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
|
|
326
|
+
const n = Math.max(aTokens.length, bTokens.length);
|
|
327
|
+
for (let i = 0; i < n; i += 1) {
|
|
328
|
+
const av = aTokens[i];
|
|
329
|
+
const bv = bTokens[i];
|
|
330
|
+
if (av === undefined && bv === undefined) {
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
if (av === undefined) {
|
|
334
|
+
return -1;
|
|
335
|
+
}
|
|
336
|
+
if (bv === undefined) {
|
|
337
|
+
return 1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const an = Number(av);
|
|
341
|
+
const bn = Number(bv);
|
|
342
|
+
const aIsNum = Number.isFinite(an) && `${an}` === av;
|
|
343
|
+
const bIsNum = Number.isFinite(bn) && `${bn}` === bv;
|
|
344
|
+
if (aIsNum && bIsNum) {
|
|
345
|
+
if (an < bn) {
|
|
346
|
+
return -1;
|
|
347
|
+
}
|
|
348
|
+
if (an > bn) {
|
|
349
|
+
return 1;
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const cmp = av.localeCompare(bv);
|
|
355
|
+
if (cmp !== 0) {
|
|
356
|
+
return cmp;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isSafeRelativePath(relPath: string): boolean {
|
|
363
|
+
if (!relPath || isAbsolute(relPath) || !isSafePathString(relPath)) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
const normalized = relPath.replaceAll("\\", "/");
|
|
367
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
368
|
+
if (!parts.length) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
if (parts.includes(".") || parts.includes("..")) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isSubpath(parent: string, child: string): boolean {
|
|
378
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
379
|
+
return rel === "" || !(rel.startsWith("..") || isAbsolute(rel));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function parseRef(ref: string): { index: string; itemId: string } | null {
|
|
383
|
+
const i = ref.indexOf(":");
|
|
384
|
+
if (i <= 0 || i >= ref.length - 1) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
index: ref.slice(0, i).trim(),
|
|
389
|
+
itemId: ref.slice(i + 1).trim(),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
394
|
+
try {
|
|
395
|
+
await Bun.file(path).stat();
|
|
396
|
+
return true;
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function defaultFetchJson(url: string, cwd: string): Promise<unknown> {
|
|
403
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
404
|
+
const res = await fetch(url);
|
|
405
|
+
if (!res.ok) {
|
|
406
|
+
throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
|
|
407
|
+
}
|
|
408
|
+
return (await res.json()) as unknown;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let path = url;
|
|
412
|
+
if (url.startsWith("file://")) {
|
|
413
|
+
const parsed = new URL(url);
|
|
414
|
+
path = decodeURIComponent(parsed.pathname);
|
|
415
|
+
} else if (!isAbsolute(url)) {
|
|
416
|
+
path = resolve(cwd, url);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const raw = await readFile(path, "utf8");
|
|
420
|
+
return parseJsonLenient(raw);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function defaultFetchText(url: string, cwd: string): Promise<string> {
|
|
424
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
425
|
+
const res = await fetch(url);
|
|
426
|
+
if (!res.ok) {
|
|
427
|
+
throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
|
|
428
|
+
}
|
|
429
|
+
return await res.text();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let path = url;
|
|
433
|
+
if (url.startsWith("file://")) {
|
|
434
|
+
const parsed = new URL(url);
|
|
435
|
+
path = decodeURIComponent(parsed.pathname);
|
|
436
|
+
} else if (!isAbsolute(url)) {
|
|
437
|
+
path = resolve(cwd, url);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return await readFile(path, "utf8");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function parseIndexItem(raw: unknown): RemoteIndexItem | null {
|
|
444
|
+
if (!isPlainObject(raw)) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const obj = raw as Record<string, unknown>;
|
|
448
|
+
const id = typeof obj.id === "string" ? obj.id.trim() : "";
|
|
449
|
+
const type = typeof obj.type === "string" ? obj.type.trim() : "";
|
|
450
|
+
if (!id) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
if (
|
|
454
|
+
type !== "skill" &&
|
|
455
|
+
type !== "mcp" &&
|
|
456
|
+
type !== "agent" &&
|
|
457
|
+
type !== "snippet"
|
|
458
|
+
) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
const title = typeof obj.title === "string" ? obj.title : undefined;
|
|
462
|
+
const description =
|
|
463
|
+
typeof obj.description === "string" ? obj.description : undefined;
|
|
464
|
+
const version = typeof obj.version === "string" ? obj.version : undefined;
|
|
465
|
+
const sourceUrl =
|
|
466
|
+
typeof obj.sourceUrl === "string" ? obj.sourceUrl : undefined;
|
|
467
|
+
const tags = Array.isArray(obj.tags)
|
|
468
|
+
? uniqueSorted(
|
|
469
|
+
obj.tags
|
|
470
|
+
.filter((v) => typeof v === "string")
|
|
471
|
+
.map((v) => v.trim())
|
|
472
|
+
.filter(Boolean)
|
|
473
|
+
)
|
|
474
|
+
: undefined;
|
|
475
|
+
|
|
476
|
+
if (type === "skill") {
|
|
477
|
+
const skillRaw = obj.skill;
|
|
478
|
+
if (!isPlainObject(skillRaw)) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
const name =
|
|
482
|
+
typeof skillRaw.name === "string" ? skillRaw.name.trim() : "new-skill";
|
|
483
|
+
const filesRaw = skillRaw.files;
|
|
484
|
+
if (!isPlainObject(filesRaw)) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
const files: Record<string, string> = {};
|
|
488
|
+
for (const [k, v] of Object.entries(filesRaw)) {
|
|
489
|
+
if (!isSafeRelativePath(k) || typeof v !== "string") {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
files[k] = v;
|
|
493
|
+
}
|
|
494
|
+
if (!Object.keys(files).length) {
|
|
495
|
+
files["SKILL.md"] = "# {{name}}\n";
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
id,
|
|
499
|
+
type,
|
|
500
|
+
title,
|
|
501
|
+
description,
|
|
502
|
+
version,
|
|
503
|
+
sourceUrl,
|
|
504
|
+
tags,
|
|
505
|
+
skill: { name, files },
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (type === "mcp") {
|
|
510
|
+
const mcpRaw = obj.mcp;
|
|
511
|
+
if (!isPlainObject(mcpRaw)) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
const name =
|
|
515
|
+
typeof mcpRaw.name === "string" ? mcpRaw.name.trim() : "example-server";
|
|
516
|
+
const defRaw = mcpRaw.definition;
|
|
517
|
+
if (!isPlainObject(defRaw)) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
id,
|
|
522
|
+
type,
|
|
523
|
+
title,
|
|
524
|
+
description,
|
|
525
|
+
version,
|
|
526
|
+
sourceUrl,
|
|
527
|
+
tags,
|
|
528
|
+
mcp: { name, definition: defRaw },
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (type === "agent") {
|
|
533
|
+
const agentRaw = obj.agent;
|
|
534
|
+
if (!isPlainObject(agentRaw)) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
const fileName =
|
|
538
|
+
typeof agentRaw.fileName === "string" ? agentRaw.fileName.trim() : "";
|
|
539
|
+
const content =
|
|
540
|
+
typeof agentRaw.content === "string" ? agentRaw.content : "";
|
|
541
|
+
if (!(fileName && content)) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
id,
|
|
546
|
+
type,
|
|
547
|
+
title,
|
|
548
|
+
description,
|
|
549
|
+
version,
|
|
550
|
+
sourceUrl,
|
|
551
|
+
tags,
|
|
552
|
+
agent: { fileName, content },
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const snippetRaw = obj.snippet;
|
|
557
|
+
if (!isPlainObject(snippetRaw)) {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
const marker =
|
|
561
|
+
typeof snippetRaw.marker === "string" ? snippetRaw.marker.trim() : "";
|
|
562
|
+
const content =
|
|
563
|
+
typeof snippetRaw.content === "string" ? snippetRaw.content : "";
|
|
564
|
+
if (!(marker && content)) {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
id,
|
|
569
|
+
type,
|
|
570
|
+
title,
|
|
571
|
+
description,
|
|
572
|
+
version,
|
|
573
|
+
sourceUrl,
|
|
574
|
+
tags,
|
|
575
|
+
snippet: { marker, content },
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function parseManifest(source: IndexSource, raw: unknown): RemoteIndexManifest {
|
|
580
|
+
const base: RemoteIndexManifest = {
|
|
581
|
+
name: source.name,
|
|
582
|
+
url: source.url,
|
|
583
|
+
items: [],
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
if (Array.isArray(raw)) {
|
|
587
|
+
base.items = raw
|
|
588
|
+
.map(parseIndexItem)
|
|
589
|
+
.filter((v): v is RemoteIndexItem => !!v);
|
|
590
|
+
return base;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!isPlainObject(raw)) {
|
|
594
|
+
return base;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const obj = raw as Record<string, unknown>;
|
|
598
|
+
const updatedAt =
|
|
599
|
+
typeof obj.updatedAt === "string" ? obj.updatedAt : undefined;
|
|
600
|
+
const itemsRaw = Array.isArray(obj.items) ? obj.items : [];
|
|
601
|
+
return {
|
|
602
|
+
...base,
|
|
603
|
+
updatedAt,
|
|
604
|
+
items: itemsRaw
|
|
605
|
+
.map(parseIndexItem)
|
|
606
|
+
.filter((v): v is RemoteIndexItem => !!v),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function loadManifest(
|
|
611
|
+
source: IndexSource,
|
|
612
|
+
ctx: Required<Pick<RemoteCommandContext, "cwd">> & {
|
|
613
|
+
homeDir: string;
|
|
614
|
+
fetchJson: (url: string) => Promise<unknown>;
|
|
615
|
+
fetchText: (url: string) => Promise<string>;
|
|
616
|
+
},
|
|
617
|
+
hints: LoadManifestHints = {}
|
|
618
|
+
): Promise<RemoteIndexManifest> {
|
|
619
|
+
if (source.kind === "builtin") {
|
|
620
|
+
return BUILTIN_MANIFEST;
|
|
621
|
+
}
|
|
622
|
+
if (source.kind !== "manifest") {
|
|
623
|
+
return await loadProviderManifest({
|
|
624
|
+
source,
|
|
625
|
+
fetchJson: ctx.fetchJson,
|
|
626
|
+
fetchText: ctx.fetchText,
|
|
627
|
+
hints,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
const rawText = await ctx.fetchText(source.url);
|
|
631
|
+
if (source.integrity) {
|
|
632
|
+
assertManifestIntegrity({
|
|
633
|
+
sourceName: source.name,
|
|
634
|
+
sourceUrl: source.url,
|
|
635
|
+
integrity: source.integrity,
|
|
636
|
+
manifestText: rawText,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (source.signature) {
|
|
640
|
+
await assertManifestSignature({
|
|
641
|
+
sourceName: source.name,
|
|
642
|
+
sourceUrl: source.url,
|
|
643
|
+
signature: source.signature,
|
|
644
|
+
signatureKeys: source.signatureKeys,
|
|
645
|
+
manifestText: rawText,
|
|
646
|
+
cwd: ctx.cwd,
|
|
647
|
+
homeDir: ctx.homeDir,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
const raw = parseJsonLenient(rawText);
|
|
651
|
+
return parseManifest(source, raw);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function matchScore(item: RemoteIndexItem, query: string): number {
|
|
655
|
+
if (!query.trim()) {
|
|
656
|
+
return 1;
|
|
657
|
+
}
|
|
658
|
+
const haystack = [
|
|
659
|
+
item.id,
|
|
660
|
+
item.title ?? "",
|
|
661
|
+
item.description ?? "",
|
|
662
|
+
...(item.tags ?? []),
|
|
663
|
+
]
|
|
664
|
+
.join(" ")
|
|
665
|
+
.toLowerCase();
|
|
666
|
+
|
|
667
|
+
let score = 0;
|
|
668
|
+
for (const token of query
|
|
669
|
+
.toLowerCase()
|
|
670
|
+
.split(QUERY_SPLIT_RE)
|
|
671
|
+
.filter(Boolean)) {
|
|
672
|
+
if (haystack.includes(token)) {
|
|
673
|
+
score += 1;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return score;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function loadInstalledState(
|
|
680
|
+
rootDir: string
|
|
681
|
+
): Promise<InstalledRemoteState> {
|
|
682
|
+
const path = join(rootDir, "remote", "installed.json");
|
|
683
|
+
if (!(await fileExists(path))) {
|
|
684
|
+
return {
|
|
685
|
+
version: REMOTE_STATE_VERSION,
|
|
686
|
+
updatedAt: new Date(0).toISOString(),
|
|
687
|
+
items: [],
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const parsed = parseJsonLenient(await readFile(path, "utf8"));
|
|
692
|
+
if (!isPlainObject(parsed)) {
|
|
693
|
+
return {
|
|
694
|
+
version: REMOTE_STATE_VERSION,
|
|
695
|
+
updatedAt: new Date(0).toISOString(),
|
|
696
|
+
items: [],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const version =
|
|
700
|
+
typeof parsed.version === "number"
|
|
701
|
+
? parsed.version
|
|
702
|
+
: REMOTE_STATE_VERSION;
|
|
703
|
+
const updatedAt =
|
|
704
|
+
typeof parsed.updatedAt === "string"
|
|
705
|
+
? parsed.updatedAt
|
|
706
|
+
: new Date(0).toISOString();
|
|
707
|
+
const itemsRaw = Array.isArray(parsed.items) ? parsed.items : [];
|
|
708
|
+
const items: InstalledRemoteItem[] = [];
|
|
709
|
+
for (const raw of itemsRaw) {
|
|
710
|
+
if (!isPlainObject(raw)) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const ref = typeof raw.ref === "string" ? raw.ref : "";
|
|
714
|
+
const index = typeof raw.index === "string" ? raw.index : "";
|
|
715
|
+
const itemId = typeof raw.itemId === "string" ? raw.itemId : "";
|
|
716
|
+
const type = typeof raw.type === "string" ? raw.type : "";
|
|
717
|
+
const installedAs =
|
|
718
|
+
typeof raw.installedAs === "string" ? raw.installedAs : "";
|
|
719
|
+
const pathValue = typeof raw.path === "string" ? raw.path : "";
|
|
720
|
+
if (!(ref && index && itemId && installedAs && pathValue)) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (
|
|
724
|
+
type !== "skill" &&
|
|
725
|
+
type !== "mcp" &&
|
|
726
|
+
type !== "agent" &&
|
|
727
|
+
type !== "snippet"
|
|
728
|
+
) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
items.push({
|
|
732
|
+
ref,
|
|
733
|
+
index,
|
|
734
|
+
itemId,
|
|
735
|
+
type,
|
|
736
|
+
installedAs,
|
|
737
|
+
path: pathValue,
|
|
738
|
+
version: typeof raw.version === "string" ? raw.version : undefined,
|
|
739
|
+
sourceUrl:
|
|
740
|
+
typeof raw.sourceUrl === "string" ? raw.sourceUrl : undefined,
|
|
741
|
+
sourceTrustLevel: parseSourceTrustLevel(raw.sourceTrustLevel),
|
|
742
|
+
installedAt:
|
|
743
|
+
typeof raw.installedAt === "string"
|
|
744
|
+
? raw.installedAt
|
|
745
|
+
: new Date(0).toISOString(),
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
return { version, updatedAt, items };
|
|
749
|
+
} catch {
|
|
750
|
+
return {
|
|
751
|
+
version: REMOTE_STATE_VERSION,
|
|
752
|
+
updatedAt: new Date(0).toISOString(),
|
|
753
|
+
items: [],
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function saveInstalledState(
|
|
759
|
+
rootDir: string,
|
|
760
|
+
state: InstalledRemoteState
|
|
761
|
+
): Promise<void> {
|
|
762
|
+
const path = join(rootDir, "remote", "installed.json");
|
|
763
|
+
await mkdir(dirname(path), { recursive: true });
|
|
764
|
+
await Bun.write(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function loadCanonicalMcpContainer(rootDir: string): Promise<{
|
|
768
|
+
path: string;
|
|
769
|
+
parsed: Record<string, unknown>;
|
|
770
|
+
getServers: () => Record<string, unknown>;
|
|
771
|
+
setServers: (servers: Record<string, unknown>) => void;
|
|
772
|
+
}> {
|
|
773
|
+
const serversPath = join(rootDir, "mcp", "servers.json");
|
|
774
|
+
const mcpPath = join(rootDir, "mcp", "mcp.json");
|
|
775
|
+
|
|
776
|
+
let path = serversPath;
|
|
777
|
+
if (await fileExists(serversPath)) {
|
|
778
|
+
path = serversPath;
|
|
779
|
+
} else if (await fileExists(mcpPath)) {
|
|
780
|
+
path = mcpPath;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
let parsed: Record<string, unknown> = {};
|
|
784
|
+
if (await fileExists(path)) {
|
|
785
|
+
const raw = await readFile(path, "utf8");
|
|
786
|
+
const obj = parseJsonLenient(raw);
|
|
787
|
+
if (isPlainObject(obj)) {
|
|
788
|
+
parsed = { ...obj };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const getServers = () => {
|
|
793
|
+
if (isPlainObject(parsed.servers)) {
|
|
794
|
+
return parsed.servers as Record<string, unknown>;
|
|
795
|
+
}
|
|
796
|
+
if (isPlainObject(parsed.mcpServers)) {
|
|
797
|
+
return parsed.mcpServers as Record<string, unknown>;
|
|
798
|
+
}
|
|
799
|
+
if (
|
|
800
|
+
isPlainObject(parsed.mcp) &&
|
|
801
|
+
isPlainObject((parsed.mcp as Record<string, unknown>).servers)
|
|
802
|
+
) {
|
|
803
|
+
return (parsed.mcp as Record<string, unknown>).servers as Record<
|
|
804
|
+
string,
|
|
805
|
+
unknown
|
|
806
|
+
>;
|
|
807
|
+
}
|
|
808
|
+
parsed.servers = {};
|
|
809
|
+
return parsed.servers as Record<string, unknown>;
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const setServers = (servers: Record<string, unknown>) => {
|
|
813
|
+
if (isPlainObject(parsed.servers)) {
|
|
814
|
+
parsed.servers = servers;
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (isPlainObject(parsed.mcpServers)) {
|
|
818
|
+
parsed.mcpServers = servers;
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (
|
|
822
|
+
isPlainObject(parsed.mcp) &&
|
|
823
|
+
isPlainObject((parsed.mcp as Record<string, unknown>).servers)
|
|
824
|
+
) {
|
|
825
|
+
(parsed.mcp as Record<string, unknown>).servers = servers;
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
parsed.servers = servers;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
return { path, parsed, getServers, setServers };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function snippetMarkerToPath(rootDir: string, marker: string): string {
|
|
835
|
+
const parts = marker.split("/").filter(Boolean);
|
|
836
|
+
if (parts[0] === "global" && parts.length >= 2) {
|
|
837
|
+
return join(
|
|
838
|
+
rootDir,
|
|
839
|
+
"snippets",
|
|
840
|
+
"global",
|
|
841
|
+
`${parts.slice(1).join("/")}.md`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
if (parts.length >= 2) {
|
|
845
|
+
const project = parts[0] ?? "project";
|
|
846
|
+
const name = parts.slice(1).join("/");
|
|
847
|
+
return join(rootDir, "snippets", "projects", project, `${name}.md`);
|
|
848
|
+
}
|
|
849
|
+
return join(rootDir, "snippets", "global", `${marker}.md`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function assertInstallPath(path: string, parent: string): void {
|
|
853
|
+
if (!(isSafePathString(path) && isSubpath(parent, path))) {
|
|
854
|
+
throw new Error(`Refusing unsafe install path: ${path}`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function installSkillItem(args: {
|
|
859
|
+
item: RemoteSkillItem;
|
|
860
|
+
installAs?: string;
|
|
861
|
+
rootDir: string;
|
|
862
|
+
force: boolean;
|
|
863
|
+
dryRun: boolean;
|
|
864
|
+
}): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
|
|
865
|
+
const installedAs = (args.installAs ?? args.item.skill.name).trim();
|
|
866
|
+
if (!installedAs) {
|
|
867
|
+
throw new Error("Skill install target cannot be empty.");
|
|
868
|
+
}
|
|
869
|
+
const skillDir = join(args.rootDir, "skills", installedAs);
|
|
870
|
+
assertInstallPath(skillDir, join(args.rootDir, "skills"));
|
|
871
|
+
|
|
872
|
+
if ((await fileExists(skillDir)) && !args.force) {
|
|
873
|
+
throw new Error(
|
|
874
|
+
`Skill already exists: ${installedAs} (use --force to overwrite)`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const changedPaths: string[] = [];
|
|
879
|
+
const files = Object.entries(args.item.skill.files);
|
|
880
|
+
if (files.length === 0) {
|
|
881
|
+
throw new Error(`Skill template ${args.item.id} has no files.`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (!args.dryRun) {
|
|
885
|
+
if (args.force && (await fileExists(skillDir))) {
|
|
886
|
+
await rm(skillDir, { recursive: true, force: true });
|
|
887
|
+
}
|
|
888
|
+
await mkdir(skillDir, { recursive: true });
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
for (const [relPath, rawContent] of files) {
|
|
892
|
+
if (!isSafeRelativePath(relPath)) {
|
|
893
|
+
throw new Error(`Unsafe skill template file path: ${relPath}`);
|
|
894
|
+
}
|
|
895
|
+
const outPath = join(skillDir, relPath);
|
|
896
|
+
assertInstallPath(outPath, skillDir);
|
|
897
|
+
const content = renderTemplate(rawContent, { name: installedAs });
|
|
898
|
+
changedPaths.push(outPath);
|
|
899
|
+
if (!args.dryRun) {
|
|
900
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
901
|
+
await Bun.write(outPath, content);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return { installedAs, path: skillDir, changedPaths };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function installMcpItem(args: {
|
|
909
|
+
item: RemoteMcpItem;
|
|
910
|
+
installAs?: string;
|
|
911
|
+
rootDir: string;
|
|
912
|
+
force: boolean;
|
|
913
|
+
dryRun: boolean;
|
|
914
|
+
}): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
|
|
915
|
+
const installedAs = (args.installAs ?? args.item.mcp.name).trim();
|
|
916
|
+
if (!installedAs) {
|
|
917
|
+
throw new Error("MCP server name cannot be empty.");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const container = await loadCanonicalMcpContainer(args.rootDir);
|
|
921
|
+
const servers = { ...container.getServers() };
|
|
922
|
+
if (servers[installedAs] && !args.force) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
`MCP server already exists: ${installedAs} (use --force to overwrite)`
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const rendered = JSON.parse(
|
|
929
|
+
JSON.stringify(args.item.mcp.definition).replaceAll("{{name}}", installedAs)
|
|
930
|
+
) as Record<string, unknown>;
|
|
931
|
+
servers[installedAs] = rendered;
|
|
932
|
+
container.setServers(servers);
|
|
933
|
+
|
|
934
|
+
if (!args.dryRun) {
|
|
935
|
+
await mkdir(dirname(container.path), { recursive: true });
|
|
936
|
+
await Bun.write(
|
|
937
|
+
container.path,
|
|
938
|
+
`${JSON.stringify(container.parsed, null, 2)}\n`
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return {
|
|
943
|
+
installedAs,
|
|
944
|
+
path: container.path,
|
|
945
|
+
changedPaths: [container.path],
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function installAgentItem(args: {
|
|
950
|
+
item: RemoteAgentItem;
|
|
951
|
+
installAs?: string;
|
|
952
|
+
rootDir: string;
|
|
953
|
+
force: boolean;
|
|
954
|
+
dryRun: boolean;
|
|
955
|
+
}): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
|
|
956
|
+
const fileName = (args.installAs ?? args.item.agent.fileName).trim();
|
|
957
|
+
if (!fileName) {
|
|
958
|
+
throw new Error("Agent instruction file name cannot be empty.");
|
|
959
|
+
}
|
|
960
|
+
if (!isSafeRelativePath(fileName)) {
|
|
961
|
+
throw new Error(`Unsafe agent instruction file name: ${fileName}`);
|
|
962
|
+
}
|
|
963
|
+
const filePath = join(args.rootDir, "agents", fileName);
|
|
964
|
+
assertInstallPath(filePath, join(args.rootDir, "agents"));
|
|
965
|
+
|
|
966
|
+
if ((await fileExists(filePath)) && !args.force) {
|
|
967
|
+
throw new Error(
|
|
968
|
+
`Agent instruction already exists: ${fileName} (use --force to overwrite)`
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (!args.dryRun) {
|
|
973
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
974
|
+
await Bun.write(
|
|
975
|
+
filePath,
|
|
976
|
+
renderTemplate(args.item.agent.content, {
|
|
977
|
+
name: fileName.replace(MD_EXT_RE, ""),
|
|
978
|
+
})
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
return { installedAs: fileName, path: filePath, changedPaths: [filePath] };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function installSnippetItem(args: {
|
|
985
|
+
item: RemoteSnippetItem;
|
|
986
|
+
installAs?: string;
|
|
987
|
+
rootDir: string;
|
|
988
|
+
force: boolean;
|
|
989
|
+
dryRun: boolean;
|
|
990
|
+
}): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
|
|
991
|
+
const marker = (args.installAs ?? args.item.snippet.marker).trim();
|
|
992
|
+
const markerErr = validateSnippetMarkerName(marker);
|
|
993
|
+
if (markerErr) {
|
|
994
|
+
throw new Error(`Invalid snippet marker "${marker}": ${markerErr}`);
|
|
995
|
+
}
|
|
996
|
+
const snippetPath = snippetMarkerToPath(args.rootDir, marker);
|
|
997
|
+
assertInstallPath(snippetPath, join(args.rootDir, "snippets"));
|
|
998
|
+
if ((await fileExists(snippetPath)) && !args.force) {
|
|
999
|
+
throw new Error(
|
|
1000
|
+
`Snippet already exists: ${marker} (use --force to overwrite)`
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
if (!args.dryRun) {
|
|
1004
|
+
await mkdir(dirname(snippetPath), { recursive: true });
|
|
1005
|
+
await Bun.write(
|
|
1006
|
+
snippetPath,
|
|
1007
|
+
renderTemplate(args.item.snippet.content, { name: marker })
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
return {
|
|
1011
|
+
installedAs: marker,
|
|
1012
|
+
path: snippetPath,
|
|
1013
|
+
changedPaths: [snippetPath],
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function installParsedItem(args: {
|
|
1018
|
+
parsedRef: { index: string; itemId: string };
|
|
1019
|
+
item: RemoteIndexItem;
|
|
1020
|
+
sourceTrustLevel: SourceTrustLevel;
|
|
1021
|
+
installAs?: string;
|
|
1022
|
+
dryRun: boolean;
|
|
1023
|
+
force: boolean;
|
|
1024
|
+
homeDir: string;
|
|
1025
|
+
rootDir: string;
|
|
1026
|
+
now?: () => Date;
|
|
1027
|
+
}): Promise<InstallResult> {
|
|
1028
|
+
let writeResult: {
|
|
1029
|
+
installedAs: string;
|
|
1030
|
+
path: string;
|
|
1031
|
+
changedPaths: string[];
|
|
1032
|
+
} | null = null;
|
|
1033
|
+
|
|
1034
|
+
if (args.item.type === "skill") {
|
|
1035
|
+
writeResult = await installSkillItem({
|
|
1036
|
+
item: args.item,
|
|
1037
|
+
installAs: args.installAs,
|
|
1038
|
+
rootDir: args.rootDir,
|
|
1039
|
+
force: args.force,
|
|
1040
|
+
dryRun: args.dryRun,
|
|
1041
|
+
});
|
|
1042
|
+
} else if (args.item.type === "mcp") {
|
|
1043
|
+
writeResult = await installMcpItem({
|
|
1044
|
+
item: args.item,
|
|
1045
|
+
installAs: args.installAs,
|
|
1046
|
+
rootDir: args.rootDir,
|
|
1047
|
+
force: args.force,
|
|
1048
|
+
dryRun: args.dryRun,
|
|
1049
|
+
});
|
|
1050
|
+
} else if (args.item.type === "agent") {
|
|
1051
|
+
writeResult = await installAgentItem({
|
|
1052
|
+
item: args.item,
|
|
1053
|
+
installAs: args.installAs,
|
|
1054
|
+
rootDir: args.rootDir,
|
|
1055
|
+
force: args.force,
|
|
1056
|
+
dryRun: args.dryRun,
|
|
1057
|
+
});
|
|
1058
|
+
} else {
|
|
1059
|
+
writeResult = await installSnippetItem({
|
|
1060
|
+
item: args.item,
|
|
1061
|
+
installAs: args.installAs,
|
|
1062
|
+
rootDir: args.rootDir,
|
|
1063
|
+
force: args.force,
|
|
1064
|
+
dryRun: args.dryRun,
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const result: InstallResult = {
|
|
1069
|
+
ref: `${args.parsedRef.index}:${args.item.id}`,
|
|
1070
|
+
type: args.item.type,
|
|
1071
|
+
installedAs: writeResult.installedAs,
|
|
1072
|
+
path: writeResult.path,
|
|
1073
|
+
sourceTrustLevel: args.sourceTrustLevel,
|
|
1074
|
+
dryRun: args.dryRun,
|
|
1075
|
+
changedPaths: writeResult.changedPaths,
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
if (args.dryRun) {
|
|
1079
|
+
return result;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const state = await loadInstalledState(args.rootDir);
|
|
1083
|
+
const next: InstalledRemoteItem = {
|
|
1084
|
+
ref: result.ref,
|
|
1085
|
+
index: args.parsedRef.index,
|
|
1086
|
+
itemId: args.item.id,
|
|
1087
|
+
type: args.item.type,
|
|
1088
|
+
installedAs: result.installedAs,
|
|
1089
|
+
path: result.path,
|
|
1090
|
+
version: args.item.version,
|
|
1091
|
+
sourceUrl: args.item.sourceUrl,
|
|
1092
|
+
sourceTrustLevel: args.sourceTrustLevel,
|
|
1093
|
+
installedAt: nowIso(args.now),
|
|
1094
|
+
};
|
|
1095
|
+
const dedup = state.items.filter(
|
|
1096
|
+
(existing) =>
|
|
1097
|
+
!(
|
|
1098
|
+
existing.ref === next.ref &&
|
|
1099
|
+
existing.installedAs === next.installedAs &&
|
|
1100
|
+
existing.type === next.type
|
|
1101
|
+
)
|
|
1102
|
+
);
|
|
1103
|
+
dedup.push(next);
|
|
1104
|
+
await saveInstalledState(args.rootDir, {
|
|
1105
|
+
version: REMOTE_STATE_VERSION,
|
|
1106
|
+
updatedAt: nowIso(args.now),
|
|
1107
|
+
items: dedup.sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
1108
|
+
});
|
|
1109
|
+
await buildIndex({ rootDir: args.rootDir, force: false });
|
|
1110
|
+
return result;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
async function resolveIndexSourcesAndManifests(args: {
|
|
1114
|
+
homeDir: string;
|
|
1115
|
+
cwd: string;
|
|
1116
|
+
fetchJson: (url: string) => Promise<unknown>;
|
|
1117
|
+
fetchText: (url: string) => Promise<string>;
|
|
1118
|
+
onlyIndex?: string;
|
|
1119
|
+
hints?: LoadManifestHints;
|
|
1120
|
+
throwOnSourceError?: boolean;
|
|
1121
|
+
}): Promise<Map<string, RemoteIndexManifest>> {
|
|
1122
|
+
const sources = await readIndexSources(args.homeDir, args.cwd);
|
|
1123
|
+
if (
|
|
1124
|
+
args.onlyIndex &&
|
|
1125
|
+
!sources.some((source) => source.name === args.onlyIndex)
|
|
1126
|
+
) {
|
|
1127
|
+
const known = resolveKnownIndexSource(args.onlyIndex);
|
|
1128
|
+
if (known) {
|
|
1129
|
+
sources.push(known);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const filtered = args.onlyIndex
|
|
1133
|
+
? sources.filter((source) => source.name === args.onlyIndex)
|
|
1134
|
+
: sources;
|
|
1135
|
+
const manifests = new Map<string, RemoteIndexManifest>();
|
|
1136
|
+
for (const source of filtered) {
|
|
1137
|
+
try {
|
|
1138
|
+
const manifest = await loadManifest(
|
|
1139
|
+
source,
|
|
1140
|
+
{
|
|
1141
|
+
homeDir: args.homeDir,
|
|
1142
|
+
cwd: args.cwd,
|
|
1143
|
+
fetchJson: args.fetchJson,
|
|
1144
|
+
fetchText: args.fetchText,
|
|
1145
|
+
},
|
|
1146
|
+
args.hints
|
|
1147
|
+
);
|
|
1148
|
+
manifests.set(source.name, manifest);
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
if (args.throwOnSourceError) {
|
|
1151
|
+
throw err;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return manifests;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
export async function searchRemoteItems(args: {
|
|
1159
|
+
query: string;
|
|
1160
|
+
limit?: number;
|
|
1161
|
+
index?: string;
|
|
1162
|
+
homeDir?: string;
|
|
1163
|
+
cwd?: string;
|
|
1164
|
+
fetchJson?: (url: string) => Promise<unknown>;
|
|
1165
|
+
fetchText?: (url: string) => Promise<string>;
|
|
1166
|
+
}): Promise<SearchResult[]> {
|
|
1167
|
+
const home = args.homeDir ?? homedir();
|
|
1168
|
+
const cwd = args.cwd ?? process.cwd();
|
|
1169
|
+
const fetchJson =
|
|
1170
|
+
args.fetchJson ?? (async (url: string) => await defaultFetchJson(url, cwd));
|
|
1171
|
+
const fetchText =
|
|
1172
|
+
args.fetchText ?? (async (url: string) => await defaultFetchText(url, cwd));
|
|
1173
|
+
const manifests = await resolveIndexSourcesAndManifests({
|
|
1174
|
+
homeDir: home,
|
|
1175
|
+
cwd,
|
|
1176
|
+
fetchJson,
|
|
1177
|
+
fetchText,
|
|
1178
|
+
onlyIndex: args.index,
|
|
1179
|
+
hints: { query: args.query },
|
|
1180
|
+
throwOnSourceError: Boolean(args.index),
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
const rows: SearchResult[] = [];
|
|
1184
|
+
for (const [index, manifest] of manifests.entries()) {
|
|
1185
|
+
for (const item of manifest.items) {
|
|
1186
|
+
const score = matchScore(item, args.query);
|
|
1187
|
+
if (score <= 0) {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
rows.push({ index, item, score });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
rows.sort((a, b) => {
|
|
1195
|
+
if (b.score !== a.score) {
|
|
1196
|
+
return b.score - a.score;
|
|
1197
|
+
}
|
|
1198
|
+
if (a.index !== b.index) {
|
|
1199
|
+
return a.index.localeCompare(b.index);
|
|
1200
|
+
}
|
|
1201
|
+
return a.item.id.localeCompare(b.item.id);
|
|
1202
|
+
});
|
|
1203
|
+
const limit = args.limit && args.limit > 0 ? args.limit : 50;
|
|
1204
|
+
return rows.slice(0, limit);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
export async function installRemoteItem(args: {
|
|
1208
|
+
ref: string;
|
|
1209
|
+
as?: string;
|
|
1210
|
+
dryRun?: boolean;
|
|
1211
|
+
force?: boolean;
|
|
1212
|
+
strictSourceTrust?: boolean;
|
|
1213
|
+
homeDir?: string;
|
|
1214
|
+
rootDir?: string;
|
|
1215
|
+
cwd?: string;
|
|
1216
|
+
now?: () => Date;
|
|
1217
|
+
fetchJson?: (url: string) => Promise<unknown>;
|
|
1218
|
+
fetchText?: (url: string) => Promise<string>;
|
|
1219
|
+
}): Promise<InstallResult> {
|
|
1220
|
+
const parsedRef = parseRef(args.ref);
|
|
1221
|
+
if (!parsedRef) {
|
|
1222
|
+
throw new Error(`Invalid ref "${args.ref}". Use <index>:<item>.`);
|
|
1223
|
+
}
|
|
1224
|
+
const home = args.homeDir ?? homedir();
|
|
1225
|
+
const root = args.rootDir ?? facultRootDir(home);
|
|
1226
|
+
const cwd = args.cwd ?? process.cwd();
|
|
1227
|
+
const strictSourceTrust = Boolean(args.strictSourceTrust);
|
|
1228
|
+
const fetchJson =
|
|
1229
|
+
args.fetchJson ?? (async (url: string) => await defaultFetchJson(url, cwd));
|
|
1230
|
+
const fetchText =
|
|
1231
|
+
args.fetchText ?? (async (url: string) => await defaultFetchText(url, cwd));
|
|
1232
|
+
const manifests = await resolveIndexSourcesAndManifests({
|
|
1233
|
+
homeDir: home,
|
|
1234
|
+
cwd,
|
|
1235
|
+
fetchJson,
|
|
1236
|
+
fetchText,
|
|
1237
|
+
onlyIndex: parsedRef.index,
|
|
1238
|
+
hints: { itemId: parsedRef.itemId },
|
|
1239
|
+
throwOnSourceError: true,
|
|
1240
|
+
});
|
|
1241
|
+
const manifest = manifests.get(parsedRef.index);
|
|
1242
|
+
if (!manifest) {
|
|
1243
|
+
throw new Error(`Index not found: ${parsedRef.index}`);
|
|
1244
|
+
}
|
|
1245
|
+
const item = manifest.items.find(
|
|
1246
|
+
(candidate) => candidate.id === parsedRef.itemId
|
|
1247
|
+
);
|
|
1248
|
+
if (!item) {
|
|
1249
|
+
throw new Error(`Item not found: ${args.ref}`);
|
|
1250
|
+
}
|
|
1251
|
+
const trustState = await loadSourceTrustState({ homeDir: home });
|
|
1252
|
+
const sourceTrustLevel = assertSourceAllowed({
|
|
1253
|
+
sourceName: parsedRef.index,
|
|
1254
|
+
trustState,
|
|
1255
|
+
strictSourceTrust,
|
|
1256
|
+
});
|
|
1257
|
+
return await installParsedItem({
|
|
1258
|
+
parsedRef,
|
|
1259
|
+
item,
|
|
1260
|
+
sourceTrustLevel,
|
|
1261
|
+
installAs: args.as,
|
|
1262
|
+
dryRun: Boolean(args.dryRun),
|
|
1263
|
+
force: Boolean(args.force),
|
|
1264
|
+
homeDir: home,
|
|
1265
|
+
rootDir: root,
|
|
1266
|
+
now: args.now,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
export async function checkRemoteUpdates(args?: {
|
|
1271
|
+
apply?: boolean;
|
|
1272
|
+
force?: boolean;
|
|
1273
|
+
strictSourceTrust?: boolean;
|
|
1274
|
+
homeDir?: string;
|
|
1275
|
+
rootDir?: string;
|
|
1276
|
+
cwd?: string;
|
|
1277
|
+
now?: () => Date;
|
|
1278
|
+
fetchJson?: (url: string) => Promise<unknown>;
|
|
1279
|
+
fetchText?: (url: string) => Promise<string>;
|
|
1280
|
+
}): Promise<UpdateReport> {
|
|
1281
|
+
const home = args?.homeDir ?? homedir();
|
|
1282
|
+
const root = args?.rootDir ?? facultRootDir(home);
|
|
1283
|
+
const cwd = args?.cwd ?? process.cwd();
|
|
1284
|
+
const fetchJson =
|
|
1285
|
+
args?.fetchJson ??
|
|
1286
|
+
(async (url: string) => await defaultFetchJson(url, cwd));
|
|
1287
|
+
const fetchText =
|
|
1288
|
+
args?.fetchText ??
|
|
1289
|
+
(async (url: string) => await defaultFetchText(url, cwd));
|
|
1290
|
+
const strictSourceTrust = Boolean(args?.strictSourceTrust);
|
|
1291
|
+
const sourceTrustState = await loadSourceTrustState({ homeDir: home });
|
|
1292
|
+
|
|
1293
|
+
const installed = await loadInstalledState(root);
|
|
1294
|
+
const checks: UpdateCheckResult[] = [];
|
|
1295
|
+
const applied: InstallResult[] = [];
|
|
1296
|
+
if (!installed.items.length) {
|
|
1297
|
+
return { checkedAt: nowIso(args?.now), checks, applied };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const configuredSources = await readIndexSources(home, cwd);
|
|
1301
|
+
const sourceByName = new Map<string, IndexSource>();
|
|
1302
|
+
for (const source of configuredSources) {
|
|
1303
|
+
sourceByName.set(source.name, source);
|
|
1304
|
+
}
|
|
1305
|
+
for (const item of installed.items) {
|
|
1306
|
+
if (sourceByName.has(item.index)) {
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
const known = resolveKnownIndexSource(item.index);
|
|
1310
|
+
if (known) {
|
|
1311
|
+
sourceByName.set(known.name, known);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
const manifestCache = new Map<string, RemoteIndexManifest>();
|
|
1315
|
+
|
|
1316
|
+
for (const entry of installed.items) {
|
|
1317
|
+
const trust = evaluateSourceTrust({
|
|
1318
|
+
sourceName: entry.index,
|
|
1319
|
+
trustState: sourceTrustState,
|
|
1320
|
+
});
|
|
1321
|
+
if (trust.level === "blocked") {
|
|
1322
|
+
checks.push({
|
|
1323
|
+
installed: entry,
|
|
1324
|
+
status: "blocked-source",
|
|
1325
|
+
});
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
if (strictSourceTrust && trust.level === "review") {
|
|
1329
|
+
checks.push({
|
|
1330
|
+
installed: entry,
|
|
1331
|
+
status: "review-source",
|
|
1332
|
+
});
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const source = sourceByName.get(entry.index);
|
|
1337
|
+
if (!source) {
|
|
1338
|
+
checks.push({ installed: entry, status: "missing-index" });
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
const cacheKey = `${source.name}:${entry.itemId}`;
|
|
1342
|
+
let manifest = manifestCache.get(cacheKey);
|
|
1343
|
+
if (!manifest) {
|
|
1344
|
+
try {
|
|
1345
|
+
manifest = await loadManifest(
|
|
1346
|
+
source,
|
|
1347
|
+
{ homeDir: home, cwd, fetchJson, fetchText },
|
|
1348
|
+
{ itemId: entry.itemId }
|
|
1349
|
+
);
|
|
1350
|
+
} catch {
|
|
1351
|
+
checks.push({ installed: entry, status: "missing-index" });
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
manifestCache.set(cacheKey, manifest);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const item = manifest.items.find(
|
|
1358
|
+
(candidate) => candidate.id === entry.itemId
|
|
1359
|
+
);
|
|
1360
|
+
if (!item) {
|
|
1361
|
+
checks.push({ installed: entry, status: "missing-item" });
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
const latestVersion = item.version;
|
|
1365
|
+
const currentVersion = entry.version;
|
|
1366
|
+
if (!(latestVersion && currentVersion)) {
|
|
1367
|
+
checks.push({
|
|
1368
|
+
installed: entry,
|
|
1369
|
+
status: "up-to-date",
|
|
1370
|
+
latestVersion,
|
|
1371
|
+
currentVersion,
|
|
1372
|
+
});
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
const cmp = compareVersions(currentVersion, latestVersion);
|
|
1376
|
+
if (cmp < 0) {
|
|
1377
|
+
checks.push({
|
|
1378
|
+
installed: entry,
|
|
1379
|
+
status: "outdated",
|
|
1380
|
+
latestVersion,
|
|
1381
|
+
currentVersion,
|
|
1382
|
+
});
|
|
1383
|
+
if (args?.apply) {
|
|
1384
|
+
const next = await installRemoteItem({
|
|
1385
|
+
ref: entry.ref,
|
|
1386
|
+
as: entry.installedAs,
|
|
1387
|
+
dryRun: false,
|
|
1388
|
+
force: args.force ?? true,
|
|
1389
|
+
strictSourceTrust,
|
|
1390
|
+
homeDir: home,
|
|
1391
|
+
rootDir: root,
|
|
1392
|
+
cwd,
|
|
1393
|
+
now: args.now,
|
|
1394
|
+
fetchJson,
|
|
1395
|
+
fetchText,
|
|
1396
|
+
});
|
|
1397
|
+
applied.push(next);
|
|
1398
|
+
}
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
checks.push({
|
|
1402
|
+
installed: entry,
|
|
1403
|
+
status: "up-to-date",
|
|
1404
|
+
latestVersion,
|
|
1405
|
+
currentVersion,
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return { checkedAt: nowIso(args?.now), checks, applied };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
async function verifySource(args: {
|
|
1413
|
+
sourceName: string;
|
|
1414
|
+
homeDir?: string;
|
|
1415
|
+
cwd?: string;
|
|
1416
|
+
now?: () => Date;
|
|
1417
|
+
fetchJson?: (url: string) => Promise<unknown>;
|
|
1418
|
+
fetchText?: (url: string) => Promise<string>;
|
|
1419
|
+
}): Promise<VerifySourceReport> {
|
|
1420
|
+
const home = args.homeDir ?? homedir();
|
|
1421
|
+
const cwd = args.cwd ?? process.cwd();
|
|
1422
|
+
const fetchJson =
|
|
1423
|
+
args.fetchJson ?? (async (url: string) => await defaultFetchJson(url, cwd));
|
|
1424
|
+
const fetchText =
|
|
1425
|
+
args.fetchText ?? (async (url: string) => await defaultFetchText(url, cwd));
|
|
1426
|
+
const configured = await readIndexSources(home, cwd);
|
|
1427
|
+
const source =
|
|
1428
|
+
configured.find((candidate) => candidate.name === args.sourceName) ??
|
|
1429
|
+
resolveKnownIndexSource(args.sourceName);
|
|
1430
|
+
if (!source) {
|
|
1431
|
+
throw new Error(`Source not found: ${args.sourceName}`);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const trustState = await loadSourceTrustState({ homeDir: home });
|
|
1435
|
+
const trust = evaluateSourceTrust({
|
|
1436
|
+
sourceName: source.name,
|
|
1437
|
+
trustState,
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
const report: VerifySourceReport = {
|
|
1441
|
+
checkedAt: nowIso(args.now),
|
|
1442
|
+
source: {
|
|
1443
|
+
name: source.name,
|
|
1444
|
+
url: source.url,
|
|
1445
|
+
kind: source.kind,
|
|
1446
|
+
},
|
|
1447
|
+
trust,
|
|
1448
|
+
checks: {
|
|
1449
|
+
fetch: "not-applicable",
|
|
1450
|
+
parse: "not-applicable",
|
|
1451
|
+
integrity: "not-applicable",
|
|
1452
|
+
signature: "not-applicable",
|
|
1453
|
+
items: 0,
|
|
1454
|
+
},
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
try {
|
|
1458
|
+
if (source.kind === "builtin") {
|
|
1459
|
+
report.checks.parse = "passed";
|
|
1460
|
+
report.checks.items = BUILTIN_MANIFEST.items.length;
|
|
1461
|
+
return report;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (source.kind === "manifest") {
|
|
1465
|
+
const rawText = await fetchText(source.url);
|
|
1466
|
+
report.checks.fetch = "passed";
|
|
1467
|
+
|
|
1468
|
+
if (source.integrity) {
|
|
1469
|
+
assertManifestIntegrity({
|
|
1470
|
+
sourceName: source.name,
|
|
1471
|
+
sourceUrl: source.url,
|
|
1472
|
+
integrity: source.integrity,
|
|
1473
|
+
manifestText: rawText,
|
|
1474
|
+
});
|
|
1475
|
+
report.checks.integrity = "passed";
|
|
1476
|
+
} else {
|
|
1477
|
+
report.checks.integrity = "not-configured";
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (source.signature) {
|
|
1481
|
+
await assertManifestSignature({
|
|
1482
|
+
sourceName: source.name,
|
|
1483
|
+
sourceUrl: source.url,
|
|
1484
|
+
signature: source.signature,
|
|
1485
|
+
signatureKeys: source.signatureKeys,
|
|
1486
|
+
manifestText: rawText,
|
|
1487
|
+
cwd,
|
|
1488
|
+
homeDir: home,
|
|
1489
|
+
});
|
|
1490
|
+
report.checks.signature = "passed";
|
|
1491
|
+
} else {
|
|
1492
|
+
report.checks.signature = "not-configured";
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const parsed = parseJsonLenient(rawText);
|
|
1496
|
+
const manifest = parseManifest(source, parsed);
|
|
1497
|
+
report.checks.parse = "passed";
|
|
1498
|
+
report.checks.items = manifest.items.length;
|
|
1499
|
+
return report;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const manifest = await loadProviderManifest({
|
|
1503
|
+
source,
|
|
1504
|
+
fetchJson,
|
|
1505
|
+
fetchText,
|
|
1506
|
+
hints: {},
|
|
1507
|
+
});
|
|
1508
|
+
report.checks.fetch = "passed";
|
|
1509
|
+
report.checks.parse = "passed";
|
|
1510
|
+
report.checks.items = manifest.items.length;
|
|
1511
|
+
return report;
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1514
|
+
report.error = message;
|
|
1515
|
+
if (report.checks.fetch === "not-applicable") {
|
|
1516
|
+
report.checks.fetch = "failed";
|
|
1517
|
+
}
|
|
1518
|
+
if (report.checks.parse === "not-applicable") {
|
|
1519
|
+
report.checks.parse = "failed";
|
|
1520
|
+
}
|
|
1521
|
+
if (
|
|
1522
|
+
report.checks.integrity === "not-applicable" &&
|
|
1523
|
+
source.kind === "manifest"
|
|
1524
|
+
) {
|
|
1525
|
+
report.checks.integrity = source.integrity ? "failed" : "not-configured";
|
|
1526
|
+
}
|
|
1527
|
+
if (
|
|
1528
|
+
report.checks.signature === "not-applicable" &&
|
|
1529
|
+
source.kind === "manifest"
|
|
1530
|
+
) {
|
|
1531
|
+
report.checks.signature = source.signature ? "failed" : "not-configured";
|
|
1532
|
+
}
|
|
1533
|
+
return report;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function printSearchHelp() {
|
|
1538
|
+
console.log(`facult search — search configured remote indices
|
|
1539
|
+
|
|
1540
|
+
Usage:
|
|
1541
|
+
facult search <query> [--index <name>] [--limit <n>] [--json]
|
|
1542
|
+
|
|
1543
|
+
Notes:
|
|
1544
|
+
- Builtin index "${BUILTIN_INDEX_NAME}" is always available.
|
|
1545
|
+
- Builtin provider aliases: "${SMITHERY_INDEX_NAME}", "${GLAMA_INDEX_NAME}", "${SKILLS_SH_INDEX_NAME}", "${CLAWHUB_INDEX_NAME}".
|
|
1546
|
+
- Optional custom indices can be configured in ~/.facult/indices.json.
|
|
1547
|
+
`);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function printInstallHelp() {
|
|
1551
|
+
console.log(`facult install — install an item from a remote index
|
|
1552
|
+
|
|
1553
|
+
Usage:
|
|
1554
|
+
facult install <index:item> [--as <name>] [--dry-run] [--force] [--strict-source-trust] [--json]
|
|
1555
|
+
|
|
1556
|
+
Examples:
|
|
1557
|
+
facult install facult:skill-template --as my-skill
|
|
1558
|
+
facult install facult:mcp-stdio-template --as github
|
|
1559
|
+
facult install smithery:github
|
|
1560
|
+
facult install glama:systeminit/si --as system-initiative
|
|
1561
|
+
facult install skills.sh:owner/repo --as my-skill
|
|
1562
|
+
facult install clawhub:my-skill
|
|
1563
|
+
`);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function printUpdateHelp() {
|
|
1567
|
+
console.log(`facult update — check for updates to remotely installed items
|
|
1568
|
+
|
|
1569
|
+
Usage:
|
|
1570
|
+
facult update [--apply] [--strict-source-trust] [--json]
|
|
1571
|
+
|
|
1572
|
+
Options:
|
|
1573
|
+
--apply Install available updates
|
|
1574
|
+
--strict-source-trust Block review-level sources unless explicitly trusted
|
|
1575
|
+
`);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function printTemplatesHelp() {
|
|
1579
|
+
console.log(`facult templates — DX-first local scaffolding for skills/instructions/MCP/snippets
|
|
1580
|
+
|
|
1581
|
+
Usage:
|
|
1582
|
+
facult templates list [--json]
|
|
1583
|
+
facult templates init skill <name> [--force] [--dry-run]
|
|
1584
|
+
facult templates init mcp <name> [--force] [--dry-run]
|
|
1585
|
+
facult templates init snippet <marker> [--force] [--dry-run]
|
|
1586
|
+
facult templates init agents [--force] [--dry-run]
|
|
1587
|
+
facult templates init claude [--force] [--dry-run]
|
|
1588
|
+
|
|
1589
|
+
Notes:
|
|
1590
|
+
- Templates are powered by the builtin remote index (${BUILTIN_INDEX_NAME}).
|
|
1591
|
+
`);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function printVerifySourceHelp() {
|
|
1595
|
+
console.log(`facult verify-source — verify source trust/integrity/signature status
|
|
1596
|
+
|
|
1597
|
+
Usage:
|
|
1598
|
+
facult verify-source <name> [--json]
|
|
1599
|
+
|
|
1600
|
+
Examples:
|
|
1601
|
+
facult verify-source facult
|
|
1602
|
+
facult verify-source smithery
|
|
1603
|
+
facult verify-source local-index --json
|
|
1604
|
+
`);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function parseLongFlag(argv: string[], flag: string): string | null {
|
|
1608
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1609
|
+
const arg = argv[i];
|
|
1610
|
+
if (!arg) {
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (arg === flag) {
|
|
1614
|
+
return argv[i + 1] ?? null;
|
|
1615
|
+
}
|
|
1616
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
1617
|
+
return arg.slice(flag.length + 1);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
export async function sourcesCommand(
|
|
1624
|
+
argv: string[],
|
|
1625
|
+
ctx: RemoteCommandContext = {}
|
|
1626
|
+
) {
|
|
1627
|
+
await runSourcesCommand({
|
|
1628
|
+
argv,
|
|
1629
|
+
ctx: {
|
|
1630
|
+
homeDir: ctx.homeDir,
|
|
1631
|
+
cwd: ctx.cwd,
|
|
1632
|
+
now: ctx.now,
|
|
1633
|
+
},
|
|
1634
|
+
readIndexSources,
|
|
1635
|
+
builtinIndexName: BUILTIN_INDEX_NAME,
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
export async function searchCommand(
|
|
1640
|
+
argv: string[],
|
|
1641
|
+
ctx: RemoteCommandContext = {}
|
|
1642
|
+
) {
|
|
1643
|
+
if (
|
|
1644
|
+
!argv.length ||
|
|
1645
|
+
argv.includes("--help") ||
|
|
1646
|
+
argv.includes("-h") ||
|
|
1647
|
+
argv[0] === "help"
|
|
1648
|
+
) {
|
|
1649
|
+
printSearchHelp();
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const query = argv.find((arg) => arg && !arg.startsWith("-"));
|
|
1653
|
+
if (!query) {
|
|
1654
|
+
console.error("search requires a query");
|
|
1655
|
+
process.exitCode = 1;
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const index = parseLongFlag(argv, "--index") ?? undefined;
|
|
1659
|
+
const limitRaw = parseLongFlag(argv, "--limit");
|
|
1660
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
|
|
1661
|
+
if (limitRaw && (!Number.isFinite(limit) || (limit ?? 0) <= 0)) {
|
|
1662
|
+
console.error(`Invalid --limit value: ${limitRaw}`);
|
|
1663
|
+
process.exitCode = 1;
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const json = argv.includes("--json");
|
|
1667
|
+
|
|
1668
|
+
try {
|
|
1669
|
+
const results = await searchRemoteItems({
|
|
1670
|
+
query,
|
|
1671
|
+
index,
|
|
1672
|
+
limit,
|
|
1673
|
+
homeDir: ctx.homeDir,
|
|
1674
|
+
cwd: ctx.cwd,
|
|
1675
|
+
fetchJson: ctx.fetchJson,
|
|
1676
|
+
fetchText: ctx.fetchText,
|
|
1677
|
+
});
|
|
1678
|
+
if (json) {
|
|
1679
|
+
console.log(JSON.stringify(results, null, 2));
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (!results.length) {
|
|
1683
|
+
console.log("(no results)");
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
for (const row of results) {
|
|
1687
|
+
const version = row.item.version ?? "-";
|
|
1688
|
+
const title = row.item.title ?? row.item.description ?? "";
|
|
1689
|
+
console.log(
|
|
1690
|
+
`${row.index}:${row.item.id}\t${row.item.type}\t${version}\t${title}`
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1695
|
+
process.exitCode = 1;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
export async function installCommand(
|
|
1700
|
+
argv: string[],
|
|
1701
|
+
ctx: RemoteCommandContext = {}
|
|
1702
|
+
) {
|
|
1703
|
+
if (
|
|
1704
|
+
!argv.length ||
|
|
1705
|
+
argv.includes("--help") ||
|
|
1706
|
+
argv.includes("-h") ||
|
|
1707
|
+
argv[0] === "help"
|
|
1708
|
+
) {
|
|
1709
|
+
printInstallHelp();
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const ref = argv.find((arg) => arg && !arg.startsWith("-"));
|
|
1713
|
+
if (!ref) {
|
|
1714
|
+
console.error("install requires a ref like <index:item>");
|
|
1715
|
+
process.exitCode = 1;
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const as = parseLongFlag(argv, "--as") ?? undefined;
|
|
1719
|
+
const dryRun = argv.includes("--dry-run");
|
|
1720
|
+
const force = argv.includes("--force");
|
|
1721
|
+
const strictSourceTrust =
|
|
1722
|
+
argv.includes("--strict-source-trust") || Boolean(ctx.strictSourceTrust);
|
|
1723
|
+
const json = argv.includes("--json");
|
|
1724
|
+
try {
|
|
1725
|
+
const result = await installRemoteItem({
|
|
1726
|
+
ref,
|
|
1727
|
+
as,
|
|
1728
|
+
dryRun,
|
|
1729
|
+
force,
|
|
1730
|
+
strictSourceTrust,
|
|
1731
|
+
homeDir: ctx.homeDir,
|
|
1732
|
+
rootDir: ctx.rootDir,
|
|
1733
|
+
cwd: ctx.cwd,
|
|
1734
|
+
fetchJson: ctx.fetchJson,
|
|
1735
|
+
fetchText: ctx.fetchText,
|
|
1736
|
+
now: ctx.now,
|
|
1737
|
+
});
|
|
1738
|
+
if (json) {
|
|
1739
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
const action = dryRun ? "Would install" : "Installed";
|
|
1743
|
+
console.log(`${action} ${result.ref} as ${result.installedAs}`);
|
|
1744
|
+
if (result.sourceTrustLevel === "review" && !strictSourceTrust) {
|
|
1745
|
+
console.log(
|
|
1746
|
+
" ! source policy: review (use --strict-source-trust to enforce trust-only installs)"
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
for (const path of result.changedPaths) {
|
|
1750
|
+
console.log(` - ${path}`);
|
|
1751
|
+
}
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1754
|
+
process.exitCode = 1;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
export async function updateCommand(
|
|
1759
|
+
argv: string[],
|
|
1760
|
+
ctx: RemoteCommandContext = {}
|
|
1761
|
+
) {
|
|
1762
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
1763
|
+
printUpdateHelp();
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
const apply = argv.includes("--apply");
|
|
1767
|
+
const strictSourceTrust =
|
|
1768
|
+
argv.includes("--strict-source-trust") || Boolean(ctx.strictSourceTrust);
|
|
1769
|
+
const json = argv.includes("--json");
|
|
1770
|
+
try {
|
|
1771
|
+
const report = await checkRemoteUpdates({
|
|
1772
|
+
apply,
|
|
1773
|
+
strictSourceTrust,
|
|
1774
|
+
homeDir: ctx.homeDir,
|
|
1775
|
+
rootDir: ctx.rootDir,
|
|
1776
|
+
cwd: ctx.cwd,
|
|
1777
|
+
fetchJson: ctx.fetchJson,
|
|
1778
|
+
fetchText: ctx.fetchText,
|
|
1779
|
+
now: ctx.now,
|
|
1780
|
+
});
|
|
1781
|
+
if (json) {
|
|
1782
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (!report.checks.length) {
|
|
1786
|
+
console.log("No remotely installed items found.");
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
for (const check of report.checks) {
|
|
1790
|
+
const current = check.currentVersion ?? "-";
|
|
1791
|
+
const latest = check.latestVersion ?? "-";
|
|
1792
|
+
console.log(
|
|
1793
|
+
`${check.installed.ref} (${check.installed.installedAs})\t${check.status}\t${current} -> ${latest}`
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
if (apply) {
|
|
1797
|
+
console.log(`Applied ${report.applied.length} updates.`);
|
|
1798
|
+
}
|
|
1799
|
+
} catch (err) {
|
|
1800
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1801
|
+
process.exitCode = 1;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
export async function verifySourceCommand(
|
|
1806
|
+
argv: string[],
|
|
1807
|
+
ctx: RemoteCommandContext = {}
|
|
1808
|
+
) {
|
|
1809
|
+
if (
|
|
1810
|
+
!argv.length ||
|
|
1811
|
+
argv.includes("--help") ||
|
|
1812
|
+
argv.includes("-h") ||
|
|
1813
|
+
argv[0] === "help"
|
|
1814
|
+
) {
|
|
1815
|
+
printVerifySourceHelp();
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
const sourceName = argv.find((arg) => arg && !arg.startsWith("-"));
|
|
1820
|
+
if (!sourceName) {
|
|
1821
|
+
console.error("verify-source requires a source name");
|
|
1822
|
+
process.exitCode = 1;
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
const json = argv.includes("--json");
|
|
1826
|
+
|
|
1827
|
+
try {
|
|
1828
|
+
const report = await verifySource({
|
|
1829
|
+
sourceName,
|
|
1830
|
+
homeDir: ctx.homeDir,
|
|
1831
|
+
cwd: ctx.cwd,
|
|
1832
|
+
now: ctx.now,
|
|
1833
|
+
fetchJson: ctx.fetchJson,
|
|
1834
|
+
fetchText: ctx.fetchText,
|
|
1835
|
+
});
|
|
1836
|
+
if (json) {
|
|
1837
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1838
|
+
} else {
|
|
1839
|
+
const trustOrigin = report.trust.explicit ? "explicit" : "default";
|
|
1840
|
+
console.log(
|
|
1841
|
+
`${report.source.name}\t${report.source.kind}\t${report.source.url}`
|
|
1842
|
+
);
|
|
1843
|
+
console.log(
|
|
1844
|
+
`trust=${report.trust.level} (${trustOrigin})\tfetch=${report.checks.fetch}\tparse=${report.checks.parse}\tintegrity=${report.checks.integrity}\tsignature=${report.checks.signature}\titems=${report.checks.items}`
|
|
1845
|
+
);
|
|
1846
|
+
if (report.error) {
|
|
1847
|
+
console.log(`error: ${report.error}`);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
if (report.error) {
|
|
1852
|
+
process.exitCode = 1;
|
|
1853
|
+
}
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1856
|
+
process.exitCode = 1;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
export async function templatesCommand(
|
|
1861
|
+
argv: string[],
|
|
1862
|
+
ctx: RemoteCommandContext = {}
|
|
1863
|
+
) {
|
|
1864
|
+
const [sub, ...rest] = argv;
|
|
1865
|
+
if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
|
|
1866
|
+
printTemplatesHelp();
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
if (sub === "list") {
|
|
1870
|
+
const json = rest.includes("--json");
|
|
1871
|
+
const rows = BUILTIN_MANIFEST.items.map((item) => ({
|
|
1872
|
+
id: item.id,
|
|
1873
|
+
type: item.type,
|
|
1874
|
+
title: item.title ?? "",
|
|
1875
|
+
description: item.description ?? "",
|
|
1876
|
+
version: item.version ?? "",
|
|
1877
|
+
}));
|
|
1878
|
+
if (json) {
|
|
1879
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
for (const row of rows) {
|
|
1883
|
+
console.log(`${row.id}\t${row.type}\t${row.version}\t${row.title}`);
|
|
1884
|
+
}
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
if (sub !== "init") {
|
|
1888
|
+
console.error(`Unknown templates command: ${sub}`);
|
|
1889
|
+
process.exitCode = 2;
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const [kind, ...args] = rest;
|
|
1894
|
+
if (!kind) {
|
|
1895
|
+
console.error(
|
|
1896
|
+
"templates init requires a kind (skill|mcp|snippet|agents|claude)"
|
|
1897
|
+
);
|
|
1898
|
+
process.exitCode = 2;
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const dryRun = args.includes("--dry-run");
|
|
1902
|
+
const force = args.includes("--force");
|
|
1903
|
+
const json = args.includes("--json");
|
|
1904
|
+
const positional = args.filter((a) => a && !a.startsWith("-"));
|
|
1905
|
+
|
|
1906
|
+
let ref = "";
|
|
1907
|
+
let as: string | undefined;
|
|
1908
|
+
if (kind === "skill") {
|
|
1909
|
+
ref = `${BUILTIN_INDEX_NAME}:skill-template`;
|
|
1910
|
+
as = positional[0];
|
|
1911
|
+
if (!as) {
|
|
1912
|
+
console.error("templates init skill requires a <name>");
|
|
1913
|
+
process.exitCode = 2;
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
} else if (kind === "mcp") {
|
|
1917
|
+
ref = `${BUILTIN_INDEX_NAME}:mcp-stdio-template`;
|
|
1918
|
+
as = positional[0];
|
|
1919
|
+
if (!as) {
|
|
1920
|
+
console.error("templates init mcp requires a <name>");
|
|
1921
|
+
process.exitCode = 2;
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
} else if (kind === "snippet") {
|
|
1925
|
+
ref = `${BUILTIN_INDEX_NAME}:snippet-template`;
|
|
1926
|
+
as = positional[0];
|
|
1927
|
+
if (!as) {
|
|
1928
|
+
console.error("templates init snippet requires a <marker>");
|
|
1929
|
+
process.exitCode = 2;
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
} else if (kind === "agents") {
|
|
1933
|
+
ref = `${BUILTIN_INDEX_NAME}:agents-md-template`;
|
|
1934
|
+
as = positional[0];
|
|
1935
|
+
} else if (kind === "claude") {
|
|
1936
|
+
ref = `${BUILTIN_INDEX_NAME}:claude-md-template`;
|
|
1937
|
+
as = positional[0];
|
|
1938
|
+
} else {
|
|
1939
|
+
console.error(`Unknown template kind: ${kind}`);
|
|
1940
|
+
process.exitCode = 2;
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
try {
|
|
1945
|
+
const result = await installRemoteItem({
|
|
1946
|
+
ref,
|
|
1947
|
+
as,
|
|
1948
|
+
dryRun,
|
|
1949
|
+
force,
|
|
1950
|
+
homeDir: ctx.homeDir,
|
|
1951
|
+
rootDir: ctx.rootDir,
|
|
1952
|
+
cwd: ctx.cwd,
|
|
1953
|
+
fetchJson: ctx.fetchJson,
|
|
1954
|
+
fetchText: ctx.fetchText,
|
|
1955
|
+
now: ctx.now,
|
|
1956
|
+
});
|
|
1957
|
+
if (json) {
|
|
1958
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
const action = dryRun ? "Would scaffold" : "Scaffolded";
|
|
1962
|
+
console.log(`${action} ${kind} template as ${result.installedAs}`);
|
|
1963
|
+
for (const path of result.changedPaths) {
|
|
1964
|
+
console.log(` - ${path}`);
|
|
1965
|
+
}
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1968
|
+
process.exitCode = 1;
|
|
1969
|
+
}
|
|
1970
|
+
}
|