loadouts 0.2.0 → 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.
Files changed (50) hide show
  1. package/README.md +3 -1
  2. package/dist/builtins/index.d.ts.map +1 -1
  3. package/dist/builtins/index.js +26 -13
  4. package/dist/builtins/index.js.map +1 -1
  5. package/dist/builtins/tools/cursor.js +2 -2
  6. package/dist/builtins/tools/cursor.js.map +1 -1
  7. package/dist/builtins/tools/opencode.d.ts.map +1 -1
  8. package/dist/builtins/tools/opencode.js +8 -2
  9. package/dist/builtins/tools/opencode.js.map +1 -1
  10. package/dist/cli/commands/check.d.ts.map +1 -1
  11. package/dist/cli/commands/check.js +17 -1
  12. package/dist/cli/commands/check.js.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts.map +1 -1
  14. package/dist/cli/commands/doctor.js +484 -50
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/init.d.ts.map +1 -1
  17. package/dist/cli/commands/init.js +6 -1
  18. package/dist/cli/commands/init.js.map +1 -1
  19. package/dist/cli/commands/install.d.ts +6 -3
  20. package/dist/cli/commands/install.d.ts.map +1 -1
  21. package/dist/cli/commands/install.js +225 -87
  22. package/dist/cli/commands/install.js.map +1 -1
  23. package/dist/cli/commands/rule.js +11 -7
  24. package/dist/cli/commands/rule.js.map +1 -1
  25. package/dist/cli/commands/sanitize.d.ts +3 -2
  26. package/dist/cli/commands/sanitize.d.ts.map +1 -1
  27. package/dist/cli/commands/sanitize.js +27 -13
  28. package/dist/cli/commands/sanitize.js.map +1 -1
  29. package/dist/cli/commands/skill.d.ts.map +1 -1
  30. package/dist/cli/commands/skill.js +23 -14
  31. package/dist/cli/commands/skill.js.map +1 -1
  32. package/dist/cli/commands/status.d.ts.map +1 -1
  33. package/dist/cli/commands/status.js +15 -10
  34. package/dist/cli/commands/status.js.map +1 -1
  35. package/dist/core/config.d.ts +73 -13
  36. package/dist/core/config.d.ts.map +1 -1
  37. package/dist/core/config.js +224 -34
  38. package/dist/core/config.js.map +1 -1
  39. package/dist/core/import-discovery.d.ts +9 -4
  40. package/dist/core/import-discovery.d.ts.map +1 -1
  41. package/dist/core/import-discovery.js +414 -141
  42. package/dist/core/import-discovery.js.map +1 -1
  43. package/dist/core/schema.d.ts +91 -11
  44. package/dist/core/schema.d.ts.map +1 -1
  45. package/dist/core/schema.js +28 -0
  46. package/dist/core/schema.js.map +1 -1
  47. package/docs/commands.md +4 -2
  48. package/docs/index.md +1 -0
  49. package/docs/quickstart.md +4 -1
  50. package/package.json +1 -1
@@ -3,34 +3,14 @@
3
3
  * that can be imported into loadout.
4
4
  */
5
5
  import * as fs from "node:fs";
6
+ import * as os from "node:os";
6
7
  import * as path from "node:path";
7
- import { fileExists, isDirectory, isSymlink, listFiles, listFilesWithExtension } from "../lib/fs.js";
8
- const TOOL_LOCATIONS = {
9
- "claude-code": {
10
- rules: { dir: ".claude/rules", ext: ".md" },
11
- skills: { dir: ".claude/skills" },
12
- },
13
- cursor: {
14
- rules: { dir: ".cursor/rules", ext: ".mdc" },
15
- skills: { dir: ".cursor/skills" },
16
- },
17
- opencode: {
18
- rules: { dir: ".opencode/rules", ext: ".md" },
19
- skills: { dir: ".opencode/skills" },
20
- },
21
- codex: {
22
- // codex doesn't support rules
23
- skills: { dir: ".agents/skills" },
24
- },
25
- pi: {
26
- // pi doesn't have native rules support
27
- skills: { dir: ".pi/skills" },
28
- },
29
- };
8
+ import { fileExists, isDirectory, isSymlink, listFiles, walkDir } from "../lib/fs.js";
9
+ import { registry } from "./registry.js";
30
10
  // Instruction file locations to check (in priority order)
31
11
  const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
32
12
  // ---------------------------------------------------------------------------
33
- // Discovery functions
13
+ // Filesystem helpers
34
14
  // ---------------------------------------------------------------------------
35
15
  /**
36
16
  * Get file/directory size.
@@ -53,86 +33,228 @@ function getSize(filePath) {
53
33
  function getMtime(filePath) {
54
34
  return fs.statSync(filePath).mtime;
55
35
  }
36
+ function readArtifactMetadata(filePath) {
37
+ try {
38
+ return {
39
+ size: getSize(filePath),
40
+ mtime: getMtime(filePath),
41
+ };
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
56
47
  /**
57
- * Discover rules in a tool directory.
48
+ * Convert Windows path separators to POSIX-style separators.
58
49
  */
59
- function discoverRules(projectRoot, tool, locations) {
60
- if (!locations.rules)
61
- return [];
62
- const rulesDir = path.join(projectRoot, locations.rules.dir);
63
- if (!isDirectory(rulesDir))
64
- return [];
50
+ function toPosix(filePath) {
51
+ return filePath.split(path.sep).join("/");
52
+ }
53
+ /**
54
+ * Escape string for safe regex inclusion.
55
+ */
56
+ function escapeRegex(value) {
57
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
58
+ }
59
+ /**
60
+ * Select the path template for the requested scope.
61
+ */
62
+ function getScopeTemplate(mappingPath, scope) {
63
+ return typeof mappingPath === "string" ? mappingPath : mappingPath[scope];
64
+ }
65
+ /**
66
+ * Resolve a tool base path to absolute for the current scope.
67
+ */
68
+ function resolveAbsoluteBasePath(scanRoot, basePath) {
69
+ return path.isAbsolute(basePath) ? basePath : path.join(scanRoot, basePath);
70
+ }
71
+ /**
72
+ * Format a path for display relative to scan root when possible.
73
+ */
74
+ function toDisplayPath(scanRoot, absolutePath) {
75
+ const rel = path.relative(scanRoot, absolutePath);
76
+ if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
77
+ return toPosix(rel);
78
+ }
79
+ const home = os.homedir();
80
+ if (absolutePath === home)
81
+ return "~";
82
+ if (absolutePath.startsWith(`${home}${path.sep}`)) {
83
+ return `~/${toPosix(path.relative(home, absolutePath))}`;
84
+ }
85
+ return toPosix(absolutePath);
86
+ }
87
+ function isPathWithin(parentPath, childPath) {
88
+ const rel = path.relative(parentPath, childPath);
89
+ return rel === "" || (!!rel && !rel.startsWith("..") && !path.isAbsolute(rel));
90
+ }
91
+ function matchesSourcePath(artifact, sourcePath) {
92
+ if (artifact.kind === "skill" && isPathWithin(artifact.sourcePath, sourcePath)) {
93
+ return true;
94
+ }
95
+ return isPathWithin(sourcePath, artifact.sourcePath) || artifact.sourcePath === sourcePath;
96
+ }
97
+ function sourceAllowsKind(kindId, kinds) {
98
+ return !kinds || kinds.length === 0 || kinds.includes(kindId);
99
+ }
100
+ function buildSourceArtifact(scanRoot, kind, sourcePath, destPath, name) {
101
+ const metadata = readArtifactMetadata(sourcePath);
102
+ if (!metadata)
103
+ return null;
104
+ return {
105
+ kind,
106
+ name,
107
+ sourcePath,
108
+ displayPath: toDisplayPath(scanRoot, sourcePath),
109
+ tool: "source",
110
+ size: metadata.size,
111
+ mtime: metadata.mtime,
112
+ destPath,
113
+ };
114
+ }
115
+ function buildRuleSourceArtifact(scanRoot, sourcePath) {
116
+ const ext = path.extname(sourcePath).toLowerCase();
117
+ if (ext !== ".md" && ext !== ".mdc")
118
+ return null;
119
+ const stem = path.basename(sourcePath, ext);
120
+ if (!stem || stem === "AGENTS" || stem === "CLAUDE")
121
+ return null;
122
+ return buildSourceArtifact(scanRoot, "rule", sourcePath, `rules/${stem}.md`, stem);
123
+ }
124
+ function buildInstructionSourceArtifact(scanRoot, sourcePath) {
125
+ const basename = path.basename(sourcePath);
126
+ if (basename !== "AGENTS.md" && basename !== "CLAUDE.md" && !/^AGENTS\.[^.]+\.md$/.test(basename)) {
127
+ return null;
128
+ }
129
+ return buildSourceArtifact(scanRoot, "instruction", sourcePath, "instructions/AGENTS.base.md", "AGENTS.md");
130
+ }
131
+ function buildSkillSourceArtifact(scanRoot, skillDir) {
132
+ const skillMdPath = path.join(skillDir, "SKILL.md");
133
+ if (!fileExists(skillMdPath))
134
+ return null;
135
+ const name = path.basename(skillDir);
136
+ return buildSourceArtifact(scanRoot, "skill", skillDir, `skills/${name}`, name);
137
+ }
138
+ function discoverDirectSourceArtifacts(scanRoot, sourcePath, kinds) {
65
139
  const artifacts = [];
66
- const files = listFilesWithExtension(rulesDir, locations.rules.ext);
67
- for (const file of files) {
68
- const sourcePath = path.join(rulesDir, file);
69
- const name = file.replace(/\.(md|mdc)$/, "");
70
- artifacts.push({
71
- kind: "rule",
72
- name,
73
- sourcePath,
74
- displayPath: path.join(locations.rules.dir, file),
75
- tool,
76
- size: getSize(sourcePath),
77
- mtime: getMtime(sourcePath),
78
- destPath: `rules/${name}.md`,
79
- });
140
+ if (!fileExists(sourcePath))
141
+ return artifacts;
142
+ if (!isDirectory(sourcePath)) {
143
+ if (sourceAllowsKind("instruction", kinds)) {
144
+ const instruction = buildInstructionSourceArtifact(scanRoot, sourcePath);
145
+ if (instruction)
146
+ artifacts.push(instruction);
147
+ }
148
+ if (sourceAllowsKind("skill", kinds) && path.basename(sourcePath) === "SKILL.md") {
149
+ const skill = buildSkillSourceArtifact(scanRoot, path.dirname(sourcePath));
150
+ if (skill)
151
+ artifacts.push(skill);
152
+ }
153
+ if (sourceAllowsKind("rule", kinds)) {
154
+ const rule = buildRuleSourceArtifact(scanRoot, sourcePath);
155
+ if (rule)
156
+ artifacts.push(rule);
157
+ }
158
+ return artifacts;
159
+ }
160
+ if (sourceAllowsKind("skill", kinds)) {
161
+ const directSkill = buildSkillSourceArtifact(scanRoot, sourcePath);
162
+ if (directSkill)
163
+ artifacts.push(directSkill);
164
+ }
165
+ const basename = path.basename(sourcePath);
166
+ const rulesRoot = basename === "rules" ? sourcePath : path.join(sourcePath, "rules");
167
+ if (sourceAllowsKind("rule", kinds) && isDirectory(rulesRoot)) {
168
+ for (const rel of walkDir(rulesRoot)) {
169
+ const rule = buildRuleSourceArtifact(scanRoot, path.join(rulesRoot, rel));
170
+ if (rule)
171
+ artifacts.push(rule);
172
+ }
173
+ }
174
+ const skillsRoot = basename === "skills" ? sourcePath : path.join(sourcePath, "skills");
175
+ if (sourceAllowsKind("skill", kinds) && isDirectory(skillsRoot)) {
176
+ for (const entry of listFiles(skillsRoot)) {
177
+ const skillDir = path.join(skillsRoot, entry);
178
+ if (!isDirectory(skillDir))
179
+ continue;
180
+ const skill = buildSkillSourceArtifact(scanRoot, skillDir);
181
+ if (skill)
182
+ artifacts.push(skill);
183
+ }
184
+ }
185
+ const instructionsRoot = basename === "instructions" ? sourcePath : path.join(sourcePath, "instructions");
186
+ if (sourceAllowsKind("instruction", kinds) && isDirectory(instructionsRoot)) {
187
+ for (const rel of walkDir(instructionsRoot)) {
188
+ const instruction = buildInstructionSourceArtifact(scanRoot, path.join(instructionsRoot, rel));
189
+ if (instruction)
190
+ artifacts.push(instruction);
191
+ }
80
192
  }
81
193
  return artifacts;
82
194
  }
195
+ function dedupeArtifacts(artifacts) {
196
+ const seen = new Set();
197
+ const deduped = [];
198
+ for (const artifact of artifacts) {
199
+ const key = `${artifact.kind}:${artifact.sourcePath}:${artifact.destPath}`;
200
+ if (seen.has(key))
201
+ continue;
202
+ seen.add(key);
203
+ deduped.push(artifact);
204
+ }
205
+ return deduped;
206
+ }
83
207
  /**
84
- * Discover skills in a tool directory.
85
- * Skips symlinked directories that point to other tool directories
86
- * to avoid duplicate detection.
208
+ * Build a regex matcher for a concrete template path.
209
+ * Supported tokens: {base} {home} {stem} {ext} {name} {relative} {kind}
87
210
  */
88
- function discoverSkills(projectRoot, tool, locations) {
89
- if (!locations.skills)
90
- return [];
91
- const skillsDir = path.join(projectRoot, locations.skills.dir);
92
- if (!isDirectory(skillsDir))
93
- return [];
94
- // Skip if this skills dir is a symlink pointing to another tool's directory
95
- // (e.g., .cursor/skills -> ../.claude/skills)
96
- if (isSymlink(skillsDir)) {
97
- try {
98
- const target = fs.realpathSync(skillsDir);
99
- // Check if target is inside another tool's directory
100
- for (const [otherTool, otherLoc] of Object.entries(TOOL_LOCATIONS)) {
101
- if (otherTool === tool || !otherLoc.skills)
102
- continue;
103
- const otherSkillsDir = path.join(projectRoot, otherLoc.skills.dir);
104
- if (target.startsWith(fs.realpathSync(path.dirname(otherSkillsDir)))) {
105
- // This is a symlink to another tool's skills - skip to avoid duplicates
106
- return [];
107
- }
211
+ function matchTemplatePath(templatePath, actualPath) {
212
+ const captures = {};
213
+ const tokens = [];
214
+ let pattern = "";
215
+ for (let i = 0; i < templatePath.length; i += 1) {
216
+ const ch = templatePath[i];
217
+ if (ch === "{") {
218
+ const end = templatePath.indexOf("}", i + 1);
219
+ if (end === -1) {
220
+ pattern += escapeRegex(ch);
221
+ continue;
108
222
  }
223
+ const token = templatePath.slice(i + 1, end);
224
+ tokens.push(token);
225
+ switch (token) {
226
+ case "stem":
227
+ case "name":
228
+ case "kind":
229
+ pattern += "([^/]+)";
230
+ break;
231
+ case "ext":
232
+ pattern += "([^/]*)";
233
+ break;
234
+ case "relative":
235
+ pattern += "(.+)";
236
+ break;
237
+ case "base":
238
+ case "home":
239
+ pattern += "(.+)";
240
+ break;
241
+ default:
242
+ pattern += "(.+)";
243
+ break;
244
+ }
245
+ i = end;
246
+ continue;
109
247
  }
110
- catch {
111
- // If we can't resolve the symlink, proceed normally
112
- }
248
+ pattern += escapeRegex(ch);
113
249
  }
114
- const artifacts = [];
115
- const entries = listFiles(skillsDir);
116
- for (const entry of entries) {
117
- const skillPath = path.join(skillsDir, entry);
118
- if (!isDirectory(skillPath))
119
- continue;
120
- // Check for SKILL.md to confirm it's a valid skill
121
- const skillMdPath = path.join(skillPath, "SKILL.md");
122
- if (!fileExists(skillMdPath))
123
- continue;
124
- artifacts.push({
125
- kind: "skill",
126
- name: entry,
127
- sourcePath: skillPath,
128
- displayPath: path.join(locations.skills.dir, entry),
129
- tool,
130
- size: getSize(skillPath),
131
- mtime: getMtime(skillPath),
132
- destPath: `skills/${entry}`,
133
- });
250
+ const regex = new RegExp(`^${pattern}$`);
251
+ const match = actualPath.match(regex);
252
+ if (!match)
253
+ return { matched: false, captures };
254
+ for (let i = 0; i < tokens.length; i += 1) {
255
+ captures[tokens[i]] = match[i + 1] ?? "";
134
256
  }
135
- return artifacts;
257
+ return { matched: true, captures };
136
258
  }
137
259
  /**
138
260
  * Check if a file at project root is managed by loadout.
@@ -168,90 +290,244 @@ function isClaudeWrapper(filePath) {
168
290
  return false;
169
291
  }
170
292
  }
293
+ /**
294
+ * Infer destination path for a discovered artifact.
295
+ *
296
+ * For built-ins we preserve canonical layout.
297
+ * For custom/future kinds we try several heuristics and keep whichever
298
+ * path is accepted by the kind's detect() predicate.
299
+ */
300
+ function inferDestPath(kindId, toolName, strippedTargetPath, captures) {
301
+ const kind = registry.getKind(kindId);
302
+ if (!kind)
303
+ return null;
304
+ const stem = captures.stem || captures.name || path.basename(strippedTargetPath, path.extname(strippedTargetPath));
305
+ const ext = captures.ext || path.extname(strippedTargetPath);
306
+ // Canonical built-ins.
307
+ if (kindId === "instruction") {
308
+ return "instructions/AGENTS.base.md";
309
+ }
310
+ if (kindId === "rule") {
311
+ return `rules/${stem}.md`;
312
+ }
313
+ const candidates = [
314
+ strippedTargetPath,
315
+ `${toolName}/${strippedTargetPath}`,
316
+ `${kindId}/${strippedTargetPath}`,
317
+ `${kindId.replace(/-/g, "/")}/${strippedTargetPath}`,
318
+ `${kindId}/${stem}${ext}`,
319
+ `${kindId.replace(/-/g, "/")}/${stem}${ext}`,
320
+ ];
321
+ for (const candidate of candidates) {
322
+ const rel = toPosix(candidate).replace(/^\/+/, "");
323
+ if (kind.detect(rel)) {
324
+ return rel;
325
+ }
326
+ }
327
+ return null;
328
+ }
329
+ /**
330
+ * Check whether a given destination path already exists in .loadouts/.
331
+ */
332
+ function artifactExistsInLoadout(loadoutPath, artifact) {
333
+ if (artifact.kind === "instruction") {
334
+ return false;
335
+ }
336
+ const kind = registry.getKind(artifact.kind);
337
+ if (!kind)
338
+ return false;
339
+ const destPath = path.join(loadoutPath, artifact.destPath);
340
+ return kind.layout === "dir" ? isDirectory(destPath) : fileExists(destPath);
341
+ }
171
342
  /**
172
343
  * Discover instruction files at project root.
173
344
  * Only discovers files that are NOT managed by loadout.
174
345
  */
175
- function discoverInstructions(projectRoot, loadoutPath) {
346
+ function discoverInstructions(scanRoot, loadoutPath) {
347
+ const kind = registry.getKind("instruction");
348
+ if (!kind)
349
+ return [];
176
350
  const artifacts = [];
177
351
  for (const filename of INSTRUCTION_FILES) {
178
- const filePath = path.join(projectRoot, filename);
352
+ const filePath = path.join(scanRoot, filename);
179
353
  if (!fileExists(filePath))
180
354
  continue;
181
- // Skip if this file is managed by loadout (symlink to .loadouts/)
182
355
  if (isManagedByLoadout(filePath, loadoutPath))
183
356
  continue;
184
- // Skip auto-generated CLAUDE.md wrapper
185
357
  if (filename === "CLAUDE.md" && isClaudeWrapper(filePath))
186
358
  continue;
359
+ const metadata = readArtifactMetadata(filePath);
360
+ if (!metadata)
361
+ continue;
187
362
  artifacts.push({
188
363
  kind: "instruction",
189
364
  name: "AGENTS.md",
190
365
  sourcePath: filePath,
191
366
  displayPath: filename,
192
367
  tool: "project-root",
193
- size: getSize(filePath),
194
- mtime: getMtime(filePath),
195
- destPath: "instructions/AGENTS.base.md", // Will be updated by install based on target loadout
368
+ size: metadata.size,
369
+ mtime: metadata.mtime,
370
+ destPath: "instructions/AGENTS.base.md",
196
371
  });
197
372
  }
198
373
  return artifacts;
199
374
  }
200
375
  /**
201
- * Check if an artifact already exists in .loadouts/.
202
- * For instructions, we DON'T filter them out since unmanaged files at project root
203
- * should be offered for import (to replace the template).
376
+ * Discover artifacts for one (tool, kind) pair by matching tool target templates.
204
377
  */
205
- function artifactExistsInLoadout(loadoutPath, artifact) {
206
- // Instructions at project root are already filtered by discoverInstructions
207
- // if they're managed. If they reach here, they're unmanaged and should be imported.
208
- if (artifact.kind === "instruction") {
209
- return false; // Always offer to import unmanaged instruction files
378
+ function discoverForToolKind(scanRoot, scope, toolName, kindId, warnings) {
379
+ // Instruction files are discovered centrally at project root to avoid
380
+ // duplicate detection across tools that map to AGENTS.md / CLAUDE.md.
381
+ if (kindId === "instruction")
382
+ return [];
383
+ const tool = registry.getTool(toolName);
384
+ const kind = registry.getKind(kindId);
385
+ if (!tool || !kind)
386
+ return [];
387
+ const mapping = registry.resolveMapping(toolName, kindId);
388
+ if (!mapping)
389
+ return [];
390
+ const template = getScopeTemplate(mapping.path, scope);
391
+ const absoluteBasePath = resolveAbsoluteBasePath(scanRoot, tool.basePath[scope]);
392
+ const concreteTemplate = toPosix(template)
393
+ .replaceAll("{base}", toPosix(absoluteBasePath))
394
+ .replaceAll("{home}", toPosix(os.homedir()));
395
+ const parentTemplate = path.posix.dirname(concreteTemplate);
396
+ const leafTemplate = path.posix.basename(concreteTemplate);
397
+ const templateIsAbsolute = path.posix.isAbsolute(concreteTemplate);
398
+ // If parent path still has unresolved tokens, we cannot reliably scan.
399
+ if (/{[^}]+}/.test(parentTemplate)) {
400
+ warnings.push(`Skipped ${toolName}:${kindId} import scan (unsupported template parent: ${template})`);
401
+ return [];
210
402
  }
211
- const destPath = path.join(loadoutPath, artifact.destPath);
212
- return artifact.kind === "skill" ? isDirectory(destPath) : fileExists(destPath);
403
+ const parentAbs = templateIsAbsolute
404
+ ? parentTemplate
405
+ : path.join(scanRoot, parentTemplate);
406
+ if (!isDirectory(parentAbs))
407
+ return [];
408
+ let candidateAbsPaths = [];
409
+ if (kind.layout === "dir") {
410
+ // Prefer one-level dir scanning for templates like .../{name}.
411
+ if (leafTemplate === "{name}" || leafTemplate === "{stem}") {
412
+ candidateAbsPaths = listFiles(parentAbs)
413
+ .map((entry) => path.join(parentAbs, entry))
414
+ .filter((absolutePath) => isDirectory(absolutePath));
415
+ }
416
+ else if (!/{[^}]+}/.test(leafTemplate)) {
417
+ const absolutePath = path.join(parentAbs, leafTemplate);
418
+ if (isDirectory(absolutePath)) {
419
+ candidateAbsPaths = [absolutePath];
420
+ }
421
+ }
422
+ }
423
+ else {
424
+ if (/{relative}/.test(template)) {
425
+ candidateAbsPaths = walkDir(parentAbs).map((entry) => path.join(parentAbs, entry));
426
+ }
427
+ else if (!/{[^}]+}/.test(leafTemplate)) {
428
+ const absolutePath = path.join(parentAbs, leafTemplate);
429
+ if (fileExists(absolutePath)) {
430
+ candidateAbsPaths = [absolutePath];
431
+ }
432
+ }
433
+ else {
434
+ candidateAbsPaths = listFiles(parentAbs)
435
+ .map((entry) => path.join(parentAbs, entry))
436
+ .filter((absolutePath) => fileExists(absolutePath));
437
+ }
438
+ }
439
+ const artifacts = [];
440
+ for (const sourcePath of candidateAbsPaths) {
441
+ const sourcePathPosix = toPosix(sourcePath);
442
+ const matchPath = templateIsAbsolute
443
+ ? sourcePathPosix
444
+ : toPosix(path.relative(scanRoot, sourcePath));
445
+ const match = matchTemplatePath(concreteTemplate, matchPath);
446
+ if (!match.matched)
447
+ continue;
448
+ // Skip tool directories symlinked to another tool to avoid duplicates.
449
+ if (kind.layout === "dir" && isSymlink(sourcePath)) {
450
+ continue;
451
+ }
452
+ const basePrefix = `${toPosix(absoluteBasePath)}/`;
453
+ const stripped = sourcePathPosix.startsWith(basePrefix)
454
+ ? sourcePathPosix.slice(basePrefix.length)
455
+ : matchPath;
456
+ const destPath = inferDestPath(kindId, toolName, stripped, match.captures);
457
+ if (!destPath) {
458
+ warnings.push(`Skipped ${toolName}:${kindId} artifact (cannot infer loadout path for ${matchPath})`);
459
+ continue;
460
+ }
461
+ const metadata = readArtifactMetadata(sourcePath);
462
+ if (!metadata) {
463
+ warnings.push(`Skipped ${toolName}:${kindId} artifact (unreadable path: ${toDisplayPath(scanRoot, sourcePath)})`);
464
+ continue;
465
+ }
466
+ const name = kind.layout === "dir"
467
+ ? path.basename(destPath)
468
+ : path.basename(destPath, path.extname(destPath));
469
+ artifacts.push({
470
+ kind: kindId,
471
+ name,
472
+ sourcePath,
473
+ displayPath: toDisplayPath(scanRoot, sourcePath),
474
+ tool: toolName,
475
+ size: metadata.size,
476
+ mtime: metadata.mtime,
477
+ destPath,
478
+ });
479
+ }
480
+ return artifacts;
213
481
  }
482
+ // ---------------------------------------------------------------------------
483
+ // Public discovery API
484
+ // ---------------------------------------------------------------------------
214
485
  /**
215
486
  * Discover all importable artifacts in a project.
216
487
  */
217
- export function discoverImportableArtifacts(projectRoot, options = {}) {
488
+ export function discoverImportableArtifacts(scanRoot, options = {}) {
218
489
  const artifacts = [];
219
490
  const warnings = [];
220
- const { tools, kinds, loadoutPath } = options;
221
- // Filter tools if specified
491
+ const { scope = "project", tools, kinds, loadoutPath } = options;
492
+ const sourcePath = options.sourcePath ? path.resolve(options.sourcePath) : undefined;
493
+ const allTools = registry.allToolNames();
222
494
  const toolsToScan = tools
223
- ? Object.keys(TOOL_LOCATIONS).filter((t) => tools.includes(t))
224
- : Object.keys(TOOL_LOCATIONS);
225
- // Discover from each tool directory
226
- for (const tool of toolsToScan) {
227
- const locations = TOOL_LOCATIONS[tool];
228
- // Rules
229
- if (!kinds || kinds.includes("rule")) {
230
- artifacts.push(...discoverRules(projectRoot, tool, locations));
231
- }
232
- // Skills
233
- if (!kinds || kinds.includes("skill")) {
234
- artifacts.push(...discoverSkills(projectRoot, tool, locations));
495
+ ? allTools.filter((tool) => tools.includes(tool))
496
+ : allTools;
497
+ const allKinds = registry.allKinds().map((kind) => kind.id);
498
+ const kindsToScan = kinds && kinds.length > 0
499
+ ? allKinds.filter((kind) => kinds.includes(kind))
500
+ : allKinds;
501
+ for (const toolName of toolsToScan) {
502
+ for (const kindId of kindsToScan) {
503
+ artifacts.push(...discoverForToolKind(scanRoot, scope, toolName, kindId, warnings));
235
504
  }
236
505
  }
237
- // Instructions (not tool-specific)
238
- if (!kinds || kinds.includes("instruction")) {
239
- artifacts.push(...discoverInstructions(projectRoot, loadoutPath));
506
+ // Keep root-level instruction import behavior for unmanaged AGENTS/CLAUDE files.
507
+ if (!kinds || kinds.length === 0 || kinds.includes("instruction")) {
508
+ artifacts.push(...discoverInstructions(scanRoot, loadoutPath));
509
+ }
510
+ if (sourcePath) {
511
+ artifacts.push(...discoverDirectSourceArtifacts(scanRoot, sourcePath, kinds));
240
512
  }
241
- // Filter out artifacts that already exist in .loadouts/
242
- const filteredArtifacts = loadoutPath
243
- ? artifacts.filter((a) => !artifactExistsInLoadout(loadoutPath, a))
513
+ // Filter out artifacts already present in .loadouts/.
514
+ const existingFilteredArtifacts = loadoutPath
515
+ ? artifacts.filter((artifact) => !artifactExistsInLoadout(loadoutPath, artifact))
244
516
  : artifacts;
245
- // Detect conflicts (same name from different tools)
246
- const byName = new Map();
517
+ const sourceFilteredArtifacts = sourcePath
518
+ ? existingFilteredArtifacts.filter((artifact) => matchesSourcePath(artifact, sourcePath))
519
+ : existingFilteredArtifacts;
520
+ const filteredArtifacts = dedupeArtifacts(sourceFilteredArtifacts);
521
+ // Detect conflicts by canonical destination path.
522
+ const byDest = new Map();
247
523
  for (const artifact of filteredArtifacts) {
248
- const key = `${artifact.kind}:${artifact.name}`;
249
- const existing = byName.get(key) || [];
524
+ const key = `${artifact.kind}:${artifact.destPath}`;
525
+ const existing = byDest.get(key) ?? [];
250
526
  existing.push(artifact);
251
- byName.set(key, existing);
527
+ byDest.set(key, existing);
252
528
  }
253
529
  const conflicts = new Map();
254
- for (const [key, items] of byName) {
530
+ for (const [key, items] of byDest) {
255
531
  if (items.length > 1) {
256
532
  conflicts.set(key, items);
257
533
  }
@@ -262,12 +538,9 @@ export function discoverImportableArtifacts(projectRoot, options = {}) {
262
538
  * Group artifacts by kind for display.
263
539
  */
264
540
  export function groupByKind(artifacts) {
265
- const groups = {
266
- instruction: [],
267
- rule: [],
268
- skill: [],
269
- };
541
+ const groups = {};
270
542
  for (const artifact of artifacts) {
543
+ groups[artifact.kind] ??= [];
271
544
  groups[artifact.kind].push(artifact);
272
545
  }
273
546
  return groups;