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.
- package/README.md +3 -1
- package/dist/builtins/index.d.ts.map +1 -1
- package/dist/builtins/index.js +26 -13
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/tools/cursor.js +2 -2
- package/dist/builtins/tools/cursor.js.map +1 -1
- package/dist/builtins/tools/opencode.d.ts.map +1 -1
- package/dist/builtins/tools/opencode.js +8 -2
- package/dist/builtins/tools/opencode.js.map +1 -1
- package/dist/cli/commands/check.d.ts.map +1 -1
- package/dist/cli/commands/check.js +17 -1
- package/dist/cli/commands/check.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +484 -50
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +6 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/install.d.ts +6 -3
- package/dist/cli/commands/install.d.ts.map +1 -1
- package/dist/cli/commands/install.js +225 -87
- package/dist/cli/commands/install.js.map +1 -1
- package/dist/cli/commands/rule.js +11 -7
- package/dist/cli/commands/rule.js.map +1 -1
- package/dist/cli/commands/sanitize.d.ts +3 -2
- package/dist/cli/commands/sanitize.d.ts.map +1 -1
- package/dist/cli/commands/sanitize.js +27 -13
- package/dist/cli/commands/sanitize.js.map +1 -1
- package/dist/cli/commands/skill.d.ts.map +1 -1
- package/dist/cli/commands/skill.js +23 -14
- package/dist/cli/commands/skill.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +15 -10
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/core/config.d.ts +73 -13
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +224 -34
- package/dist/core/config.js.map +1 -1
- package/dist/core/import-discovery.d.ts +9 -4
- package/dist/core/import-discovery.d.ts.map +1 -1
- package/dist/core/import-discovery.js +414 -141
- package/dist/core/import-discovery.js.map +1 -1
- package/dist/core/schema.d.ts +91 -11
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/schema.js +28 -0
- package/dist/core/schema.js.map +1 -1
- package/docs/commands.md +4 -2
- package/docs/index.md +1 -0
- package/docs/quickstart.md +4 -1
- 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,
|
|
8
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
48
|
+
* Convert Windows path separators to POSIX-style separators.
|
|
58
49
|
*/
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
111
|
-
// If we can't resolve the symlink, proceed normally
|
|
112
|
-
}
|
|
248
|
+
pattern += escapeRegex(ch);
|
|
113
249
|
}
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
194
|
-
mtime:
|
|
195
|
-
destPath: "instructions/AGENTS.base.md",
|
|
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
|
-
*
|
|
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
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
if (
|
|
209
|
-
return
|
|
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
|
|
212
|
-
|
|
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(
|
|
488
|
+
export function discoverImportableArtifacts(scanRoot, options = {}) {
|
|
218
489
|
const artifacts = [];
|
|
219
490
|
const warnings = [];
|
|
220
|
-
const { tools, kinds, loadoutPath } = options;
|
|
221
|
-
|
|
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
|
-
?
|
|
224
|
-
:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
//
|
|
238
|
-
if (!kinds || kinds.includes("instruction")) {
|
|
239
|
-
artifacts.push(...discoverInstructions(
|
|
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
|
|
242
|
-
const
|
|
243
|
-
? artifacts.filter((
|
|
513
|
+
// Filter out artifacts already present in .loadouts/.
|
|
514
|
+
const existingFilteredArtifacts = loadoutPath
|
|
515
|
+
? artifacts.filter((artifact) => !artifactExistsInLoadout(loadoutPath, artifact))
|
|
244
516
|
: artifacts;
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
249
|
-
const existing =
|
|
524
|
+
const key = `${artifact.kind}:${artifact.destPath}`;
|
|
525
|
+
const existing = byDest.get(key) ?? [];
|
|
250
526
|
existing.push(artifact);
|
|
251
|
-
|
|
527
|
+
byDest.set(key, existing);
|
|
252
528
|
}
|
|
253
529
|
const conflicts = new Map();
|
|
254
|
-
for (const [key, items] of
|
|
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;
|