skillex 0.2.1 → 0.2.2

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/dist/sync.js CHANGED
@@ -1,6 +1,7 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
- import { createSymlink, ensureDir, readJson, readSymlink, readText, removePath, writeText } from "./fs.js";
3
3
  import { getAdapter } from "./adapters.js";
4
+ import { copyPath, createSymlink, ensureDir, pathExists, readJson, readSymlink, readText, removePath, writeText, } from "./fs.js";
4
5
  import { normalizeSkillContent, parseSkillFrontmatter } from "./skill.js";
5
6
  import { SyncError } from "./types.js";
6
7
  const MANAGED_START = "<!-- SKILLEX:START -->";
@@ -12,7 +13,7 @@ const LEGACY_AUTO_INJECT_BLOCKS = [
12
13
  { start: "<!-- ASKILL:AUTO-INJECT:START -->", end: "<!-- ASKILL:AUTO-INJECT:END -->" },
13
14
  ];
14
15
  /**
15
- * Loads installed skill documents from the local workspace state directory.
16
+ * Loads installed skill documents from the selected Skillex state directory.
16
17
  *
17
18
  * @param context - Workspace root and lockfile context.
18
19
  * @returns Installed skill documents used for sync rendering.
@@ -21,7 +22,7 @@ export async function loadInstalledSkillDocuments(context) {
21
22
  const installedEntries = Object.entries(context.lockfile.installed || {}).sort(([left], [right]) => left.localeCompare(right));
22
23
  const documents = [];
23
24
  for (const [skillId, metadata] of installedEntries) {
24
- const skillDir = path.resolve(context.cwd, metadata.path);
25
+ const skillDir = resolveInstalledSkillPath(context.cwd, metadata.path);
25
26
  const manifest = (await readJson(path.join(skillDir, "skill.json"), {})) || {};
26
27
  const entry = manifest.entry || "SKILL.md";
27
28
  const rawContent = (await readText(path.join(skillDir, entry), "")) || "";
@@ -40,7 +41,7 @@ export async function loadInstalledSkillDocuments(context) {
40
41
  return documents;
41
42
  }
42
43
  /**
43
- * Synchronizes installed skills into the target file consumed by an adapter.
44
+ * Synchronizes installed skills into the target consumed by an adapter.
44
45
  *
45
46
  * @param options - Sync execution options.
46
47
  * @returns Final sync result.
@@ -50,26 +51,50 @@ export async function syncAdapterFiles(options) {
50
51
  try {
51
52
  const prepared = await prepareSyncAdapterFiles(options);
52
53
  if (!options.dryRun) {
53
- await ensureDir(path.dirname(prepared.absoluteTargetPath));
54
- if (prepared.generatedSourcePath) {
55
- await ensureDir(path.dirname(prepared.generatedSourcePath));
56
- await writeText(prepared.generatedSourcePath, prepared.nextContent);
57
- }
58
- if (prepared.syncMode === "symlink" && prepared.generatedSourcePath) {
54
+ if (prepared.directoryEntries) {
55
+ await ensureDir(prepared.absoluteTargetPath);
59
56
  const createLink = options.linkFactory || createSymlink;
60
- const linkResult = await createLink(prepared.generatedSourcePath, prepared.absoluteTargetPath);
61
- if (linkResult.fallback) {
62
- (options.warn || console.error)(`Aviso: symlink indisponivel para ${prepared.targetPath}; usando copia no lugar.`);
63
- await writeText(prepared.absoluteTargetPath, prepared.nextContent);
64
- await removePath(prepared.generatedSourcePath);
65
- prepared.syncMode = "copy";
57
+ let finalMode = prepared.syncMode;
58
+ for (const entry of prepared.directoryEntries) {
59
+ if (prepared.syncMode === "symlink") {
60
+ const linkResult = await createLink(entry.sourcePath, entry.absoluteTargetPath);
61
+ if (linkResult.fallback) {
62
+ (options.warn || console.error)(`Aviso: symlink indisponivel para ${entry.targetPath}; usando copia no lugar.`);
63
+ await copyPath(entry.sourcePath, entry.absoluteTargetPath);
64
+ finalMode = "copy";
65
+ }
66
+ }
67
+ else {
68
+ await copyPath(entry.sourcePath, entry.absoluteTargetPath);
69
+ }
66
70
  }
71
+ for (const cleanupPath of prepared.cleanupPaths) {
72
+ await removePath(cleanupPath);
73
+ }
74
+ prepared.syncMode = finalMode;
67
75
  }
68
76
  else {
69
- await writeText(prepared.absoluteTargetPath, prepared.nextContent);
70
- }
71
- for (const cleanupPath of prepared.cleanupPaths) {
72
- await removePath(cleanupPath);
77
+ await ensureDir(path.dirname(prepared.absoluteTargetPath));
78
+ if (prepared.generatedSourcePath) {
79
+ await ensureDir(path.dirname(prepared.generatedSourcePath));
80
+ await writeText(prepared.generatedSourcePath, prepared.nextContent);
81
+ }
82
+ if (prepared.syncMode === "symlink" && prepared.generatedSourcePath) {
83
+ const createLink = options.linkFactory || createSymlink;
84
+ const linkResult = await createLink(prepared.generatedSourcePath, prepared.absoluteTargetPath);
85
+ if (linkResult.fallback) {
86
+ (options.warn || console.error)(`Aviso: symlink indisponivel para ${prepared.targetPath}; usando copia no lugar.`);
87
+ await writeText(prepared.absoluteTargetPath, prepared.nextContent);
88
+ await removePath(prepared.generatedSourcePath);
89
+ prepared.syncMode = "copy";
90
+ }
91
+ }
92
+ else {
93
+ await writeText(prepared.absoluteTargetPath, prepared.nextContent);
94
+ }
95
+ for (const cleanupPath of prepared.cleanupPaths) {
96
+ await removePath(cleanupPath);
97
+ }
73
98
  }
74
99
  }
75
100
  return {
@@ -97,66 +122,80 @@ export async function syncAdapterFiles(options) {
97
122
  */
98
123
  export async function prepareSyncAdapterFiles(options) {
99
124
  const adapter = getAdapter(options.adapterId);
100
- const targetPath = path.join(options.cwd, adapter.syncTarget);
101
- const relativeTargetPath = toPosix(path.relative(options.cwd, targetPath));
125
+ const absoluteTargetPath = resolveAdapterTargetPath(adapter, options);
126
+ const targetPath = toDisplayPath(options.cwd, absoluteTargetPath, options.statePaths.scope);
127
+ const cleanupPaths = await resolveCleanupPaths(adapter, options, absoluteTargetPath);
128
+ if (adapter.syncMode === "managed-directory") {
129
+ return prepareManagedDirectorySync({
130
+ adapter,
131
+ absoluteTargetPath,
132
+ targetPath,
133
+ cleanupPaths,
134
+ options,
135
+ });
136
+ }
137
+ if (options.statePaths.scope === "global") {
138
+ throw new SyncError(`Adapter ${adapter.id} nao suporta sync global no momento. Use --scope local.`, "GLOBAL_SYNC_UNSUPPORTED");
139
+ }
140
+ if (!adapter.syncTarget) {
141
+ throw new SyncError(`Adapter ${adapter.id} nao define um alvo de sync.`, "SYNC_TARGET_MISSING");
142
+ }
102
143
  const body = renderInstalledSkills(options.skills);
103
144
  const autoInjectBlock = buildAutoInjectBlock(options.skills);
104
- const cleanupPaths = (adapter.legacySyncTargets || [])
105
- .map((relativePath) => path.join(options.cwd, relativePath))
106
- .filter((absolutePath) => absolutePath !== targetPath);
107
145
  if (adapter.syncMode === "managed-block") {
108
- const existing = (await readText(targetPath, "")) || "";
146
+ const existing = (await readText(absoluteTargetPath, "")) || "";
109
147
  const nextManaged = upsertManagedBlock(existing, wrapManagedBlock(MANAGED_START, MANAGED_END, body));
110
148
  const nextContent = upsertAutoInjectBlock(nextManaged, autoInjectBlock);
111
149
  return {
112
150
  adapter: adapter.id,
113
- absoluteTargetPath: targetPath,
114
- targetPath: relativeTargetPath,
151
+ absoluteTargetPath,
152
+ targetPath,
115
153
  cleanupPaths,
116
- changed: normalizeComparableText(existing) !== normalizeComparableText(nextContent),
154
+ changed: normalizeComparableText(existing) !== normalizeComparableText(nextContent) || cleanupPaths.length > 0,
117
155
  currentContent: existing,
118
156
  nextContent,
119
- diff: createTextDiff(existing, nextContent, relativeTargetPath),
157
+ diff: createTextDiff(existing, nextContent, targetPath),
120
158
  syncMode: "copy",
121
159
  };
122
160
  }
123
161
  const nextContent = buildManagedFileContent(adapter.id, body, autoInjectBlock);
124
162
  const requestedMode = options.mode || "symlink";
125
163
  if (requestedMode === "copy") {
126
- const existing = (await readText(targetPath, "")) || "";
164
+ const existing = (await readText(absoluteTargetPath, "")) || "";
127
165
  return {
128
166
  adapter: adapter.id,
129
- absoluteTargetPath: targetPath,
130
- targetPath: relativeTargetPath,
167
+ absoluteTargetPath,
168
+ targetPath,
131
169
  cleanupPaths,
132
- changed: normalizeComparableText(existing) !== normalizeComparableText(nextContent),
170
+ changed: normalizeComparableText(existing) !== normalizeComparableText(nextContent) || cleanupPaths.length > 0,
133
171
  currentContent: existing,
134
172
  nextContent,
135
- diff: createTextDiff(existing, nextContent, relativeTargetPath),
173
+ diff: createTextDiff(existing, nextContent, targetPath),
136
174
  syncMode: "copy",
137
175
  };
138
176
  }
139
177
  const generatedSourcePath = path.join(options.statePaths.generatedDirPath, adapter.id, path.basename(adapter.syncTarget));
140
- const currentDescriptor = await describeTarget(targetPath);
141
- const currentVisibleContent = (await readText(targetPath, "")) || "";
142
- const nextDescriptor = `symlink -> ${toPosix(generatedSourcePath)}\n`;
178
+ const currentDescriptor = await describeTarget(absoluteTargetPath);
179
+ const currentVisibleContent = (await readText(absoluteTargetPath, "")) || "";
180
+ const nextDescriptor = `symlink -> ${toPosix(path.relative(path.dirname(absoluteTargetPath), generatedSourcePath))}\n`;
143
181
  const descriptorChanged = normalizeComparableText(currentDescriptor) !== normalizeComparableText(nextDescriptor);
144
182
  const contentChanged = normalizeComparableText(currentVisibleContent) !== normalizeComparableText(nextContent);
145
183
  return {
146
184
  adapter: adapter.id,
147
- absoluteTargetPath: targetPath,
148
- targetPath: relativeTargetPath,
185
+ absoluteTargetPath,
186
+ targetPath,
149
187
  cleanupPaths,
150
- changed: descriptorChanged || contentChanged,
188
+ changed: descriptorChanged || contentChanged || cleanupPaths.length > 0,
151
189
  currentContent: currentDescriptor,
152
190
  nextContent,
153
191
  diff: createManagedFileDiff({
154
- targetPath: relativeTargetPath,
192
+ targetPath,
155
193
  currentDescriptor,
156
194
  nextDescriptor,
157
- generatedPath: toPosix(generatedSourcePath),
195
+ generatedPath: toDisplayPath(options.cwd, generatedSourcePath, options.statePaths.scope),
158
196
  currentContent: currentVisibleContent,
159
197
  nextContent,
198
+ cleanupPaths: cleanupPaths.map((cleanupPath) => toDisplayPath(options.cwd, cleanupPath, options.statePaths.scope)),
160
199
  }),
161
200
  syncMode: "symlink",
162
201
  generatedSourcePath,
@@ -206,6 +245,77 @@ export function buildAutoInjectBlock(skills) {
206
245
  ].join("\n");
207
246
  return wrapManagedBlock(AUTO_INJECT_START, AUTO_INJECT_END, body);
208
247
  }
248
+ async function prepareManagedDirectorySync(context) {
249
+ const requestedMode = context.options.mode || "symlink";
250
+ const directoryEntries = await Promise.all(context.options.skills.map(async (skill) => {
251
+ const absoluteSkillTargetPath = path.join(context.absoluteTargetPath, skill.id);
252
+ const targetPath = toDisplayPath(context.options.cwd, absoluteSkillTargetPath, context.options.statePaths.scope);
253
+ const currentDescriptor = await describeTarget(absoluteSkillTargetPath);
254
+ const nextDescriptor = requestedMode === "symlink"
255
+ ? `symlink -> ${toPosix(path.relative(path.dirname(absoluteSkillTargetPath), skill.skillDir))}\n`
256
+ : "directory\n";
257
+ return {
258
+ skillId: skill.id,
259
+ sourcePath: skill.skillDir,
260
+ absoluteTargetPath: absoluteSkillTargetPath,
261
+ targetPath,
262
+ currentDescriptor,
263
+ nextDescriptor,
264
+ };
265
+ }));
266
+ const changed = directoryEntries.some((entry) => normalizeComparableText(entry.currentDescriptor) !== normalizeComparableText(entry.nextDescriptor)) || context.cleanupPaths.length > 0;
267
+ return {
268
+ adapter: context.adapter.id,
269
+ absoluteTargetPath: context.absoluteTargetPath,
270
+ targetPath: context.targetPath,
271
+ cleanupPaths: context.cleanupPaths,
272
+ changed,
273
+ currentContent: "",
274
+ nextContent: "",
275
+ diff: createManagedDirectoryDiff({
276
+ targetPath: context.targetPath,
277
+ entries: directoryEntries,
278
+ cleanupPaths: context.cleanupPaths.map((cleanupPath) => toDisplayPath(context.options.cwd, cleanupPath, context.options.statePaths.scope)),
279
+ }),
280
+ syncMode: requestedMode,
281
+ directoryEntries,
282
+ };
283
+ }
284
+ async function resolveCleanupPaths(adapter, options, absoluteTargetPath) {
285
+ const cleanupPaths = new Set();
286
+ for (const legacyTarget of adapter.legacySyncTargets || []) {
287
+ const resolvedLegacyPath = options.statePaths.scope === "global"
288
+ ? path.join(absoluteTargetPath, path.basename(legacyTarget))
289
+ : path.resolve(options.cwd, legacyTarget);
290
+ if (await pathExists(resolvedLegacyPath)) {
291
+ cleanupPaths.add(resolvedLegacyPath);
292
+ }
293
+ }
294
+ if (adapter.syncMode === "managed-directory") {
295
+ const currentSkillIds = new Set(options.skills.map((skill) => skill.id));
296
+ for (const previousSkillId of options.previousSkillIds || []) {
297
+ if (!currentSkillIds.has(previousSkillId)) {
298
+ const stalePath = path.join(absoluteTargetPath, previousSkillId);
299
+ if (await pathExists(stalePath)) {
300
+ cleanupPaths.add(stalePath);
301
+ }
302
+ }
303
+ }
304
+ }
305
+ return [...cleanupPaths];
306
+ }
307
+ function resolveAdapterTargetPath(adapter, options) {
308
+ if (options.statePaths.scope === "global") {
309
+ if (!adapter.globalSyncTarget) {
310
+ throw new SyncError(`Adapter ${adapter.id} nao suporta sync global no momento. Use --scope local.`, "GLOBAL_SYNC_UNSUPPORTED");
311
+ }
312
+ return path.resolve(adapter.globalSyncTarget);
313
+ }
314
+ if (!adapter.syncTarget) {
315
+ throw new SyncError(`Adapter ${adapter.id} nao define um alvo de sync.`, "SYNC_TARGET_MISSING");
316
+ }
317
+ return path.join(options.cwd, adapter.syncTarget);
318
+ }
209
319
  function renderSkillSection(skill) {
210
320
  const body = skill.body.trim() || "_Sem conteudo._";
211
321
  return [`### ${skill.name} (\`${skill.id}@${skill.version}\`)`, "", body].join("\n");
@@ -237,9 +347,6 @@ function buildManagedFileContent(adapterId, body, autoInjectBlock) {
237
347
  "",
238
348
  ].join("\n");
239
349
  case "cline":
240
- case "codex":
241
- case "claude":
242
- case "gemini":
243
350
  return `${sections.join("\n\n")}\n`;
244
351
  default:
245
352
  throw new SyncError(`Adapter desconhecido: ${adapterId}`, "SYNC_ADAPTER_UNKNOWN");
@@ -279,15 +386,43 @@ async function describeTarget(targetPath) {
279
386
  if (linkTarget) {
280
387
  return `symlink -> ${toPosix(linkTarget)}\n`;
281
388
  }
282
- if (!(await readText(targetPath, null))) {
283
- return "";
389
+ try {
390
+ const stats = await fs.lstat(targetPath);
391
+ if (stats.isDirectory()) {
392
+ return "directory\n";
393
+ }
394
+ if (stats.isFile()) {
395
+ return "file\n";
396
+ }
397
+ return "path\n";
398
+ }
399
+ catch (error) {
400
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
401
+ return "";
402
+ }
403
+ throw error;
284
404
  }
285
- return "file\n";
405
+ }
406
+ function createManagedDirectoryDiff(context) {
407
+ const parts = [];
408
+ for (const entry of context.entries) {
409
+ if (normalizeComparableText(entry.currentDescriptor) === normalizeComparableText(entry.nextDescriptor)) {
410
+ continue;
411
+ }
412
+ parts.push(createTextDiff(entry.currentDescriptor, entry.nextDescriptor, entry.targetPath).trimEnd());
413
+ }
414
+ for (const cleanupPath of context.cleanupPaths) {
415
+ parts.push(`- remove ${cleanupPath}`);
416
+ }
417
+ if (parts.length === 0) {
418
+ return `Sem alteracoes em ${context.targetPath}.\n`;
419
+ }
420
+ return `${parts.join("\n")}\n`;
286
421
  }
287
422
  function createManagedFileDiff(context) {
288
423
  const descriptorChanged = normalizeComparableText(context.currentDescriptor) !== normalizeComparableText(context.nextDescriptor);
289
424
  const contentChanged = normalizeComparableText(context.currentContent) !== normalizeComparableText(context.nextContent);
290
- if (!descriptorChanged && !contentChanged) {
425
+ if (!descriptorChanged && !contentChanged && context.cleanupPaths.length === 0) {
291
426
  return `Sem alteracoes em ${context.targetPath}.\n`;
292
427
  }
293
428
  const parts = [];
@@ -297,6 +432,9 @@ function createManagedFileDiff(context) {
297
432
  if (contentChanged) {
298
433
  parts.push(createTextDiff(context.currentContent, context.nextContent, context.generatedPath).trimEnd());
299
434
  }
435
+ for (const cleanupPath of context.cleanupPaths) {
436
+ parts.push(`- remove ${cleanupPath}`);
437
+ }
300
438
  return `${parts.join("\n")}\n`;
301
439
  }
302
440
  function createTextDiff(currentContent, nextContent, targetPath) {
@@ -379,6 +517,12 @@ function diffLines(leftLines, rightLines) {
379
517
  }
380
518
  return operations;
381
519
  }
520
+ function resolveInstalledSkillPath(cwd, skillPath) {
521
+ return path.isAbsolute(skillPath) ? skillPath : path.resolve(cwd, skillPath);
522
+ }
523
+ function toDisplayPath(cwd, targetPath, scope) {
524
+ return scope === "local" ? toPosix(path.relative(cwd, targetPath)) : toPosix(targetPath);
525
+ }
382
526
  function escapeRegExp(value) {
383
527
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
384
528
  }
package/dist/types.d.ts CHANGED
@@ -4,11 +4,15 @@
4
4
  /**
5
5
  * Supported sync file rendering modes.
6
6
  */
7
- export type SyncMode = "managed-block" | "managed-file";
7
+ export type SyncMode = "managed-block" | "managed-file" | "managed-directory";
8
8
  /**
9
9
  * Workspace sync write modes.
10
10
  */
11
11
  export type SyncWriteMode = "symlink" | "copy";
12
+ /**
13
+ * Supported install scopes.
14
+ */
15
+ export type InstallScope = "local" | "global";
12
16
  /**
13
17
  * Marker used to detect an adapter in a workspace.
14
18
  */
@@ -23,7 +27,8 @@ export interface AdapterConfig {
23
27
  id: string;
24
28
  label: string;
25
29
  markers: AdapterMarker[];
26
- syncTarget: string;
30
+ syncTarget?: string;
31
+ globalSyncTarget?: string;
27
32
  legacySyncTargets?: string[];
28
33
  syncMode: SyncMode;
29
34
  }
@@ -165,6 +170,7 @@ export interface SyncMetadata {
165
170
  adapter: string;
166
171
  targetPath: string;
167
172
  syncedAt: string;
173
+ skillIds?: string[] | undefined;
168
174
  }
169
175
  /**
170
176
  * Full workspace lockfile structure.
@@ -202,6 +208,7 @@ export interface ResolvedSkillSelection {
202
208
  * Common filesystem paths used by the local state manager.
203
209
  */
204
210
  export interface StatePaths {
211
+ scope: InstallScope;
205
212
  stateDir: string;
206
213
  lockfilePath: string;
207
214
  skillsDirPath: string;
@@ -212,6 +219,7 @@ export interface StatePaths {
212
219
  */
213
220
  export interface ProjectOptions extends CatalogSourceInput {
214
221
  cwd?: string | undefined;
222
+ scope?: InstallScope | undefined;
215
223
  agentSkillsDir?: string | undefined;
216
224
  adapter?: string | undefined;
217
225
  autoSync?: boolean | undefined;
@@ -261,6 +269,18 @@ export interface PreparedSyncResult {
261
269
  diff: string;
262
270
  syncMode: SyncWriteMode;
263
271
  generatedSourcePath?: string | undefined;
272
+ directoryEntries?: PreparedDirectoryEntry[] | undefined;
273
+ }
274
+ /**
275
+ * Prepared directory entry for directory-native adapter sync.
276
+ */
277
+ export interface PreparedDirectoryEntry {
278
+ skillId: string;
279
+ sourcePath: string;
280
+ absoluteTargetPath: string;
281
+ targetPath: string;
282
+ currentDescriptor: string;
283
+ nextDescriptor: string;
264
284
  }
265
285
  /**
266
286
  * Dry-run preview returned before a sync writes files.
@@ -286,9 +306,11 @@ export interface SyncResult {
286
306
  */
287
307
  export interface SyncOptions {
288
308
  cwd: string;
309
+ scope?: InstallScope | undefined;
289
310
  adapterId: string;
290
311
  statePaths: StatePaths;
291
312
  skills: InstalledSkillDocument[];
313
+ previousSkillIds?: string[] | undefined;
292
314
  mode?: SyncWriteMode | undefined;
293
315
  dryRun?: boolean | undefined;
294
316
  linkFactory?: ((targetPath: string, linkPath: string) => Promise<CreateSymlinkResult>) | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillex",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "CLI to list, install, and synchronize AI agent skills from GitHub-hosted catalogs.",
5
5
  "type": "module",
6
6
  "repository": {