skillex 0.3.1 → 0.4.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +262 -1
  2. package/README.md +57 -10
  3. package/dist/auto-sync.d.ts +66 -0
  4. package/dist/auto-sync.js +91 -0
  5. package/dist/catalog.js +5 -29
  6. package/dist/cli.d.ts +13 -0
  7. package/dist/cli.js +247 -141
  8. package/dist/confirm.js +3 -1
  9. package/dist/direct-github.d.ts +60 -0
  10. package/dist/direct-github.js +177 -0
  11. package/dist/doctor.d.ts +31 -0
  12. package/dist/doctor.js +172 -0
  13. package/dist/downloader.d.ts +42 -0
  14. package/dist/downloader.js +41 -0
  15. package/dist/fs.d.ts +21 -1
  16. package/dist/fs.js +30 -3
  17. package/dist/http.d.ts +28 -7
  18. package/dist/http.js +143 -42
  19. package/dist/install.d.ts +23 -9
  20. package/dist/install.js +75 -348
  21. package/dist/lockfile.d.ts +46 -0
  22. package/dist/lockfile.js +169 -0
  23. package/dist/output.d.ts +11 -0
  24. package/dist/output.js +49 -0
  25. package/dist/recommended.d.ts +13 -0
  26. package/dist/recommended.js +21 -0
  27. package/dist/runner.js +9 -9
  28. package/dist/skill.d.ts +2 -0
  29. package/dist/skill.js +3 -0
  30. package/dist/sync.js +12 -9
  31. package/dist/types.d.ts +39 -0
  32. package/dist/types.js +28 -0
  33. package/dist/ui.js +1 -1
  34. package/dist/user-config.d.ts +5 -0
  35. package/dist/user-config.js +22 -1
  36. package/dist/web-ui.js +5 -0
  37. package/dist-ui/assets/CatalogPage-CbtMTkxd.js +1 -0
  38. package/dist-ui/assets/CatalogPage-W5MqylAz.css +1 -0
  39. package/dist-ui/assets/DoctorPage-oUZyX91t.js +1 -0
  40. package/dist-ui/assets/Skeleton-B_xm5L3P.js +1 -0
  41. package/dist-ui/assets/Skeleton-_Ooiw1nN.css +1 -0
  42. package/dist-ui/assets/SkillDetailPage-5JHQLq3q.js +1 -0
  43. package/dist-ui/assets/SkillDetailPage-CBAaWpcc.css +1 -0
  44. package/dist-ui/assets/{index-UBECch6X.css → index-CWm7zQTg.css} +1 -1
  45. package/dist-ui/assets/index-I0b-syhc.js +26 -0
  46. package/dist-ui/assets/recommended-D_i10hwH.js +1 -0
  47. package/dist-ui/index.html +2 -2
  48. package/package.json +2 -2
  49. package/dist-ui/assets/CatalogPage-B_qic36n.js +0 -1
  50. package/dist-ui/assets/SkillDetailPage-BJ3onKk4.js +0 -1
  51. package/dist-ui/assets/index-DN-z--cR.js +0 -25
@@ -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 o formato "skill-id:comando" para executar scripts.', "INVALID_RUN_REFERENCE");
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
- ? "Nenhuma instalacao global encontrada. Rode: skillex init --global --adapter <id>"
43
- : "Nenhuma instalacao local encontrada. Rode: skillex init", "LOCKFILE_MISSING");
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}" nao esta instalada.`, "SKILL_NOT_INSTALLED");
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
- ? `Comando "${commandName}" nao existe para "${skillId}". Disponiveis: ${available.join(", ")}`
57
- : `A skill "${skillId}" nao declara scripts executaveis.`, "RUN_COMMAND_NOT_FOUND");
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(`Executar em ${skillId}: ${script}?`));
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("Execucao cancelada pelo usuario.", "RUN_CANCELLED");
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(`Tempo limite excedido (${timeoutSeconds}s).\n`);
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 || createSymlink;
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 || console.error)(`Aviso: symlink indisponivel para ${entry.targetPath}; usando copia no lugar.`);
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 || createSymlink;
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 || console.error)(`Aviso: symlink indisponivel para ${prepared.targetPath}; usando copia no lugar.`);
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(`Falha ao sincronizar adapter ${options.adapterId}: ${message}`);
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} nao define um alvo de sync.`, "SYNC_TARGET_MISSING");
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("Nenhuma skill instalada no momento.");
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} nao define um alvo de sync.`, "SYNC_TARGET_MISSING");
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(`Adapter desconhecido: ${adapterId}`, "SYNC_ADAPTER_UNKNOWN");
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: "Filtro das skills (Enter para mostrar tudo)",
27
+ message: "Filter skills (press Enter to show all)",
28
28
  default: "",
29
29
  });
30
30
  const filteredSkills = filterCatalogForUi(options.skills, query);
@@ -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>;
@@ -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
- await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
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;