skillex 0.3.1 → 0.4.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/CHANGELOG.md +286 -1
- package/README.md +82 -16
- package/dist/auto-sync.d.ts +66 -0
- package/dist/auto-sync.js +91 -0
- package/dist/catalog.js +5 -29
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +266 -144
- package/dist/confirm.js +3 -1
- package/dist/direct-github.d.ts +60 -0
- package/dist/direct-github.js +177 -0
- package/dist/doctor.d.ts +31 -0
- package/dist/doctor.js +172 -0
- package/dist/downloader.d.ts +42 -0
- package/dist/downloader.js +41 -0
- package/dist/fs.d.ts +21 -1
- package/dist/fs.js +30 -3
- package/dist/http.d.ts +28 -7
- package/dist/http.js +143 -42
- package/dist/install.d.ts +23 -9
- package/dist/install.js +75 -348
- package/dist/lockfile.d.ts +46 -0
- package/dist/lockfile.js +169 -0
- package/dist/output.d.ts +11 -0
- package/dist/output.js +49 -0
- package/dist/recommended.d.ts +13 -0
- package/dist/recommended.js +21 -0
- package/dist/runner.js +9 -9
- package/dist/skill.d.ts +2 -0
- package/dist/skill.js +3 -0
- package/dist/sync.js +12 -9
- package/dist/types.d.ts +39 -0
- package/dist/types.js +28 -0
- package/dist/ui.js +1 -1
- package/dist/user-config.d.ts +5 -0
- package/dist/user-config.js +22 -1
- package/dist/web-ui.js +5 -0
- package/dist-ui/assets/CatalogPage-CKEfRSvG.js +1 -0
- package/dist-ui/assets/CatalogPage-W5MqylAz.css +1 -0
- package/dist-ui/assets/DoctorPage-C92pEVl_.js +1 -0
- package/dist-ui/assets/Skeleton-BISmLuhY.js +1 -0
- package/dist-ui/assets/Skeleton-_Ooiw1nN.css +1 -0
- package/dist-ui/assets/SkillDetailPage-CBAaWpcc.css +1 -0
- package/dist-ui/assets/SkillDetailPage-CWGjTH2M.js +1 -0
- package/dist-ui/assets/{index-UBECch6X.css → index-CWm7zQTg.css} +1 -1
- package/dist-ui/assets/index-DAVP4Xp_.js +26 -0
- package/dist-ui/assets/recommended-D_i10hwH.js +1 -0
- package/dist-ui/index.html +2 -2
- package/package.json +6 -2
- package/dist-ui/assets/CatalogPage-B_qic36n.js +0 -1
- package/dist-ui/assets/SkillDetailPage-BJ3onKk4.js +0 -1
- package/dist-ui/assets/index-DN-z--cR.js +0 -25
package/dist/lockfile.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lockfile shape, normalization, source-list management, and migration helpers.
|
|
3
|
+
*
|
|
4
|
+
* Keeps `install.ts` focused on install/update/remove orchestration. All
|
|
5
|
+
* callers should import from here directly; `install.ts` re-exports these
|
|
6
|
+
* symbols for backward compatibility with existing test imports.
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_REF, DEFAULT_REPO } from "./config.js";
|
|
9
|
+
/** Repos that are known placeholder values written by older versions and must be ignored. */
|
|
10
|
+
export const PLACEHOLDER_REPOS = new Set(["owner/repo"]);
|
|
11
|
+
/**
|
|
12
|
+
* Builds an empty lockfile seeded with a single source and `autoSync` on by default.
|
|
13
|
+
*/
|
|
14
|
+
export function createBaseLockfile(source, now) {
|
|
15
|
+
return {
|
|
16
|
+
formatVersion: 1,
|
|
17
|
+
createdAt: now(),
|
|
18
|
+
updatedAt: now(),
|
|
19
|
+
sources: [toLockfileSource(source)],
|
|
20
|
+
adapters: {
|
|
21
|
+
active: null,
|
|
22
|
+
detected: [],
|
|
23
|
+
},
|
|
24
|
+
settings: {
|
|
25
|
+
autoSync: true,
|
|
26
|
+
},
|
|
27
|
+
sync: null,
|
|
28
|
+
syncHistory: {},
|
|
29
|
+
syncMode: null,
|
|
30
|
+
installed: {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Normalizes a possibly-legacy lockfile shape into the current `LockfileState`.
|
|
35
|
+
* Handles arrays-of-strings adapters from very old versions, missing
|
|
36
|
+
* `syncHistory`, and the deprecated single `sync` field.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeLockfile(existing, source, now) {
|
|
39
|
+
if (!existing) {
|
|
40
|
+
return createBaseLockfile(source, now);
|
|
41
|
+
}
|
|
42
|
+
const detectedAdapters = Array.isArray(existing.adapters)
|
|
43
|
+
? existing.adapters
|
|
44
|
+
: Array.isArray(existing.adapters?.detected)
|
|
45
|
+
? existing.adapters.detected
|
|
46
|
+
: [];
|
|
47
|
+
const activeAdapter = Array.isArray(existing.adapters)
|
|
48
|
+
? existing.adapters[0] || null
|
|
49
|
+
: existing.adapters?.active || detectedAdapters[0] || null;
|
|
50
|
+
return {
|
|
51
|
+
formatVersion: Number(existing.formatVersion || 1),
|
|
52
|
+
createdAt: existing.createdAt || now(),
|
|
53
|
+
updatedAt: existing.updatedAt || now(),
|
|
54
|
+
sources: getLockfileSources(existing, source),
|
|
55
|
+
adapters: {
|
|
56
|
+
active: activeAdapter,
|
|
57
|
+
detected: [...new Set(detectedAdapters.filter(Boolean))],
|
|
58
|
+
},
|
|
59
|
+
settings: {
|
|
60
|
+
autoSync: existing.settings?.autoSync ?? true,
|
|
61
|
+
},
|
|
62
|
+
sync: existing.sync || null,
|
|
63
|
+
syncHistory: normalizeSyncHistory(existing),
|
|
64
|
+
syncMode: existing.syncMode || null,
|
|
65
|
+
installed: existing.installed || {},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Coerces the historic and current shapes of `syncHistory` into the
|
|
70
|
+
* normalized form, including a fallback that rebuilds the history from a
|
|
71
|
+
* legacy single-`sync` field.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeSyncHistory(existing) {
|
|
74
|
+
const history = {};
|
|
75
|
+
const candidate = existing && "syncHistory" in existing && existing.syncHistory && typeof existing.syncHistory === "object"
|
|
76
|
+
? existing.syncHistory
|
|
77
|
+
: null;
|
|
78
|
+
if (candidate) {
|
|
79
|
+
for (const [adapterId, metadata] of Object.entries(candidate)) {
|
|
80
|
+
if (!metadata || typeof metadata !== "object") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!("adapter" in metadata) || !("targetPath" in metadata) || !("syncedAt" in metadata)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
history[adapterId] = metadata;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (existing?.sync?.adapter && !history[existing.sync.adapter]) {
|
|
90
|
+
history[existing.sync.adapter] = existing.sync;
|
|
91
|
+
}
|
|
92
|
+
return history;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolves the configured source list, dropping placeholders and falling back
|
|
96
|
+
* to legacy single-catalog metadata when no `sources` array exists.
|
|
97
|
+
*/
|
|
98
|
+
export function getLockfileSources(existing, fallbackSource) {
|
|
99
|
+
const legacyCatalog = getLegacyCatalog(existing);
|
|
100
|
+
const configuredSources = Array.isArray(existing?.sources)
|
|
101
|
+
? existing.sources
|
|
102
|
+
.filter((entry) => Boolean(entry?.repo))
|
|
103
|
+
.filter((entry) => !PLACEHOLDER_REPOS.has(entry.repo))
|
|
104
|
+
.map((entry) => ({
|
|
105
|
+
repo: entry.repo,
|
|
106
|
+
ref: entry.ref || DEFAULT_REF,
|
|
107
|
+
...(entry.label ? { label: entry.label } : {}),
|
|
108
|
+
}))
|
|
109
|
+
: [];
|
|
110
|
+
if (configuredSources.length > 0) {
|
|
111
|
+
return dedupeSources(configuredSources);
|
|
112
|
+
}
|
|
113
|
+
if (legacyCatalog?.repo && !PLACEHOLDER_REPOS.has(legacyCatalog.repo)) {
|
|
114
|
+
return dedupeSources([
|
|
115
|
+
{
|
|
116
|
+
repo: legacyCatalog.repo,
|
|
117
|
+
ref: legacyCatalog.ref || DEFAULT_REF,
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
return [toLockfileSource(fallbackSource)];
|
|
122
|
+
}
|
|
123
|
+
function getLegacyCatalog(existing) {
|
|
124
|
+
if (!existing || !("catalog" in existing)) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const legacyState = existing;
|
|
128
|
+
return legacyState.catalog || null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Deduplicates a source list keyed by `${repo}@${ref}`, preserving the
|
|
132
|
+
* first occurrence (which carries any label).
|
|
133
|
+
*/
|
|
134
|
+
export function dedupeSources(sources) {
|
|
135
|
+
const unique = new Map();
|
|
136
|
+
for (const source of sources) {
|
|
137
|
+
const key = `${source.repo}@${source.ref}`;
|
|
138
|
+
if (!unique.has(key)) {
|
|
139
|
+
unique.set(key, source);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return [...unique.values()];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Converts a `CatalogSource` to a `LockfileSource`, attaching a label only
|
|
146
|
+
* when one is explicitly provided or when the source is the default
|
|
147
|
+
* first-party repo (which gets the `official` label automatically).
|
|
148
|
+
*/
|
|
149
|
+
export function toLockfileSource(source, label) {
|
|
150
|
+
const wantsLabel = Boolean(label) || source.repo === DEFAULT_REPO;
|
|
151
|
+
return {
|
|
152
|
+
repo: source.repo,
|
|
153
|
+
ref: source.ref,
|
|
154
|
+
...(wantsLabel ? { label: label || "official" } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Parses a `catalog:owner/repo@ref` source string into a `LockfileSource`.
|
|
159
|
+
*/
|
|
160
|
+
export function parseCatalogSource(source) {
|
|
161
|
+
const match = source.match(/^catalog:([^@]+\/[^@]+)@(.+)$/);
|
|
162
|
+
if (!match) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
repo: match[1],
|
|
167
|
+
ref: match[2],
|
|
168
|
+
};
|
|
169
|
+
}
|
package/dist/output.d.ts
CHANGED
|
@@ -63,3 +63,14 @@ export declare function statusLine(message: string): void;
|
|
|
63
63
|
* Clears the current status line written by {@link statusLine}.
|
|
64
64
|
*/
|
|
65
65
|
export declare function clearStatus(): void;
|
|
66
|
+
/**
|
|
67
|
+
* Returns the candidate closest to `actual` by Levenshtein distance, but only
|
|
68
|
+
* when the distance is at or below `threshold`. Useful for "did you mean"
|
|
69
|
+
* hints on unknown commands, flags, or config keys.
|
|
70
|
+
*
|
|
71
|
+
* @param actual - The token typed by the user.
|
|
72
|
+
* @param candidates - Known valid tokens.
|
|
73
|
+
* @param threshold - Maximum Levenshtein distance to consider (default 2).
|
|
74
|
+
* @returns The closest match within threshold, or `null`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function suggestClosest(actual: string, candidates: readonly string[], threshold?: number): string | null;
|
package/dist/output.js
CHANGED
|
@@ -119,3 +119,52 @@ export function clearStatus() {
|
|
|
119
119
|
return;
|
|
120
120
|
process.stdout.write("\r\x1b[K");
|
|
121
121
|
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// "Did you mean" suggestion helper
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* Returns the candidate closest to `actual` by Levenshtein distance, but only
|
|
127
|
+
* when the distance is at or below `threshold`. Useful for "did you mean"
|
|
128
|
+
* hints on unknown commands, flags, or config keys.
|
|
129
|
+
*
|
|
130
|
+
* @param actual - The token typed by the user.
|
|
131
|
+
* @param candidates - Known valid tokens.
|
|
132
|
+
* @param threshold - Maximum Levenshtein distance to consider (default 2).
|
|
133
|
+
* @returns The closest match within threshold, or `null`.
|
|
134
|
+
*/
|
|
135
|
+
export function suggestClosest(actual, candidates, threshold = 2) {
|
|
136
|
+
if (!actual || candidates.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
let best = null;
|
|
139
|
+
for (const candidate of candidates) {
|
|
140
|
+
const distance = levenshtein(actual, candidate);
|
|
141
|
+
if (distance > threshold)
|
|
142
|
+
continue;
|
|
143
|
+
if (!best || distance < best.distance) {
|
|
144
|
+
best = { name: candidate, distance };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return best ? best.name : null;
|
|
148
|
+
}
|
|
149
|
+
function levenshtein(a, b) {
|
|
150
|
+
if (a === b)
|
|
151
|
+
return 0;
|
|
152
|
+
if (a.length === 0)
|
|
153
|
+
return b.length;
|
|
154
|
+
if (b.length === 0)
|
|
155
|
+
return a.length;
|
|
156
|
+
const prev = new Array(b.length + 1);
|
|
157
|
+
const curr = new Array(b.length + 1);
|
|
158
|
+
for (let j = 0; j <= b.length; j += 1)
|
|
159
|
+
prev[j] = j;
|
|
160
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
161
|
+
curr[0] = i;
|
|
162
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
163
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
164
|
+
curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
|
|
165
|
+
}
|
|
166
|
+
for (let j = 0; j <= b.length; j += 1)
|
|
167
|
+
prev[j] = curr[j] ?? 0;
|
|
168
|
+
}
|
|
169
|
+
return prev[b.length] ?? 0;
|
|
170
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated starter pack installed when the user passes
|
|
3
|
+
* `skillex init --install-recommended`.
|
|
4
|
+
*
|
|
5
|
+
* Keep this list short and broadly useful. Anything specialised (LaTeX,
|
|
6
|
+
* power electronics, ...) belongs in the full catalog.
|
|
7
|
+
*/
|
|
8
|
+
export declare const RECOMMENDED_SKILL_IDS: readonly ["commit-craft", "code-review", "secure-defaults", "error-handling", "test-discipline"];
|
|
9
|
+
/**
|
|
10
|
+
* Returns the recommended skill ids as a mutable string array (suitable for
|
|
11
|
+
* passing to `installSkills`).
|
|
12
|
+
*/
|
|
13
|
+
export declare function getRecommendedSkillIds(): string[];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated starter pack installed when the user passes
|
|
3
|
+
* `skillex init --install-recommended`.
|
|
4
|
+
*
|
|
5
|
+
* Keep this list short and broadly useful. Anything specialised (LaTeX,
|
|
6
|
+
* power electronics, ...) belongs in the full catalog.
|
|
7
|
+
*/
|
|
8
|
+
export const RECOMMENDED_SKILL_IDS = Object.freeze([
|
|
9
|
+
"commit-craft",
|
|
10
|
+
"code-review",
|
|
11
|
+
"secure-defaults",
|
|
12
|
+
"error-handling",
|
|
13
|
+
"test-discipline",
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* Returns the recommended skill ids as a mutable string array (suitable for
|
|
17
|
+
* passing to `installSkills`).
|
|
18
|
+
*/
|
|
19
|
+
export function getRecommendedSkillIds() {
|
|
20
|
+
return [...RECOMMENDED_SKILL_IDS];
|
|
21
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -14,7 +14,7 @@ import { CliError } from "./types.js";
|
|
|
14
14
|
export function parseSkillCommandReference(value) {
|
|
15
15
|
const separatorIndex = value.indexOf(":");
|
|
16
16
|
if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
|
|
17
|
-
throw new CliError('Use
|
|
17
|
+
throw new CliError('Use the format "skill-id:command" to run skill scripts.', "INVALID_RUN_REFERENCE");
|
|
18
18
|
}
|
|
19
19
|
return {
|
|
20
20
|
skillId: value.slice(0, separatorIndex),
|
|
@@ -39,12 +39,12 @@ export async function runSkillScript(skillId, commandName, options = {}) {
|
|
|
39
39
|
const lockfile = (await readJson(statePaths.lockfilePath, null)) || null;
|
|
40
40
|
if (!lockfile) {
|
|
41
41
|
throw new CliError(statePaths.scope === "global"
|
|
42
|
-
? "
|
|
43
|
-
: "
|
|
42
|
+
? "No global installation found. Run: skillex init --global --adapter <id>"
|
|
43
|
+
: "No local installation found. Run: skillex init", "LOCKFILE_MISSING");
|
|
44
44
|
}
|
|
45
45
|
const metadata = lockfile.installed?.[skillId];
|
|
46
46
|
if (!metadata?.path) {
|
|
47
|
-
throw new CliError(`Skill "${skillId}"
|
|
47
|
+
throw new CliError(`Skill "${skillId}" is not installed.`, "SKILL_NOT_INSTALLED");
|
|
48
48
|
}
|
|
49
49
|
const skillDir = path.isAbsolute(metadata.path) ? metadata.path : path.resolve(cwd, metadata.path);
|
|
50
50
|
const manifest = (await readJson(path.join(skillDir, "skill.json"), {})) || {};
|
|
@@ -53,14 +53,14 @@ export async function runSkillScript(skillId, commandName, options = {}) {
|
|
|
53
53
|
if (!script) {
|
|
54
54
|
const available = Object.keys(scripts);
|
|
55
55
|
throw new CliError(available.length > 0
|
|
56
|
-
? `
|
|
57
|
-
: `
|
|
56
|
+
? `Command "${commandName}" does not exist for "${skillId}". Available: ${available.join(", ")}`
|
|
57
|
+
: `Skill "${skillId}" does not declare any executable scripts.`, "RUN_COMMAND_NOT_FOUND");
|
|
58
58
|
}
|
|
59
|
-
const confirm = options.confirm || (() => confirmAction(`
|
|
59
|
+
const confirm = options.confirm || (() => confirmAction(`Run in ${skillId}: ${script}?`));
|
|
60
60
|
if (!options.yes) {
|
|
61
61
|
const accepted = await confirm();
|
|
62
62
|
if (!accepted) {
|
|
63
|
-
throw new CliError("
|
|
63
|
+
throw new CliError("Run cancelled by user. Pass --yes to skip the confirmation prompt.", "RUN_CANCELLED");
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
const stdout = options.stdout || process.stdout;
|
|
@@ -89,7 +89,7 @@ export async function runSkillScript(skillId, commandName, options = {}) {
|
|
|
89
89
|
child.on("close", (code) => {
|
|
90
90
|
clearTimeout(timeout);
|
|
91
91
|
if (timedOut) {
|
|
92
|
-
stderr.write(`
|
|
92
|
+
stderr.write(`Timeout exceeded (${timeoutSeconds}s).\n`);
|
|
93
93
|
resolve(1);
|
|
94
94
|
return;
|
|
95
95
|
}
|
package/dist/skill.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface SkillFrontmatter {
|
|
|
6
6
|
description?: string | undefined;
|
|
7
7
|
autoInject?: boolean | undefined;
|
|
8
8
|
activationPrompt?: string | undefined;
|
|
9
|
+
/** Optional explicit category for the skill (e.g. "code", "infra", "docs"). */
|
|
10
|
+
category?: string | undefined;
|
|
9
11
|
}
|
|
10
12
|
/**
|
|
11
13
|
* Parses the YAML-like frontmatter at the top of a `SKILL.md` file.
|
package/dist/skill.js
CHANGED
|
@@ -34,6 +34,9 @@ export function parseSkillFrontmatter(content) {
|
|
|
34
34
|
if (key === "activationPrompt" && typeof value === "string") {
|
|
35
35
|
values.activationPrompt = value;
|
|
36
36
|
}
|
|
37
|
+
if (key === "category" && typeof value === "string") {
|
|
38
|
+
values.category = value;
|
|
39
|
+
}
|
|
37
40
|
}
|
|
38
41
|
return values;
|
|
39
42
|
}
|
package/dist/sync.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { getAdapter } from "./adapters.js";
|
|
4
4
|
import { copyPath, createSymlink, ensureDir, pathExists, readJson, readSymlink, readText, removePath, writeText, } from "./fs.js";
|
|
5
5
|
import { normalizeSkillContent, parseSkillFrontmatter } from "./skill.js";
|
|
6
|
+
import { warn as outputWarn } from "./output.js";
|
|
6
7
|
import { SyncError } from "./types.js";
|
|
7
8
|
const MANAGED_START = "<!-- SKILLEX:START -->";
|
|
8
9
|
const MANAGED_END = "<!-- SKILLEX:END -->";
|
|
@@ -62,13 +63,14 @@ export async function syncAdapterFiles(options) {
|
|
|
62
63
|
}
|
|
63
64
|
else if (prepared.directoryEntries) {
|
|
64
65
|
await ensureDir(prepared.absoluteTargetPath);
|
|
65
|
-
const createLink = options.linkFactory
|
|
66
|
+
const createLink = options.linkFactory
|
|
67
|
+
|| ((t, l) => createSymlink(t, l, { allowedRoot: options.statePaths.stateDir }));
|
|
66
68
|
let finalMode = prepared.syncMode;
|
|
67
69
|
for (const entry of prepared.directoryEntries) {
|
|
68
70
|
if (prepared.syncMode === "symlink") {
|
|
69
71
|
const linkResult = await createLink(entry.sourcePath, entry.absoluteTargetPath);
|
|
70
72
|
if (linkResult.fallback) {
|
|
71
|
-
(options.warn ||
|
|
73
|
+
(options.warn || outputWarn)(`Symlink unavailable for ${entry.targetPath}; falling back to copy. Pass --mode copy to make this explicit.`);
|
|
72
74
|
await copyPath(entry.sourcePath, entry.absoluteTargetPath);
|
|
73
75
|
finalMode = "copy";
|
|
74
76
|
}
|
|
@@ -89,10 +91,11 @@ export async function syncAdapterFiles(options) {
|
|
|
89
91
|
await writeText(prepared.generatedSourcePath, prepared.nextContent);
|
|
90
92
|
}
|
|
91
93
|
if (prepared.syncMode === "symlink" && prepared.generatedSourcePath) {
|
|
92
|
-
const createLink = options.linkFactory
|
|
94
|
+
const createLink = options.linkFactory
|
|
95
|
+
|| ((t, l) => createSymlink(t, l, { allowedRoot: options.statePaths.stateDir }));
|
|
93
96
|
const linkResult = await createLink(prepared.generatedSourcePath, prepared.absoluteTargetPath);
|
|
94
97
|
if (linkResult.fallback) {
|
|
95
|
-
(options.warn ||
|
|
98
|
+
(options.warn || outputWarn)(`Symlink unavailable for ${prepared.targetPath}; falling back to copy. Pass --mode copy to make this explicit.`);
|
|
96
99
|
await writeText(prepared.absoluteTargetPath, prepared.nextContent);
|
|
97
100
|
await removePath(prepared.generatedSourcePath);
|
|
98
101
|
prepared.syncMode = "copy";
|
|
@@ -119,7 +122,7 @@ export async function syncAdapterFiles(options) {
|
|
|
119
122
|
throw error;
|
|
120
123
|
}
|
|
121
124
|
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
-
throw new SyncError(`
|
|
125
|
+
throw new SyncError(`Failed to sync adapter ${options.adapterId}: ${message}`);
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
/**
|
|
@@ -147,7 +150,7 @@ export async function prepareSyncAdapterFiles(options) {
|
|
|
147
150
|
throw new SyncError(`Adapter ${adapter.id} nao suporta sync global no momento. Use --scope local.`, "GLOBAL_SYNC_UNSUPPORTED");
|
|
148
151
|
}
|
|
149
152
|
if (!adapter.syncTarget) {
|
|
150
|
-
throw new SyncError(`Adapter ${adapter.id}
|
|
153
|
+
throw new SyncError(`Adapter ${adapter.id} does not declare a sync target.`, "SYNC_TARGET_MISSING");
|
|
151
154
|
}
|
|
152
155
|
const body = renderInstalledSkills(options.skills);
|
|
153
156
|
const autoInjectBlock = buildAutoInjectBlock(options.skills);
|
|
@@ -273,7 +276,7 @@ export function renderInstalledSkills(skills) {
|
|
|
273
276
|
"",
|
|
274
277
|
];
|
|
275
278
|
if (sections.length === 0) {
|
|
276
|
-
lines.push("
|
|
279
|
+
lines.push("No skills installed yet.");
|
|
277
280
|
}
|
|
278
281
|
else {
|
|
279
282
|
lines.push(sections.join("\n\n---\n\n"));
|
|
@@ -369,7 +372,7 @@ function resolveAdapterTargetPath(adapter, options) {
|
|
|
369
372
|
return path.resolve(adapter.globalSyncTarget);
|
|
370
373
|
}
|
|
371
374
|
if (!adapter.syncTarget) {
|
|
372
|
-
throw new SyncError(`Adapter ${adapter.id}
|
|
375
|
+
throw new SyncError(`Adapter ${adapter.id} does not declare a sync target.`, "SYNC_TARGET_MISSING");
|
|
373
376
|
}
|
|
374
377
|
return path.join(options.cwd, adapter.syncTarget);
|
|
375
378
|
}
|
|
@@ -406,7 +409,7 @@ function buildManagedFileContent(adapterId, body, autoInjectBlock) {
|
|
|
406
409
|
case "cline":
|
|
407
410
|
return `${sections.join("\n\n")}\n`;
|
|
408
411
|
default:
|
|
409
|
-
throw new SyncError(`
|
|
412
|
+
throw new SyncError(`Unknown adapter: ${adapterId}`, "SYNC_ADAPTER_UNKNOWN");
|
|
410
413
|
}
|
|
411
414
|
}
|
|
412
415
|
function wrapManagedBlock(start, end, body) {
|
package/dist/types.d.ts
CHANGED
|
@@ -77,6 +77,11 @@ export interface ParsedGitHubRepo {
|
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
79
|
* Skill manifest stored in catalog and local installs.
|
|
80
|
+
*
|
|
81
|
+
* `category` is optional. When present, it lets catalog publishers group
|
|
82
|
+
* skills explicitly (e.g. `"code"`, `"infra"`, `"docs"`) instead of relying
|
|
83
|
+
* on consumer-side regex inference. Consumers SHOULD prefer the declared
|
|
84
|
+
* value when one is provided.
|
|
80
85
|
*/
|
|
81
86
|
export interface SkillManifest {
|
|
82
87
|
id: string;
|
|
@@ -89,6 +94,7 @@ export interface SkillManifest {
|
|
|
89
94
|
entry: string;
|
|
90
95
|
path: string;
|
|
91
96
|
files: string[];
|
|
97
|
+
category?: string | undefined;
|
|
92
98
|
scripts?: Record<string, string> | undefined;
|
|
93
99
|
}
|
|
94
100
|
/**
|
|
@@ -375,12 +381,17 @@ export interface UpdateInstalledSkillsResult {
|
|
|
375
381
|
}
|
|
376
382
|
/**
|
|
377
383
|
* Result returned by `removeSkills`.
|
|
384
|
+
*
|
|
385
|
+
* `autoSync` is preserved as the first adapter's result for backward
|
|
386
|
+
* compatibility; `autoSyncs` carries the full per-adapter aggregate so
|
|
387
|
+
* callers can report each adapter individually.
|
|
378
388
|
*/
|
|
379
389
|
export interface RemoveSkillsResult {
|
|
380
390
|
statePaths: StatePaths;
|
|
381
391
|
removedSkills: string[];
|
|
382
392
|
missingSkills: string[];
|
|
383
393
|
autoSync: SyncCommandResult | null;
|
|
394
|
+
autoSyncs: SyncCommandResult[];
|
|
384
395
|
}
|
|
385
396
|
/**
|
|
386
397
|
* Catalog loader signature override used in tests.
|
|
@@ -392,10 +403,15 @@ export type CatalogLoader = (source: CatalogSource) => Promise<CatalogData>;
|
|
|
392
403
|
export type SkillDownloader = (skill: SkillManifest, catalog: CatalogData, stateDir: string) => Promise<void>;
|
|
393
404
|
/**
|
|
394
405
|
* Parsed CLI arguments.
|
|
406
|
+
*
|
|
407
|
+
* `positionalAfter` carries any tokens that follow a literal `--` end-of-options
|
|
408
|
+
* sentinel; this lets `skillex run x:cmd -- --foo --bar` forward `--foo --bar`
|
|
409
|
+
* to the underlying script without the parser interpreting them.
|
|
395
410
|
*/
|
|
396
411
|
export interface ParsedArgs {
|
|
397
412
|
command?: string | undefined;
|
|
398
413
|
positionals: string[];
|
|
414
|
+
positionalAfter: string[];
|
|
399
415
|
flags: Record<string, string | boolean>;
|
|
400
416
|
}
|
|
401
417
|
/**
|
|
@@ -478,3 +494,26 @@ export declare class CliError extends SkillexError {
|
|
|
478
494
|
*/
|
|
479
495
|
constructor(message: string, code?: string);
|
|
480
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* Error thrown for HTTP failures, including timeouts, rate limits, and
|
|
499
|
+
* authentication failures. Preserves `status` and `url` for diagnostics.
|
|
500
|
+
*
|
|
501
|
+
* Possible `code` values:
|
|
502
|
+
* - `HTTP_TIMEOUT` — request aborted because of timeout
|
|
503
|
+
* - `HTTP_RATE_LIMIT` — GitHub rate limit exhausted
|
|
504
|
+
* - `HTTP_AUTH_FAILED` — 401 / 403 with auth-related cause
|
|
505
|
+
* - `HTTP_NOT_FOUND` — 404 surfaced as an error (non-optional fetchers)
|
|
506
|
+
* - `HTTP_SERVER_ERROR` — 5xx
|
|
507
|
+
* - `HTTP_ERROR` — fallback for other non-2xx responses
|
|
508
|
+
*/
|
|
509
|
+
export declare class HttpError extends SkillexError {
|
|
510
|
+
status?: number | undefined;
|
|
511
|
+
url?: string | undefined;
|
|
512
|
+
/**
|
|
513
|
+
* Creates an HTTP error.
|
|
514
|
+
*/
|
|
515
|
+
constructor(message: string, code: string, options?: {
|
|
516
|
+
status?: number;
|
|
517
|
+
url?: string;
|
|
518
|
+
});
|
|
519
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -81,3 +81,31 @@ export class CliError extends SkillexError {
|
|
|
81
81
|
super(message, code);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Error thrown for HTTP failures, including timeouts, rate limits, and
|
|
86
|
+
* authentication failures. Preserves `status` and `url` for diagnostics.
|
|
87
|
+
*
|
|
88
|
+
* Possible `code` values:
|
|
89
|
+
* - `HTTP_TIMEOUT` — request aborted because of timeout
|
|
90
|
+
* - `HTTP_RATE_LIMIT` — GitHub rate limit exhausted
|
|
91
|
+
* - `HTTP_AUTH_FAILED` — 401 / 403 with auth-related cause
|
|
92
|
+
* - `HTTP_NOT_FOUND` — 404 surfaced as an error (non-optional fetchers)
|
|
93
|
+
* - `HTTP_SERVER_ERROR` — 5xx
|
|
94
|
+
* - `HTTP_ERROR` — fallback for other non-2xx responses
|
|
95
|
+
*/
|
|
96
|
+
export class HttpError extends SkillexError {
|
|
97
|
+
status;
|
|
98
|
+
url;
|
|
99
|
+
/**
|
|
100
|
+
* Creates an HTTP error.
|
|
101
|
+
*/
|
|
102
|
+
constructor(message, code, options = {}) {
|
|
103
|
+
super(message, code);
|
|
104
|
+
if (options.status !== undefined) {
|
|
105
|
+
this.status = options.status;
|
|
106
|
+
}
|
|
107
|
+
if (options.url !== undefined) {
|
|
108
|
+
this.url = options.url;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -24,7 +24,7 @@ export function filterCatalogForUi(skills, query) {
|
|
|
24
24
|
export async function runInteractiveUi(options) {
|
|
25
25
|
const prompts = options.prompts || (await loadPromptAdapters());
|
|
26
26
|
const query = await (prompts.input || fallbackInput)({
|
|
27
|
-
message: "
|
|
27
|
+
message: "Filter skills (press Enter to show all)",
|
|
28
28
|
default: "",
|
|
29
29
|
});
|
|
30
30
|
const filteredSkills = filterCatalogForUi(options.skills, query);
|
package/dist/user-config.d.ts
CHANGED
|
@@ -34,6 +34,11 @@ export declare function readUserConfig(): Promise<UserConfig>;
|
|
|
34
34
|
/**
|
|
35
35
|
* Writes the global user configuration file, merging with any existing values.
|
|
36
36
|
*
|
|
37
|
+
* The file is always written with mode `0o600` so any stored `githubToken` is
|
|
38
|
+
* not world-readable. If a previous version of the file existed with looser
|
|
39
|
+
* permissions, the mode is tightened on save and a one-time warning is
|
|
40
|
+
* printed during the session.
|
|
41
|
+
*
|
|
37
42
|
* @param updates - Key/value pairs to write.
|
|
38
43
|
*/
|
|
39
44
|
export declare function writeUserConfig(updates: Partial<UserConfig>): Promise<void>;
|
package/dist/user-config.js
CHANGED
|
@@ -43,6 +43,11 @@ export async function readUserConfig() {
|
|
|
43
43
|
/**
|
|
44
44
|
* Writes the global user configuration file, merging with any existing values.
|
|
45
45
|
*
|
|
46
|
+
* The file is always written with mode `0o600` so any stored `githubToken` is
|
|
47
|
+
* not world-readable. If a previous version of the file existed with looser
|
|
48
|
+
* permissions, the mode is tightened on save and a one-time warning is
|
|
49
|
+
* printed during the session.
|
|
50
|
+
*
|
|
46
51
|
* @param updates - Key/value pairs to write.
|
|
47
52
|
*/
|
|
48
53
|
export async function writeUserConfig(updates) {
|
|
@@ -50,5 +55,21 @@ export async function writeUserConfig(updates) {
|
|
|
50
55
|
const existing = await readUserConfig();
|
|
51
56
|
const merged = { ...existing, ...updates };
|
|
52
57
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
53
|
-
|
|
58
|
+
let previousMode = null;
|
|
59
|
+
try {
|
|
60
|
+
const stat = await fs.stat(configPath);
|
|
61
|
+
previousMode = stat.mode & 0o777;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
previousMode = null;
|
|
65
|
+
}
|
|
66
|
+
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
67
|
+
// `fs.writeFile` with `mode` only applies on file creation; chmod afterwards to
|
|
68
|
+
// tighten an existing file's permissions.
|
|
69
|
+
await fs.chmod(configPath, 0o600);
|
|
70
|
+
if (previousMode !== null && previousMode !== 0o600 && !looseConfigWarned) {
|
|
71
|
+
looseConfigWarned = true;
|
|
72
|
+
console.warn(`[skillex] Tightened permissions on ${configPath} from ${previousMode.toString(8)} to 600.`);
|
|
73
|
+
}
|
|
54
74
|
}
|
|
75
|
+
let looseConfigWarned = false;
|
package/dist/web-ui.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { listAdapters, resolveAdapterState } from "./adapters.js";
|
|
8
8
|
import { buildRawGitHubUrl } from "./catalog.js";
|
|
9
9
|
import { getScopedStatePaths } from "./config.js";
|
|
10
|
+
import { runDoctorChecks } from "./doctor.js";
|
|
10
11
|
import { readJson, readText } from "./fs.js";
|
|
11
12
|
import { fetchText } from "./http.js";
|
|
12
13
|
import { addProjectSource, getInstalledSkills, installSkills, listProjectSources, loadProjectCatalogs, removeProjectSource, removeSkills, syncInstalledSkills, updateInstalledSkills, } from "./install.js";
|
|
@@ -149,6 +150,10 @@ async function handleRequest(request, response, context) {
|
|
|
149
150
|
sendJson(response, 200, await buildDashboardState(withRequestScope(context, url.searchParams.get("scope"))));
|
|
150
151
|
return;
|
|
151
152
|
}
|
|
153
|
+
if (method === "GET" && pathname === "/api/doctor") {
|
|
154
|
+
sendJson(response, 200, await runDoctorChecks(withRequestScope(context, url.searchParams.get("scope"))));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
152
157
|
if (method === "GET" && pathname === "/api/catalog") {
|
|
153
158
|
sendJson(response, 200, await buildCatalogResponse(withRequestScope(context, url.searchParams.get("scope"))));
|
|
154
159
|
return;
|