@agent-loom/loom 1.0.2 → 1.0.3
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 +69 -0
- package/dist/acp/client.d.ts +182 -0
- package/dist/acp/client.d.ts.map +1 -0
- package/dist/acp/client.js +432 -0
- package/dist/acp/client.js.map +1 -0
- package/dist/acp/index.d.ts +5 -0
- package/dist/acp/index.d.ts.map +1 -0
- package/dist/acp/index.js +3 -0
- package/dist/acp/index.js.map +1 -0
- package/dist/acp/run.d.ts +41 -0
- package/dist/acp/run.d.ts.map +1 -0
- package/dist/acp/run.js +32 -0
- package/dist/acp/run.js.map +1 -0
- package/dist/apply.d.ts +15 -6
- package/dist/apply.d.ts.map +1 -1
- package/dist/apply.js +78 -49
- package/dist/apply.js.map +1 -1
- package/dist/chat/chat.d.ts +108 -0
- package/dist/chat/chat.d.ts.map +1 -0
- package/dist/chat/chat.js +221 -0
- package/dist/chat/chat.js.map +1 -0
- package/dist/chat/discovery.d.ts +30 -0
- package/dist/chat/discovery.d.ts.map +1 -0
- package/dist/chat/discovery.js +68 -0
- package/dist/chat/discovery.js.map +1 -0
- package/dist/chat/frontmatter.d.ts +12 -0
- package/dist/chat/frontmatter.d.ts.map +1 -0
- package/dist/chat/frontmatter.js +11 -0
- package/dist/chat/frontmatter.js.map +1 -0
- package/dist/chat/index.d.ts +16 -0
- package/dist/chat/index.d.ts.map +1 -0
- package/dist/chat/index.js +11 -0
- package/dist/chat/index.js.map +1 -0
- package/dist/chat/registry.d.ts +73 -0
- package/dist/chat/registry.d.ts.map +1 -0
- package/dist/chat/registry.js +118 -0
- package/dist/chat/registry.js.map +1 -0
- package/dist/chat/resolve-agent.d.ts +39 -0
- package/dist/chat/resolve-agent.d.ts.map +1 -0
- package/dist/chat/resolve-agent.js +36 -0
- package/dist/chat/resolve-agent.js.map +1 -0
- package/dist/chat/suggest.d.ts +20 -0
- package/dist/chat/suggest.d.ts.map +1 -0
- package/dist/chat/suggest.js +55 -0
- package/dist/chat/suggest.js.map +1 -0
- package/dist/cli.js +627 -75
- package/dist/cli.js.map +1 -1
- package/dist/clone.d.ts +21 -3
- package/dist/clone.d.ts.map +1 -1
- package/dist/clone.js +240 -12
- package/dist/clone.js.map +1 -1
- package/dist/copilot/mcp.d.ts +48 -0
- package/dist/copilot/mcp.d.ts.map +1 -0
- package/dist/copilot/mcp.js +146 -0
- package/dist/copilot/mcp.js.map +1 -0
- package/dist/copilot/resolve.d.ts +33 -0
- package/dist/copilot/resolve.d.ts.map +1 -0
- package/dist/copilot/resolve.js +96 -0
- package/dist/copilot/resolve.js.map +1 -0
- package/dist/copilot/spawn.d.ts +51 -0
- package/dist/copilot/spawn.d.ts.map +1 -0
- package/dist/copilot/spawn.js +132 -0
- package/dist/copilot/spawn.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/launch/index.d.ts +10 -0
- package/dist/launch/index.d.ts.map +1 -0
- package/dist/launch/index.js +9 -0
- package/dist/launch/index.js.map +1 -0
- package/dist/launch/stage.d.ts +62 -0
- package/dist/launch/stage.d.ts.map +1 -0
- package/dist/launch/stage.js +108 -0
- package/dist/launch/stage.js.map +1 -0
- package/dist/manifest.d.ts +165 -18
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +980 -225
- package/dist/manifest.js.map +1 -1
- package/dist/renderers/claude.d.ts +5 -0
- package/dist/renderers/claude.d.ts.map +1 -1
- package/dist/renderers/claude.js +17 -3
- package/dist/renderers/claude.js.map +1 -1
- package/dist/renderers/copilot.d.ts +1 -1
- package/dist/renderers/copilot.d.ts.map +1 -1
- package/dist/renderers/copilot.js +205 -22
- package/dist/renderers/copilot.js.map +1 -1
- package/dist/repo-clone.js +17 -11
- package/dist/repo-clone.js.map +1 -1
- package/dist/resolve-template.d.ts +12 -4
- package/dist/resolve-template.d.ts.map +1 -1
- package/dist/resolve-template.js +39 -8
- package/dist/resolve-template.js.map +1 -1
- package/dist/run/index.d.ts +4 -0
- package/dist/run/index.d.ts.map +1 -0
- package/dist/run/index.js +2 -0
- package/dist/run/index.js.map +1 -0
- package/dist/run/run.d.ts +143 -0
- package/dist/run/run.d.ts.map +1 -0
- package/dist/run/run.js +406 -0
- package/dist/run/run.js.map +1 -0
- package/dist/search-registry.d.ts +10 -3
- package/dist/search-registry.d.ts.map +1 -1
- package/dist/search-registry.js +16 -16
- package/dist/search-registry.js.map +1 -1
- package/dist/sessions/index.d.ts +16 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +15 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/store.d.ts +56 -0
- package/dist/sessions/store.d.ts.map +1 -0
- package/dist/sessions/store.js +220 -0
- package/dist/sessions/store.js.map +1 -0
- package/dist/sessions/types.d.ts +62 -0
- package/dist/sessions/types.d.ts.map +1 -0
- package/dist/sessions/types.js +5 -0
- package/dist/sessions/types.js.map +1 -0
- package/dist/skill-fetcher.d.ts.map +1 -1
- package/dist/skill-fetcher.js +5 -6
- package/dist/skill-fetcher.js.map +1 -1
- package/dist/types.d.ts +123 -41
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -1
- package/dist/util/binary-cache.d.ts +53 -0
- package/dist/util/binary-cache.d.ts.map +1 -0
- package/dist/util/binary-cache.js +211 -0
- package/dist/util/binary-cache.js.map +1 -0
- package/dist/util/frontmatter.d.ts +53 -0
- package/dist/util/frontmatter.d.ts.map +1 -0
- package/dist/util/frontmatter.js +85 -0
- package/dist/util/frontmatter.js.map +1 -0
- package/dist/util/loom-home.d.ts +19 -0
- package/dist/util/loom-home.d.ts.map +1 -0
- package/dist/util/loom-home.js +37 -0
- package/dist/util/loom-home.js.map +1 -0
- package/dist/util/workspace-folder.d.ts +29 -0
- package/dist/util/workspace-folder.d.ts.map +1 -0
- package/dist/util/workspace-folder.js +43 -0
- package/dist/util/workspace-folder.js.map +1 -0
- package/dist/validate.d.ts +7 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +90 -17
- package/dist/validate.js.map +1 -1
- package/package.json +31 -2
package/dist/manifest.js
CHANGED
|
@@ -4,40 +4,368 @@
|
|
|
4
4
|
* Parses manifest.yaml files and resolves $ref / file / registry references
|
|
5
5
|
* to produce a fully resolved manifest ready for rendering.
|
|
6
6
|
*/
|
|
7
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
8
|
-
import { resolve, dirname, basename, extname, relative, join } from 'node:path';
|
|
7
|
+
import { readFile, readdir, stat, realpath } from 'node:fs/promises';
|
|
8
|
+
import { resolve, dirname, basename, extname, relative, join, isAbsolute } from 'node:path';
|
|
9
9
|
import yaml from 'js-yaml';
|
|
10
|
-
import { isSharedRef, isLocalFileRef, isRegistrySkillRef, isMcpInlineDef, } from './types.js';
|
|
10
|
+
import { isSharedRef, isLocalFileRef, isRegistrySkillRef, isDiscoverSkillsRef, isDiscoverAgentRef, isMcpInlineDef, } from './types.js';
|
|
11
|
+
import { parseFrontmatterRaw, stripFrontmatter } from './util/frontmatter.js';
|
|
12
|
+
import { resolveBinaryToCache } from './util/binary-cache.js';
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Path safety (security primitives)
|
|
15
|
+
//
|
|
16
|
+
// Manifest-controlled paths flow into renderers and writers. A malicious or
|
|
17
|
+
// hand-edited template can declare `$ref: ../../.vscode/pwned` or
|
|
18
|
+
// `name: ../../escape` and cause loom to read or write outside the workspace.
|
|
19
|
+
// These helpers close that vector at the resolver layer so downstream code
|
|
20
|
+
// (renderers, writers) never sees an unsafe path.
|
|
21
|
+
//
|
|
22
|
+
// Recovered from PR #134 cycle 1-3 review (commits dc9c4dd, 2432428, b16ef3c)
|
|
23
|
+
// during the foundation-first carve in 2026-05.
|
|
24
|
+
// =============================================================================
|
|
25
|
+
/**
|
|
26
|
+
* Validate that `name` is safe to use as a single filesystem path
|
|
27
|
+
* component (skill dir, prompt filename, agent slug). Frontmatter
|
|
28
|
+
* `name:` values flow into renderer paths; a malicious template can
|
|
29
|
+
* declare `name: ../../../.vscode/pwned` and have the renderer write
|
|
30
|
+
* outside the workspace. This helper closes that vector at the
|
|
31
|
+
* resolver layer so renderers never see an unsafe name.
|
|
32
|
+
*/
|
|
33
|
+
export function isSafePathSegment(name) {
|
|
34
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
35
|
+
return false;
|
|
36
|
+
if (name === '.' || name === '..')
|
|
37
|
+
return false;
|
|
38
|
+
if (name.includes('/') || name.includes('\\'))
|
|
39
|
+
return false;
|
|
40
|
+
// eslint-disable-next-line no-control-regex
|
|
41
|
+
if (name.includes('\0'))
|
|
42
|
+
return false;
|
|
43
|
+
// Reject Windows drive letters (e.g. `C:`) used as directory names.
|
|
44
|
+
if (/^[A-Za-z]:/.test(name))
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function unsafePathSegmentMessage(kind, name, source) {
|
|
49
|
+
return `[loom] Unsafe ${kind} name "${name}" from ${source}: names must be a single filesystem path segment.`;
|
|
50
|
+
}
|
|
51
|
+
function assertSafePathSegment(kind, name, source) {
|
|
52
|
+
if (!isSafePathSegment(name)) {
|
|
53
|
+
throw new Error(unsafePathSegmentMessage(kind, name, source));
|
|
54
|
+
}
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve `ref` against `root`, then assert the result stays under
|
|
59
|
+
* `root` after symlink resolution. Throws on escape (absolute paths,
|
|
60
|
+
* `..` traversal, symlinks pointing out). Use for every manifest-
|
|
61
|
+
* controlled relative path before reading or writing it.
|
|
62
|
+
*
|
|
63
|
+
* Skips the realpath check if the file does not yet exist (e.g. a
|
|
64
|
+
* renderer about to write to it); returns the lexically-resolved path
|
|
65
|
+
* in that case. Callers that need realpath safety on an existing file
|
|
66
|
+
* should call after stat.
|
|
67
|
+
*/
|
|
68
|
+
export async function safeJoinUnder(root, ref) {
|
|
69
|
+
if (typeof ref !== 'string' || ref.length === 0) {
|
|
70
|
+
throw new Error(`manifest path safety: empty reference`);
|
|
71
|
+
}
|
|
72
|
+
if (isAbsolute(ref)) {
|
|
73
|
+
throw new Error(`manifest path safety: absolute paths not allowed: ${ref}`);
|
|
74
|
+
}
|
|
75
|
+
const candidate = resolve(root, ref);
|
|
76
|
+
let realRoot;
|
|
77
|
+
try {
|
|
78
|
+
realRoot = await realpath(root);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
realRoot = resolve(root);
|
|
82
|
+
}
|
|
83
|
+
let realCandidate;
|
|
84
|
+
try {
|
|
85
|
+
realCandidate = await realpath(candidate);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Doesn't exist yet -- fall back to lexical containment check.
|
|
89
|
+
realCandidate = candidate;
|
|
90
|
+
}
|
|
91
|
+
const rel = relative(realRoot, realCandidate);
|
|
92
|
+
if (rel === '' || rel === '.' || (!rel.startsWith('..') && !isAbsolute(rel))) {
|
|
93
|
+
return realCandidate;
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`manifest path safety: "${ref}" escapes its root (${root})`);
|
|
96
|
+
}
|
|
11
97
|
// =============================================================================
|
|
12
98
|
// Parse
|
|
13
99
|
// =============================================================================
|
|
14
100
|
/**
|
|
15
|
-
* Parse a manifest
|
|
101
|
+
* Parse a manifest into a typed Manifest object.
|
|
102
|
+
*
|
|
103
|
+
* Accepts either:
|
|
104
|
+
* - A path to a manifest file (`.../manifest.yaml` or `.../dobby.yaml`)
|
|
105
|
+
* - A path to a directory containing one of those filenames
|
|
106
|
+
*
|
|
107
|
+
* When given a directory, prefers `manifest.yaml`; falls back to
|
|
108
|
+
* `dobby.yaml` (with a one-time deprecation note). If both are
|
|
109
|
+
* present, prefers `manifest.yaml` and warns.
|
|
110
|
+
*
|
|
111
|
+
* Per U5/U6, field-name and layout aliases are normalized at parse time:
|
|
112
|
+
* - `mcpServers:` -> `mcp:`
|
|
113
|
+
* - `sparseCheckout:` -> `paths:` (+ `sparse: true`)
|
|
114
|
+
* - `team:` -> `template:`
|
|
115
|
+
* - flat top-level resources -> wrapped in synthetic `contents:`
|
|
116
|
+
* Each kind of alias triggers a once-per-session deprecation warning.
|
|
117
|
+
* See normalizeManifestAliases.
|
|
16
118
|
*/
|
|
17
|
-
export async function parseManifest(
|
|
119
|
+
export async function parseManifest(pathOrDir) {
|
|
120
|
+
const manifestPath = await resolveManifestPath(pathOrDir);
|
|
18
121
|
const content = await readFile(manifestPath, 'utf-8');
|
|
19
122
|
const raw = yaml.load(content);
|
|
20
|
-
|
|
123
|
+
const normalized = normalizeManifestAliases(raw, manifestPath);
|
|
124
|
+
return normalized;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a path-or-directory argument into the actual manifest file path.
|
|
128
|
+
*
|
|
129
|
+
* - If the path is a file, returns it unchanged.
|
|
130
|
+
* - If the path is a directory, probes `manifest.yaml` first (canonical),
|
|
131
|
+
* then `dobby.yaml` (legacy alias). Logs a one-time deprecation
|
|
132
|
+
* warning when falling back to `dobby.yaml`.
|
|
133
|
+
* - Throws if neither is found.
|
|
134
|
+
*
|
|
135
|
+
* Exported so callers (e.g. validate.ts) can probe for a manifest using
|
|
136
|
+
* the same rules without re-reading the file.
|
|
137
|
+
*/
|
|
138
|
+
export async function resolveManifestPath(pathOrDir) {
|
|
139
|
+
let isDir = false;
|
|
140
|
+
try {
|
|
141
|
+
const s = await stat(pathOrDir);
|
|
142
|
+
isDir = s.isDirectory();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Path doesn't exist; fall through and let the file read fail with the
|
|
146
|
+
// original ENOENT so the caller sees the same error as before.
|
|
147
|
+
return pathOrDir;
|
|
148
|
+
}
|
|
149
|
+
if (!isDir)
|
|
150
|
+
return pathOrDir;
|
|
151
|
+
const canonical = join(pathOrDir, 'manifest.yaml');
|
|
152
|
+
const dobby = join(pathOrDir, 'dobby.yaml');
|
|
153
|
+
const hasCanonical = await fileExists(canonical);
|
|
154
|
+
const hasDobby = await fileExists(dobby);
|
|
155
|
+
if (hasCanonical && hasDobby) {
|
|
156
|
+
console.warn(`[loom] Both manifest.yaml and dobby.yaml present in ${pathOrDir}; ` +
|
|
157
|
+
`using manifest.yaml. Remove dobby.yaml to silence this warning.`);
|
|
158
|
+
return canonical;
|
|
159
|
+
}
|
|
160
|
+
if (hasCanonical)
|
|
161
|
+
return canonical;
|
|
162
|
+
if (hasDobby) {
|
|
163
|
+
logFilenameDeprecation(pathOrDir);
|
|
164
|
+
return dobby;
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`No manifest.yaml or dobby.yaml found in ${pathOrDir}`);
|
|
167
|
+
}
|
|
168
|
+
async function fileExists(path) {
|
|
169
|
+
try {
|
|
170
|
+
const s = await stat(path);
|
|
171
|
+
return s.isFile();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
21
176
|
}
|
|
22
177
|
// =============================================================================
|
|
23
|
-
//
|
|
178
|
+
// Field-name aliases (U5: dobby-style -> canonical)
|
|
24
179
|
// =============================================================================
|
|
25
180
|
/**
|
|
26
|
-
*
|
|
181
|
+
* One-time deprecation log dedup, keyed by alias name. Each deprecated
|
|
182
|
+
* field is reported at most once per process, no matter how many
|
|
183
|
+
* manifests use it. Tests can clear the set via
|
|
184
|
+
* `_resetAliasDeprecationsForTest`.
|
|
185
|
+
*/
|
|
186
|
+
const ALIAS_DEPRECATION_LOGGED = new Set();
|
|
187
|
+
/**
|
|
188
|
+
* Top-level fields that, when present at the manifest root and `contents:`
|
|
189
|
+
* is absent, signal a flat layout (dobby-style). These are hoisted into
|
|
190
|
+
* a synthetic `contents:` wrapper by normalizeManifestAliases so the
|
|
191
|
+
* resolver doesn't need a separate code path. `prerequisites:` stays at
|
|
192
|
+
* the root since that's the canonical location.
|
|
193
|
+
*/
|
|
194
|
+
const FLAT_LAYOUT_RESOURCES = [
|
|
195
|
+
'instructions',
|
|
196
|
+
'skills',
|
|
197
|
+
'agents',
|
|
198
|
+
'mcp',
|
|
199
|
+
'mcpServers',
|
|
200
|
+
'repos',
|
|
201
|
+
'prompts',
|
|
202
|
+
];
|
|
203
|
+
/**
|
|
204
|
+
* Walk a freshly-loaded manifest YAML object and rename dobby-style
|
|
205
|
+
* field names to loom's canonical names. The input object is shallow-
|
|
206
|
+
* cloned; the caller's object is never mutated. Each alias triggers a
|
|
207
|
+
* one-time deprecation warning per session.
|
|
27
208
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
209
|
+
* Aliases supported:
|
|
210
|
+
* - top-level `team:` -> `template:` (only when `template:` absent)
|
|
211
|
+
* - top-level `mcpServers:` -> `mcp:` (also recognized inside `contents:`)
|
|
212
|
+
* - per-repo entry: `sparseCheckout:` -> `paths:` (also sets
|
|
213
|
+
* `sparse: true` when no explicit `sparse:` key is given)
|
|
214
|
+
* - flat layout: top-level `instructions:` / `skills:` / `agents:` /
|
|
215
|
+
* `mcp:` / `repos:` / `prompts:` are hoisted into a synthetic
|
|
216
|
+
* `contents:` block when `contents:` is absent. Per U6, this
|
|
217
|
+
* collapses the dobby-format flat layout into the canonical
|
|
218
|
+
* contents:-wrapped layout that resolveManifest expects.
|
|
219
|
+
*
|
|
220
|
+
* Filename-level aliases (e.g. dobby.yaml -> manifest.yaml) are
|
|
221
|
+
* handled in `resolveManifestPath` before this function runs.
|
|
222
|
+
*/
|
|
223
|
+
export function normalizeManifestAliases(raw, sourcePath) {
|
|
224
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
225
|
+
return raw;
|
|
226
|
+
}
|
|
227
|
+
let out = { ...raw };
|
|
228
|
+
// U6: `team:` -> `template:` alias (used by dobby.yaml). Only fires
|
|
229
|
+
// when `template:` is absent so canonical manifests are untouched.
|
|
230
|
+
if ('team' in out && !('template' in out)) {
|
|
231
|
+
logAliasDeprecation('team', 'template', sourcePath);
|
|
232
|
+
out.template = out.team;
|
|
233
|
+
delete out.team;
|
|
234
|
+
}
|
|
235
|
+
// U6: flat layout -> synthetic `contents:` wrapper. Triggered when
|
|
236
|
+
// `contents:` is absent but at least one resource field lives at the
|
|
237
|
+
// root. After hoisting, the rest of normalize and resolveManifest
|
|
238
|
+
// operate on the unified shape.
|
|
239
|
+
if (!('contents' in out)) {
|
|
240
|
+
const hoisted = {};
|
|
241
|
+
let hadAny = false;
|
|
242
|
+
for (const field of FLAT_LAYOUT_RESOURCES) {
|
|
243
|
+
if (field in out) {
|
|
244
|
+
hoisted[field] = out[field];
|
|
245
|
+
delete out[field];
|
|
246
|
+
hadAny = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (hadAny) {
|
|
250
|
+
logAliasDeprecation('flat-layout', 'contents', sourcePath);
|
|
251
|
+
// U6: implicit filesystem discovery for skills and agents in
|
|
252
|
+
// flat-layout dobby-style manifests. Matches the prior dobby
|
|
253
|
+
// adapter's behavior so existing dobby checkouts render skills
|
|
254
|
+
// and agents from disk without requiring dobby.yaml edits.
|
|
255
|
+
// Order encodes precedence: shared first, team second (later
|
|
256
|
+
// dirs override earlier on duplicate names; see
|
|
257
|
+
// resolveSkillEntries / discoverSkillsFromDirs).
|
|
258
|
+
if (!('skills' in hoisted)) {
|
|
259
|
+
hoisted.skills = [{ discover: ['../_shared/skills', './skills'] }];
|
|
260
|
+
}
|
|
261
|
+
if (!('agents' in hoisted)) {
|
|
262
|
+
hoisted.agents = [{ discover: ['./agents'] }];
|
|
263
|
+
}
|
|
264
|
+
out.contents = hoisted;
|
|
265
|
+
// Defaults for fields the canonical schema requires but flat-
|
|
266
|
+
// layout dobby.yaml typically omits. Existing values are kept.
|
|
267
|
+
if (!('version' in out))
|
|
268
|
+
out.version = '1.0.0';
|
|
269
|
+
if (!('targets' in out))
|
|
270
|
+
out.targets = ['copilot'];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Top-level `mcpServers:` (legacy dobby flat layout, kept for
|
|
274
|
+
// safety in case flat hoisting was already done by an earlier pass).
|
|
275
|
+
if ('mcpServers' in out) {
|
|
276
|
+
logAliasDeprecation('mcpServers', 'mcp', sourcePath);
|
|
277
|
+
if (!('mcp' in out))
|
|
278
|
+
out.mcp = out.mcpServers;
|
|
279
|
+
delete out.mcpServers;
|
|
280
|
+
}
|
|
281
|
+
// Inside `contents:` (canonical nested layout, or freshly-hoisted
|
|
282
|
+
// flat layout): mcpServers -> mcp, walk repos for sparseCheckout
|
|
283
|
+
// -> paths.
|
|
284
|
+
if (typeof out.contents === 'object' &&
|
|
285
|
+
out.contents !== null &&
|
|
286
|
+
!Array.isArray(out.contents)) {
|
|
287
|
+
const contents = { ...out.contents };
|
|
288
|
+
if ('mcpServers' in contents) {
|
|
289
|
+
logAliasDeprecation('mcpServers', 'mcp', sourcePath);
|
|
290
|
+
if (!('mcp' in contents))
|
|
291
|
+
contents.mcp = contents.mcpServers;
|
|
292
|
+
delete contents.mcpServers;
|
|
293
|
+
}
|
|
294
|
+
if (Array.isArray(contents.repos)) {
|
|
295
|
+
contents.repos = contents.repos.map((entry) => normalizeRepoEntryAliases(entry, sourcePath));
|
|
296
|
+
}
|
|
297
|
+
out.contents = contents;
|
|
298
|
+
}
|
|
299
|
+
// Top-level `repos:` (e.g. canonical layout where repos: is at root,
|
|
300
|
+
// or a flat layout that was NOT hoisted because contents: was already
|
|
301
|
+
// present). Apply sparseCheckout normalization in place.
|
|
302
|
+
if (Array.isArray(out.repos)) {
|
|
303
|
+
out.repos = out.repos.map((entry) => normalizeRepoEntryAliases(entry, sourcePath));
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
function normalizeRepoEntryAliases(entry, sourcePath) {
|
|
308
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
309
|
+
return entry;
|
|
310
|
+
}
|
|
311
|
+
const r = { ...entry };
|
|
312
|
+
if ('sparseCheckout' in r) {
|
|
313
|
+
logAliasDeprecation('sparseCheckout', 'paths', sourcePath);
|
|
314
|
+
if (!('paths' in r))
|
|
315
|
+
r.paths = r.sparseCheckout;
|
|
316
|
+
if (!('sparse' in r) && r.sparseCheckout != null)
|
|
317
|
+
r.sparse = true;
|
|
318
|
+
delete r.sparseCheckout;
|
|
319
|
+
}
|
|
320
|
+
return r;
|
|
321
|
+
}
|
|
322
|
+
function logAliasDeprecation(aliasName, canonicalName, sourcePath) {
|
|
323
|
+
if (ALIAS_DEPRECATION_LOGGED.has(aliasName))
|
|
324
|
+
return;
|
|
325
|
+
ALIAS_DEPRECATION_LOGGED.add(aliasName);
|
|
326
|
+
const where = sourcePath ? ` (manifest: ${sourcePath})` : '';
|
|
327
|
+
if (aliasName === 'flat-layout') {
|
|
328
|
+
console.warn(`[loom] Top-level resources without a 'contents:' wrapper are deprecated; ` +
|
|
329
|
+
`nest 'instructions:', 'skills:', 'agents:', 'mcp:', 'repos:', 'prompts:' ` +
|
|
330
|
+
`under 'contents:' instead.${where}`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
console.warn(`[loom] Field '${aliasName}' is deprecated; use '${canonicalName}' instead.${where}`);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* One-time deprecation log dedup for the dobby.yaml filename. Tests
|
|
337
|
+
* can clear it via `_resetAliasDeprecationsForTest`.
|
|
338
|
+
*/
|
|
339
|
+
function logFilenameDeprecation(dir) {
|
|
340
|
+
if (ALIAS_DEPRECATION_LOGGED.has('dobby.yaml-filename'))
|
|
341
|
+
return;
|
|
342
|
+
ALIAS_DEPRECATION_LOGGED.add('dobby.yaml-filename');
|
|
343
|
+
console.warn(`[loom] Found dobby.yaml in ${dir}; this filename is supported as an alias ` +
|
|
344
|
+
`but the canonical filename is 'manifest.yaml'. Rename to silence this warning.`);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Test-only: clear the once-per-session dedup so subsequent calls log
|
|
348
|
+
* again. Production code should never need to call this.
|
|
31
349
|
*/
|
|
32
|
-
export
|
|
350
|
+
export function _resetAliasDeprecationsForTest() {
|
|
351
|
+
ALIAS_DEPRECATION_LOGGED.clear();
|
|
352
|
+
}
|
|
353
|
+
export async function resolveManifest(manifest, templateDir, registryRoot, options = {}) {
|
|
354
|
+
const legacyAgents = await readLegacyAgentManifests(templateDir);
|
|
355
|
+
const selectedLegacyAgents = scopeLegacyAgents(legacyAgents, options.agents);
|
|
356
|
+
const contents = mergeLegacyAgentContents(manifest.contents, selectedLegacyAgents);
|
|
357
|
+
const prerequisiteEntries = deduplicateEntries([
|
|
358
|
+
...(manifest.prerequisites ?? []),
|
|
359
|
+
...selectedLegacyAgents.flatMap((a) => a.manifest.prerequisites ?? []),
|
|
360
|
+
], prereqEntryKey);
|
|
33
361
|
const [instructions, skills, agents, mcp, repos, prerequisites, prompts] = await Promise.all([
|
|
34
|
-
resolveInstructions(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
resolveRepos(
|
|
39
|
-
resolvePrerequisites(
|
|
40
|
-
resolvePrompts(
|
|
362
|
+
resolveInstructions(contents.instructions ?? [], templateDir, registryRoot),
|
|
363
|
+
resolveSkillEntries(contents.skills ?? [], templateDir),
|
|
364
|
+
resolveAgentEntries(contents.agents ?? [], templateDir, () => { }, registryRoot),
|
|
365
|
+
resolveMcpEntries(contents.mcp ?? [], registryRoot),
|
|
366
|
+
resolveRepos(contents.repos ?? [], registryRoot),
|
|
367
|
+
resolvePrerequisites(prerequisiteEntries, registryRoot),
|
|
368
|
+
resolvePrompts(contents.prompts ?? [], templateDir, registryRoot),
|
|
41
369
|
]);
|
|
42
370
|
return {
|
|
43
371
|
version: manifest.version,
|
|
@@ -52,56 +380,286 @@ export async function resolveManifest(manifest, templateDir, registryRoot) {
|
|
|
52
380
|
prerequisites,
|
|
53
381
|
};
|
|
54
382
|
}
|
|
383
|
+
function scopeLegacyAgents(legacyAgents, selectedAgents) {
|
|
384
|
+
const wanted = new Set((selectedAgents ?? []).filter(Boolean));
|
|
385
|
+
if (wanted.size === 0)
|
|
386
|
+
return legacyAgents;
|
|
387
|
+
return legacyAgents.filter((source) => {
|
|
388
|
+
const agentId = source.manifest.agent?.id;
|
|
389
|
+
return wanted.has(source.dir) || (agentId !== undefined && wanted.has(agentId));
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
async function readLegacyAgentManifests(templateDir) {
|
|
393
|
+
const agentsDir = join(templateDir, 'agents');
|
|
394
|
+
let entries;
|
|
395
|
+
try {
|
|
396
|
+
entries = (await readdir(agentsDir, { withFileTypes: true }));
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
const result = [];
|
|
402
|
+
for (const entry of entries.filter((e) => e.isDirectory()).sort((a, b) => String(a.name).localeCompare(String(b.name)))) {
|
|
403
|
+
const dir = String(entry.name);
|
|
404
|
+
const manifestPath = join(agentsDir, dir, 'manifest.yaml');
|
|
405
|
+
try {
|
|
406
|
+
const s = await stat(manifestPath);
|
|
407
|
+
if (!s.isFile())
|
|
408
|
+
continue;
|
|
409
|
+
const parsed = yaml.load(await readFile(manifestPath, 'utf-8'));
|
|
410
|
+
if (parsed?.agent)
|
|
411
|
+
result.push({ dir, manifest: parsed });
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
async function readLegacyAgentManifestFromDir(agentDir) {
|
|
420
|
+
const manifestPath = join(agentDir, 'manifest.yaml');
|
|
421
|
+
try {
|
|
422
|
+
const s = await stat(manifestPath);
|
|
423
|
+
if (!s.isFile())
|
|
424
|
+
return undefined;
|
|
425
|
+
const parsed = yaml.load(await readFile(manifestPath, 'utf-8'));
|
|
426
|
+
return parsed?.agent ? parsed : undefined;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function mergeLegacyAgentContents(contents, legacyAgents) {
|
|
433
|
+
if (legacyAgents.length === 0)
|
|
434
|
+
return contents;
|
|
435
|
+
const agentContents = legacyAgents.map((a) => a.manifest.contents ?? {});
|
|
436
|
+
const agentEntries = contents.agents ?? [{ discover: ['./agents'] }];
|
|
437
|
+
return {
|
|
438
|
+
...contents,
|
|
439
|
+
instructions: [
|
|
440
|
+
...(contents.instructions ?? []),
|
|
441
|
+
...agentContents.flatMap((c) => c.instructions ?? []),
|
|
442
|
+
],
|
|
443
|
+
skills: deduplicateEntries([
|
|
444
|
+
...(contents.skills ?? []),
|
|
445
|
+
...agentContents.flatMap((c) => c.skills ?? []),
|
|
446
|
+
], skillEntryKey),
|
|
447
|
+
agents: agentEntries,
|
|
448
|
+
mcp: deduplicateEntries([
|
|
449
|
+
...(contents.mcp ?? []),
|
|
450
|
+
...agentContents.flatMap((c) => c.mcp ?? []),
|
|
451
|
+
], mcpEntryKey),
|
|
452
|
+
repos: deduplicateEntries([
|
|
453
|
+
...(contents.repos ?? []),
|
|
454
|
+
...agentContents.flatMap((c) => c.repos ?? []),
|
|
455
|
+
], repoEntryKey),
|
|
456
|
+
prompts: [
|
|
457
|
+
...(contents.prompts ?? []),
|
|
458
|
+
...agentContents.flatMap((c) => c.prompts ?? []),
|
|
459
|
+
],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
55
462
|
// =============================================================================
|
|
56
463
|
// Internal resolvers
|
|
57
464
|
// =============================================================================
|
|
58
465
|
async function resolveInstructions(entries, templateDir, registryRoot) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
466
|
+
// Per U6: instruction entries are resolved with best-effort
|
|
467
|
+
// semantics: missing `scope` defaults to '**' (was: dobby adapter
|
|
468
|
+
// hardcoded this for its catalog format) and missing files emit a
|
|
469
|
+
// warning instead of erroring. The strict reference check still
|
|
470
|
+
// lives in validate.ts for `loom validate` -- apply's job is to
|
|
471
|
+
// produce a usable workspace from the manifest it was given, even
|
|
472
|
+
// if a few files have rotted.
|
|
473
|
+
const resolved = await Promise.all(entries.map(async (entry) => {
|
|
474
|
+
const scope = (entry.scope ?? '**');
|
|
475
|
+
try {
|
|
476
|
+
if (isSharedRef(entry)) {
|
|
477
|
+
const filePath = await safeJoinUnder(registryRoot, entry.$ref);
|
|
478
|
+
const content = await readFile(filePath, 'utf-8');
|
|
479
|
+
return { scope, content, sourcePath: entry.$ref };
|
|
480
|
+
}
|
|
481
|
+
if (isLocalFileRef(entry)) {
|
|
482
|
+
const filePath = await safeJoinUnder(templateDir, entry.file);
|
|
483
|
+
const content = await readFile(filePath, 'utf-8');
|
|
484
|
+
return { scope, content, sourcePath: entry.file };
|
|
485
|
+
}
|
|
486
|
+
throw new Error(`Unknown instruction entry type: ${JSON.stringify(entry)}`);
|
|
64
487
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
488
|
+
catch (err) {
|
|
489
|
+
if (err.code === 'ENOENT') {
|
|
490
|
+
const refLabel = isSharedRef(entry)
|
|
491
|
+
? entry.$ref
|
|
492
|
+
: isLocalFileRef(entry)
|
|
493
|
+
? entry.file
|
|
494
|
+
: JSON.stringify(entry);
|
|
495
|
+
console.warn(`[loom] Skipping missing instruction file: ${refLabel}`);
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
throw err;
|
|
69
499
|
}
|
|
70
|
-
throw new Error(`Unknown instruction entry type: ${JSON.stringify(entry)}`);
|
|
71
500
|
}));
|
|
501
|
+
return resolved.filter((r) => r !== null);
|
|
72
502
|
}
|
|
73
|
-
|
|
74
|
-
|
|
503
|
+
/**
|
|
504
|
+
* Resolve a list of skill entries (registry / file / discover) into
|
|
505
|
+
* `ResolvedSkill[]`. Exposed so adapters (e.g. the dobby adapter) can
|
|
506
|
+
* pump entries through the same code path used by `resolveManifest`
|
|
507
|
+
* rather than re-implementing skill discovery.
|
|
508
|
+
*
|
|
509
|
+
* Each entry expands to zero, one, or many ResolvedSkill values:
|
|
510
|
+
* - `registry:` -> 1 entry (registry coordinates only, no content)
|
|
511
|
+
* - `file:` -> 1 entry (SKILL.md content + extra files)
|
|
512
|
+
* - `discover:` -> N entries (one per immediate subdirectory of each
|
|
513
|
+
* listed dir that contains a SKILL.md). Within a discover entry,
|
|
514
|
+
* later paths override earlier ones on duplicate skill names
|
|
515
|
+
* (preserving "team skills override shared skills" semantics).
|
|
516
|
+
*
|
|
517
|
+
* No cross-entry deduplication is performed -- callers that need it
|
|
518
|
+
* apply their own pass after concatenating the result.
|
|
519
|
+
*/
|
|
520
|
+
export async function resolveSkillEntries(entries, templateDir, log = () => { }) {
|
|
521
|
+
const resolved = [];
|
|
522
|
+
for (const entry of entries) {
|
|
75
523
|
if (isRegistrySkillRef(entry)) {
|
|
76
|
-
|
|
77
|
-
type: 'registry',
|
|
524
|
+
resolved.push({
|
|
78
525
|
registry: entry.registry,
|
|
79
526
|
ref: entry.ref,
|
|
80
|
-
|
|
527
|
+
name: entry.ref,
|
|
528
|
+
});
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (isDiscoverSkillsRef(entry)) {
|
|
532
|
+
const discovered = await discoverSkillsFromDirs(entry.discover, templateDir, log);
|
|
533
|
+
resolved.push(...discovered);
|
|
534
|
+
continue;
|
|
81
535
|
}
|
|
82
536
|
if (isLocalFileRef(entry)) {
|
|
83
|
-
const filePath =
|
|
537
|
+
const filePath = await safeJoinUnder(templateDir, entry.file);
|
|
84
538
|
const content = await readFile(filePath, 'utf-8');
|
|
85
|
-
const
|
|
539
|
+
const frontmatterName = parseFrontmatterField(content, 'name');
|
|
540
|
+
const fallbackName = basename(filePath).toLowerCase() === 'skill.md'
|
|
541
|
+
? basename(dirname(filePath))
|
|
542
|
+
: basename(filePath, extname(filePath)).replace(/\.skill$/, '');
|
|
543
|
+
const name = assertSafePathSegment('skill', frontmatterName === undefined ? fallbackName : frontmatterName, entry.file);
|
|
86
544
|
const skillDir = dirname(filePath);
|
|
87
545
|
const extraFiles = await collectExtraFiles(skillDir, filePath);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
name: name ?? entry.name,
|
|
546
|
+
resolved.push({
|
|
547
|
+
name,
|
|
91
548
|
content,
|
|
92
549
|
sourcePath: entry.file,
|
|
93
550
|
extraFiles: extraFiles.length > 0 ? extraFiles : undefined,
|
|
94
|
-
};
|
|
551
|
+
});
|
|
552
|
+
continue;
|
|
95
553
|
}
|
|
96
554
|
throw new Error(`Unknown skill entry type: ${JSON.stringify(entry)}`);
|
|
97
|
-
}
|
|
555
|
+
}
|
|
556
|
+
return resolved;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Walk each listed directory looking for `<name>/SKILL.md` and return a
|
|
560
|
+
* ResolvedSkill[] with content + extra files. Within `dirs`, later
|
|
561
|
+
* paths override earlier ones on duplicate skill names -- so caller
|
|
562
|
+
* order encodes precedence (e.g. `[shared, team]` -> team wins).
|
|
563
|
+
*
|
|
564
|
+
* Relative paths are resolved against `baseDir`; absolute paths are
|
|
565
|
+
* used as-is. Missing directories are silently skipped (matches dobby's
|
|
566
|
+
* "best effort" discovery).
|
|
567
|
+
*/
|
|
568
|
+
async function discoverSkillsFromDirs(dirs, baseDir, log) {
|
|
569
|
+
const byName = new Map();
|
|
570
|
+
for (const rawDir of dirs) {
|
|
571
|
+
let skillsRoot;
|
|
572
|
+
if (isAbsolute(rawDir)) {
|
|
573
|
+
skillsRoot = rawDir;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
try {
|
|
577
|
+
skillsRoot = await safeJoinUnder(baseDir, rawDir);
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
log(` Warning: skipping skill discover dir: ${err.message}`);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
let entries;
|
|
585
|
+
try {
|
|
586
|
+
entries = (await readdir(skillsRoot, { withFileTypes: true }));
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
continue; // Directory doesn't exist -- that's fine, just skip
|
|
590
|
+
}
|
|
591
|
+
for (const entry of entries) {
|
|
592
|
+
if (!entry.isDirectory())
|
|
593
|
+
continue;
|
|
594
|
+
const skillDir = join(skillsRoot, String(entry.name));
|
|
595
|
+
const skillMdPath = join(skillDir, 'SKILL.md');
|
|
596
|
+
try {
|
|
597
|
+
const s = await stat(skillMdPath);
|
|
598
|
+
if (!s.isFile())
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
continue; // No SKILL.md -- not a skill directory
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const content = await readFile(skillMdPath, 'utf-8');
|
|
606
|
+
const frontmatter = parseFrontmatterRaw(content);
|
|
607
|
+
const name = typeof frontmatter.name === 'string' ? frontmatter.name : String(entry.name);
|
|
608
|
+
if (!isSafePathSegment(name)) {
|
|
609
|
+
log(` Warning: skipping skill ${entry.name}: ${unsafePathSegmentMessage('skill', name, skillMdPath)}`);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const extraFiles = await collectExtraFiles(skillDir, skillMdPath);
|
|
613
|
+
// Last-write-wins on duplicate names: later dirs override earlier.
|
|
614
|
+
byName.set(name, {
|
|
615
|
+
name,
|
|
616
|
+
content,
|
|
617
|
+
sourcePath: skillMdPath,
|
|
618
|
+
extraFiles: extraFiles.length > 0 ? extraFiles : undefined,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
log(` Warning: could not read skill ${entry.name}: ${err.message}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return Array.from(byName.values());
|
|
98
627
|
}
|
|
628
|
+
// File extensions that are safe to read as UTF-8 text. Everything else
|
|
629
|
+
// is read as a Buffer so binary assets (.png, .ico, .zip, ...) survive
|
|
630
|
+
// the resolver intact. Recovered from PR #134 cycle 3.
|
|
631
|
+
const TEXT_EXTENSIONS = new Set([
|
|
632
|
+
'.md', '.markdown', '.txt', '.rst', '.json', '.yaml', '.yml',
|
|
633
|
+
'.toml', '.ini', '.cfg', '.conf', '.csv', '.tsv', '.log',
|
|
634
|
+
'.html', '.xml', '.xsd', '.js', '.mjs', '.cjs', '.ts', '.tsx',
|
|
635
|
+
'.jsx', '.py', '.rb', '.go', '.rs', '.c', '.cc', '.cpp', '.h',
|
|
636
|
+
'.hpp', '.cs', '.java', '.kt', '.swift', '.sh', '.bash', '.zsh',
|
|
637
|
+
'.fish', '.ps1', '.psm1', '.bat', '.cmd', '.sql',
|
|
638
|
+
]);
|
|
639
|
+
// Bound traversal of pathological registries (symlink loops would be
|
|
640
|
+
// caught by `Dirent#isSymbolicLink()` below, but a deeply-nested or
|
|
641
|
+
// fan-out tree would still consume unbounded memory without these caps).
|
|
642
|
+
const MAX_EXTRA_FILES = 200;
|
|
643
|
+
const MAX_EXTRA_DEPTH = 8;
|
|
99
644
|
/**
|
|
100
|
-
* Recursively collect
|
|
645
|
+
* Recursively collect files in a skill directory except the main SKILL.md.
|
|
646
|
+
*
|
|
647
|
+
* Hardening (recovered from PR #134 cycle 3):
|
|
648
|
+
* - Symlinks (file or directory) are skipped. Following them would let
|
|
649
|
+
* a malicious skill point at e.g. `id_rsa -> ~/.ssh/id_rsa` and
|
|
650
|
+
* silently pull the key into the rendered workspace.
|
|
651
|
+
* - Non-text extensions (.png, .ico, .zip, etc.) are read as binary
|
|
652
|
+
* Buffer to avoid lossy UTF-8 decode of binary bytes.
|
|
653
|
+
* - Per-file readFile is wrapped in try/catch so one EACCES/EISDIR
|
|
654
|
+
* aborts only that entry, not the whole skill resolution.
|
|
655
|
+
* - Depth cap (8) and per-skill file cap (200) prevent runaway
|
|
656
|
+
* traversal of pathological registries.
|
|
101
657
|
*/
|
|
102
|
-
async function collectExtraFiles(dir, mainFile, baseDir) {
|
|
658
|
+
async function collectExtraFiles(dir, mainFile, baseDir, depthLeft = MAX_EXTRA_DEPTH, countSoFar = { value: 0 }) {
|
|
103
659
|
baseDir = baseDir ?? dir;
|
|
104
660
|
const results = [];
|
|
661
|
+
if (depthLeft < 0)
|
|
662
|
+
return results;
|
|
105
663
|
let dirEntries;
|
|
106
664
|
try {
|
|
107
665
|
dirEntries = await readdir(dir, { withFileTypes: true });
|
|
@@ -110,42 +668,345 @@ async function collectExtraFiles(dir, mainFile, baseDir) {
|
|
|
110
668
|
return results;
|
|
111
669
|
}
|
|
112
670
|
for (const entry of dirEntries) {
|
|
671
|
+
if (countSoFar.value >= MAX_EXTRA_FILES)
|
|
672
|
+
break;
|
|
673
|
+
// Skip symlinks defensively. Dirent#isSymbolicLink() reflects lstat
|
|
674
|
+
// semantics (Node uses lstat to populate Dirent), so a symlinked
|
|
675
|
+
// file or symlinked directory is detected and skipped here.
|
|
676
|
+
if (entry.isSymbolicLink())
|
|
677
|
+
continue;
|
|
113
678
|
const fullPath = join(dir, String(entry.name));
|
|
114
679
|
if (entry.isDirectory()) {
|
|
115
|
-
results.push(...(await collectExtraFiles(fullPath, mainFile, baseDir)));
|
|
680
|
+
results.push(...(await collectExtraFiles(fullPath, mainFile, baseDir, depthLeft - 1, countSoFar)));
|
|
681
|
+
}
|
|
682
|
+
else if (entry.isFile() && fullPath !== mainFile) {
|
|
683
|
+
try {
|
|
684
|
+
const ext = (entry.name.match(/\.[^.]+$/)?.[0] ?? '').toLowerCase();
|
|
685
|
+
const isText = TEXT_EXTENSIONS.has(ext);
|
|
686
|
+
const content = isText
|
|
687
|
+
? await readFile(fullPath, 'utf-8')
|
|
688
|
+
: await readFile(fullPath); // Buffer; renderer writes raw
|
|
689
|
+
results.push({
|
|
690
|
+
relativePath: relative(baseDir, fullPath).replace(/\\/g, '/'),
|
|
691
|
+
content: content,
|
|
692
|
+
});
|
|
693
|
+
countSoFar.value++;
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// EACCES / EISDIR / permission errors: skip the file but
|
|
697
|
+
// continue walking. One bad file shouldn't tank the whole
|
|
698
|
+
// skill resolution. (Pre-cycle-3 behavior aborted the resolve here.)
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return results;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Resolve a list of agent entries (file: or discover:) into
|
|
707
|
+
* `ResolvedAgent[]`. Exposed so adapters (e.g. the dobby adapter) can
|
|
708
|
+
* pump entries through the same code path used by `resolveManifest`
|
|
709
|
+
* rather than re-implementing agent loading + frontmatter parsing.
|
|
710
|
+
*
|
|
711
|
+
* Each entry expands to zero, one, or many ResolvedAgent values:
|
|
712
|
+
* - `file:` -> 1 entry (read agent.md, parse frontmatter for ALL
|
|
713
|
+
* metadata: name, description, model, tools, mcp-servers).
|
|
714
|
+
* - `discover:` -> N entries (one per immediate subdir containing
|
|
715
|
+
* an `*.agent.md`). Within a discover entry, later paths
|
|
716
|
+
* override earlier ones on duplicate agent ids.
|
|
717
|
+
*
|
|
718
|
+
* Per U4, the .agent.md frontmatter is the only source of agent
|
|
719
|
+
* metadata. Manifest entries declare routing (the file path) only;
|
|
720
|
+
* model/tools overrides at the entry level are not honored. Per U4.5,
|
|
721
|
+
* the same applies to per-agent MCPs: an agent file's frontmatter
|
|
722
|
+
* `mcp-servers:` block is the single source of per-agent MCP
|
|
723
|
+
* declarations. The block is a map keyed by server id; each value
|
|
724
|
+
* carries the same fields as a top-level inline MCP entry (type,
|
|
725
|
+
* command, args, url, env, binary, ...).
|
|
726
|
+
*
|
|
727
|
+
* Across the entries array, later entries override earlier ones on
|
|
728
|
+
* duplicate agent ids -- so caller order encodes precedence
|
|
729
|
+
* (consistent with U2's skill-discovery rule).
|
|
730
|
+
*/
|
|
731
|
+
export async function resolveAgentEntries(entries, templateDir, log = () => { }, registryRoot) {
|
|
732
|
+
const mcpRoot = registryRoot ?? templateDir;
|
|
733
|
+
const byId = new Map();
|
|
734
|
+
for (const entry of entries) {
|
|
735
|
+
if (isDiscoverAgentRef(entry)) {
|
|
736
|
+
const discovered = await discoverAgentsFromDirs(entry.discover, templateDir, log, mcpRoot);
|
|
737
|
+
for (const a of discovered)
|
|
738
|
+
byId.set(a.agentId, a);
|
|
739
|
+
continue;
|
|
116
740
|
}
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
741
|
+
if (isLocalFileRef(entry)) {
|
|
742
|
+
const filePath = await safeJoinUnder(templateDir, entry.file);
|
|
743
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
744
|
+
const fm = parseFrontmatterRaw(raw);
|
|
745
|
+
const legacy = await readLegacyAgentManifestFromDir(dirname(filePath));
|
|
746
|
+
const legacyAgent = legacy?.agent;
|
|
747
|
+
const agentId = assertSafePathSegment('agent id', deriveAgentIdFromSourcePath(entry.file), entry.file);
|
|
748
|
+
const name = assertSafePathSegment('agent', (typeof fm.name === 'string' ? fm.name : undefined) ?? legacyAgent?.name ?? agentId, entry.file);
|
|
749
|
+
const description = (typeof fm.description === 'string' ? fm.description : undefined) ?? legacyAgent?.description ?? '';
|
|
750
|
+
const content = stripFrontmatter(raw);
|
|
751
|
+
const sourcePath = basename(filePath) === 'agent.md'
|
|
752
|
+
? join(dirname(filePath), `${agentId}.agent.md`)
|
|
753
|
+
: filePath;
|
|
754
|
+
// Per U4: all metadata comes from frontmatter. No entry-level
|
|
755
|
+
// model/tools overlay -- the .agent.md file is the single source
|
|
756
|
+
// of truth. Dobby's `model:` is canonically a YAML array; when
|
|
757
|
+
// we see one, take the first string element and warn loudly so
|
|
758
|
+
// the author knows loom requires a single string.
|
|
759
|
+
const model = modelFromFrontmatter(fm.model, `agent ${entry.file}`, log) ?? legacyAgent?.model;
|
|
760
|
+
const tools = toolsFromFrontmatter(fm.tools) ?? legacyAgent?.tools;
|
|
761
|
+
// Per U4.5: per-agent MCPs come from the `mcp-servers:` block in
|
|
762
|
+
// the agent file's frontmatter (replacing the legacy
|
|
763
|
+
// `agents/<id>/manifest.yaml` sibling YAML).
|
|
764
|
+
const mcp = await resolveAgentFrontmatterMcps(fm, mcpRoot, log);
|
|
765
|
+
byId.set(agentId, {
|
|
766
|
+
agentId,
|
|
767
|
+
name,
|
|
768
|
+
description,
|
|
769
|
+
model,
|
|
770
|
+
tools,
|
|
121
771
|
content,
|
|
772
|
+
sourcePath,
|
|
773
|
+
mcp,
|
|
122
774
|
});
|
|
775
|
+
continue;
|
|
123
776
|
}
|
|
777
|
+
throw new Error(`Unknown agent entry type: ${JSON.stringify(entry)}`);
|
|
124
778
|
}
|
|
125
|
-
return
|
|
779
|
+
return Array.from(byId.values());
|
|
126
780
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
781
|
+
function modelFromFrontmatter(value, label, log) {
|
|
782
|
+
if (typeof value === 'string')
|
|
783
|
+
return value;
|
|
784
|
+
if (Array.isArray(value) && typeof value[0] === 'string') {
|
|
785
|
+
log(` Warning: ${label}: \`model:\` is an array; using first element "${value[0]}". Loom requires a single string.`);
|
|
786
|
+
return value[0];
|
|
787
|
+
}
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
function toolsFromFrontmatter(value) {
|
|
791
|
+
return Array.isArray(value) ? value.map(String) : undefined;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Walk each listed directory looking for `<id>/<id>.agent.md` (dobby
|
|
795
|
+
* convention), `<id>/<basename>.agent.md` (loom convention), or legacy
|
|
796
|
+
* `<id>/agent.md` with sibling manifest metadata, and return a
|
|
797
|
+
* ResolvedAgent[] with parsed metadata.
|
|
798
|
+
*
|
|
799
|
+
* Within `dirs`, later paths override earlier ones on duplicate agent
|
|
800
|
+
* ids -- so caller order encodes precedence (consistent with U2). The
|
|
801
|
+
* subdir name is the canonical agent id; multiple `.agent.md` files in
|
|
802
|
+
* one dir are ambiguous and trigger a warning (with deterministic
|
|
803
|
+
* fallback to `<id>.agent.md` if present, else first alphabetically).
|
|
804
|
+
*
|
|
805
|
+
* Relative paths are resolved against `baseDir`; absolute paths are
|
|
806
|
+
* used as-is. Missing directories are silently skipped.
|
|
807
|
+
*
|
|
808
|
+
* Per U4.5, per-agent MCPs are read from the agent file's frontmatter
|
|
809
|
+
* `mcp-servers:` block. Legacy sibling `manifest.yaml` files are still
|
|
810
|
+
* accepted as a deprecated metadata/content fallback during migration.
|
|
811
|
+
*/
|
|
812
|
+
async function discoverAgentsFromDirs(dirs, baseDir, log, registryRoot) {
|
|
813
|
+
const byId = new Map();
|
|
814
|
+
for (const rawDir of dirs) {
|
|
815
|
+
let agentsRoot;
|
|
816
|
+
if (isAbsolute(rawDir)) {
|
|
817
|
+
agentsRoot = rawDir;
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
try {
|
|
821
|
+
agentsRoot = await safeJoinUnder(baseDir, rawDir);
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
log(` Warning: skipping agent discover dir: ${err.message}`);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
let entries;
|
|
829
|
+
try {
|
|
830
|
+
entries = (await readdir(agentsRoot, { withFileTypes: true }));
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
continue; // Directory doesn't exist -- that's fine, just skip
|
|
834
|
+
}
|
|
835
|
+
for (const entry of entries) {
|
|
836
|
+
if (!entry.isDirectory())
|
|
837
|
+
continue;
|
|
838
|
+
const id = String(entry.name);
|
|
839
|
+
if (!isSafePathSegment(id)) {
|
|
840
|
+
log(` Warning: skipping agent ${id}: ${unsafePathSegmentMessage('agent id', id, agentsRoot)}`);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const agentDir = join(agentsRoot, id);
|
|
844
|
+
const agentMdPath = await findAgentFile(agentDir, id, log);
|
|
845
|
+
if (!agentMdPath)
|
|
846
|
+
continue;
|
|
847
|
+
try {
|
|
848
|
+
const raw = await readFile(agentMdPath, 'utf-8');
|
|
849
|
+
const fm = parseFrontmatterRaw(raw);
|
|
850
|
+
const legacy = await readLegacyAgentManifestFromDir(agentDir);
|
|
851
|
+
const legacyAgent = legacy?.agent;
|
|
852
|
+
const name = (typeof fm.name === 'string' ? fm.name : undefined) ?? legacyAgent?.name ?? id;
|
|
853
|
+
if (!isSafePathSegment(name)) {
|
|
854
|
+
log(` Warning: skipping agent ${id}: ${unsafePathSegmentMessage('agent', name, agentMdPath)}`);
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
const description = (typeof fm.description === 'string' ? fm.description : undefined) ?? legacyAgent?.description ?? '';
|
|
858
|
+
const model = modelFromFrontmatter(fm.model, `agent ${id}`, log) ?? legacyAgent?.model;
|
|
859
|
+
const tools = toolsFromFrontmatter(fm.tools) ?? legacyAgent?.tools;
|
|
860
|
+
const content = stripFrontmatter(raw);
|
|
861
|
+
const mcp = await resolveAgentFrontmatterMcps(fm, registryRoot, log);
|
|
862
|
+
const sourcePath = basename(agentMdPath) === 'agent.md'
|
|
863
|
+
? join(agentDir, `${id}.agent.md`)
|
|
864
|
+
: agentMdPath;
|
|
865
|
+
// Last-write-wins on duplicate ids: later dirs override earlier.
|
|
866
|
+
byId.set(id, {
|
|
867
|
+
agentId: id,
|
|
868
|
+
name,
|
|
869
|
+
description,
|
|
870
|
+
model,
|
|
871
|
+
tools,
|
|
872
|
+
content,
|
|
873
|
+
sourcePath,
|
|
874
|
+
mcp,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
log(` Warning: could not read agent ${id}: ${err.message}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return Array.from(byId.values());
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Convert an agent file's frontmatter `mcp-servers:` block (a map keyed
|
|
886
|
+
* by server id) into a `ResolvedMcp[]`. The block uses the same value
|
|
887
|
+
* shape as a top-level inline MCP entry (type, command, args, url,
|
|
888
|
+
* env, binary, ...); the map key supplies the `id`. Returns
|
|
889
|
+
* `undefined` if the block is missing, empty, or malformed -- matching
|
|
890
|
+
* "no per-agent MCPs declared" semantics.
|
|
891
|
+
*
|
|
892
|
+
* Inline-only: `$ref` is not supported inside frontmatter. Per-agent
|
|
893
|
+
* MCPs that need to live in shared/ should be declared at team level
|
|
894
|
+
* via the manifest's `mcp:` block instead.
|
|
895
|
+
*/
|
|
896
|
+
async function resolveAgentFrontmatterMcps(fm, registryRoot, log) {
|
|
897
|
+
const block = fm['mcp-servers'];
|
|
898
|
+
if (!block || typeof block !== 'object' || Array.isArray(block))
|
|
899
|
+
return undefined;
|
|
900
|
+
const raw = block;
|
|
901
|
+
const mcpEntries = [];
|
|
902
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
903
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
904
|
+
continue;
|
|
905
|
+
const def = value;
|
|
906
|
+
const type = def.type === 'http' ? 'http' : 'stdio';
|
|
907
|
+
const inline = {
|
|
908
|
+
id,
|
|
909
|
+
name: typeof def.name === 'string' ? def.name : undefined,
|
|
910
|
+
description: typeof def.description === 'string' ? def.description : undefined,
|
|
911
|
+
type,
|
|
912
|
+
command: typeof def.command === 'string' ? def.command : undefined,
|
|
913
|
+
args: Array.isArray(def.args) ? def.args.map(String) : undefined,
|
|
914
|
+
cwd: typeof def.cwd === 'string' ? def.cwd : undefined,
|
|
915
|
+
url: typeof def.url === 'string' ? def.url : undefined,
|
|
916
|
+
authType: typeof def.authType === 'string' ? def.authType : undefined,
|
|
917
|
+
headers: def.headers && typeof def.headers === 'object' && !Array.isArray(def.headers)
|
|
918
|
+
? def.headers
|
|
919
|
+
: undefined,
|
|
920
|
+
env: def.env && typeof def.env === 'object' && !Array.isArray(def.env)
|
|
921
|
+
? def.env
|
|
922
|
+
: undefined,
|
|
923
|
+
binary: def.binary && typeof def.binary === 'object' && !Array.isArray(def.binary)
|
|
924
|
+
? def.binary
|
|
925
|
+
: undefined,
|
|
142
926
|
};
|
|
143
|
-
|
|
927
|
+
mcpEntries.push(inline);
|
|
928
|
+
}
|
|
929
|
+
if (mcpEntries.length === 0)
|
|
930
|
+
return undefined;
|
|
931
|
+
return resolveMcpEntries(mcpEntries, registryRoot, log);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Find the agent markdown file inside an agent directory. Accepts both
|
|
935
|
+
* the dobby convention (`<id>.agent.md`), the loom convention (a single
|
|
936
|
+
* `*.agent.md` file with any basename), and legacy `agent.md` fallback.
|
|
937
|
+
* Multiple `.agent.md` candidates trigger a warning and a deterministic
|
|
938
|
+
* fallback.
|
|
939
|
+
*/
|
|
940
|
+
async function findAgentFile(agentDir, id, log) {
|
|
941
|
+
let entries;
|
|
942
|
+
try {
|
|
943
|
+
entries = (await readdir(agentDir, { withFileTypes: true }));
|
|
944
|
+
}
|
|
945
|
+
catch {
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
const agentFiles = entries
|
|
949
|
+
.filter((e) => e.isFile() && String(e.name).endsWith('.agent.md'))
|
|
950
|
+
.map((e) => String(e.name))
|
|
951
|
+
.sort();
|
|
952
|
+
if (agentFiles.length === 0) {
|
|
953
|
+
const legacyAgentMd = join(agentDir, 'agent.md');
|
|
954
|
+
return (await fileExists(legacyAgentMd)) ? legacyAgentMd : undefined;
|
|
955
|
+
}
|
|
956
|
+
if (agentFiles.length === 1)
|
|
957
|
+
return join(agentDir, agentFiles[0]);
|
|
958
|
+
// Multiple candidates: prefer <id>.agent.md (no warning -- that's
|
|
959
|
+
// the dobby convention and the canonical pick). Otherwise warn and
|
|
960
|
+
// fall back to first alphabetically.
|
|
961
|
+
const idMatch = `${id}.agent.md`;
|
|
962
|
+
if (agentFiles.includes(idMatch)) {
|
|
963
|
+
return join(agentDir, idMatch);
|
|
964
|
+
}
|
|
965
|
+
log(` Warning: agent dir ${agentDir} has multiple .agent.md files ` +
|
|
966
|
+
`(${agentFiles.join(', ')}); using ${agentFiles[0]} alphabetically. ` +
|
|
967
|
+
`Rename one to ${idMatch} to make this deterministic.`);
|
|
968
|
+
return join(agentDir, agentFiles[0]);
|
|
144
969
|
}
|
|
145
|
-
|
|
970
|
+
/**
|
|
971
|
+
* Derive the stable agent identifier from a source file path. This
|
|
972
|
+
* is the slug callers use to look the agent up at launch time and
|
|
973
|
+
* the basename of the rendered `.agent.md` file. Two layouts:
|
|
974
|
+
*
|
|
975
|
+
* - Loom nested layout: `agents/<id>/agent.md` -> id = `<id>`
|
|
976
|
+
* - Dobby / flat layout: `<id>.agent.md` -> id = `<id>`
|
|
977
|
+
*
|
|
978
|
+
* The function picks the parent dir name when it looks like a
|
|
979
|
+
* loom-nested layout (parent dir is non-trivial and not `agents/`);
|
|
980
|
+
* otherwise it falls back to the basename without the `.agent.md`
|
|
981
|
+
* extension.
|
|
982
|
+
*/
|
|
983
|
+
export function deriveAgentIdFromSourcePath(sourcePath) {
|
|
984
|
+
const stem = basename(sourcePath, '.agent.md').replace(/\.md$/, '');
|
|
985
|
+
const parentDir = basename(dirname(sourcePath));
|
|
986
|
+
if (parentDir && parentDir !== '.' && parentDir !== 'agents') {
|
|
987
|
+
return parentDir;
|
|
988
|
+
}
|
|
989
|
+
return stem;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Resolve a list of MCP entries (shared $ref or inline) into
|
|
993
|
+
* `ResolvedMcp[]`. Exposed for adapters that resolve per-agent MCP
|
|
994
|
+
* blocks separately from the team-level ones (loom#127).
|
|
995
|
+
*
|
|
996
|
+
* Inline entries with a `binary:` block (and no explicit `command`) are
|
|
997
|
+
* routed through the binary cache: on first use the asset is fetched
|
|
998
|
+
* from the GitHub release into ~/.loom/mcp-binaries/ and the absolute
|
|
999
|
+
* cache path becomes the resolved `command`. If resolution fails the
|
|
1000
|
+
* entry passes through with an undefined command and downstream
|
|
1001
|
+
* launch surfaces a clear error.
|
|
1002
|
+
*
|
|
1003
|
+
* Pure: reads YAML for $ref entries, no provenance tagging, no
|
|
1004
|
+
* dedup. The caller decides how to combine results.
|
|
1005
|
+
*/
|
|
1006
|
+
export async function resolveMcpEntries(entries, registryRoot, log = () => { }) {
|
|
146
1007
|
return Promise.all(entries.map(async (entry) => {
|
|
147
1008
|
if (isSharedRef(entry)) {
|
|
148
|
-
const filePath =
|
|
1009
|
+
const filePath = await safeJoinUnder(registryRoot, entry.$ref);
|
|
149
1010
|
const content = await readFile(filePath, 'utf-8');
|
|
150
1011
|
const parsed = yaml.load(content);
|
|
151
1012
|
return {
|
|
@@ -155,21 +1016,38 @@ async function resolveMcp(entries, registryRoot) {
|
|
|
155
1016
|
type: parsed.type,
|
|
156
1017
|
command: parsed.command,
|
|
157
1018
|
args: parsed.args,
|
|
1019
|
+
cwd: parsed.cwd,
|
|
158
1020
|
url: parsed.url,
|
|
159
1021
|
authType: parsed.authType,
|
|
1022
|
+
// U6: forward static auth headers from shared MCP files so a
|
|
1023
|
+
// manifest referencing shared/mcp/foo.yaml with required
|
|
1024
|
+
// headers ends up with a working server definition.
|
|
1025
|
+
headers: parsed.headers,
|
|
160
1026
|
env: parsed.env,
|
|
161
1027
|
};
|
|
162
1028
|
}
|
|
163
1029
|
if (isMcpInlineDef(entry)) {
|
|
1030
|
+
let command = entry.command;
|
|
1031
|
+
// `binary:` resolution: download the release asset (or hit the
|
|
1032
|
+
// cache) and use the absolute cached path as the launch command.
|
|
1033
|
+
// Only fires when no explicit `command` was provided.
|
|
1034
|
+
if (entry.binary && !command) {
|
|
1035
|
+
const cached = await resolveBinaryToCache(entry.binary, log);
|
|
1036
|
+
if (cached) {
|
|
1037
|
+
command = cached.cachedPath;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
164
1040
|
return {
|
|
165
1041
|
id: entry.id,
|
|
166
1042
|
name: entry.name,
|
|
167
1043
|
description: entry.description,
|
|
168
1044
|
type: entry.type,
|
|
169
|
-
command
|
|
1045
|
+
command,
|
|
170
1046
|
args: entry.args,
|
|
1047
|
+
cwd: entry.cwd,
|
|
171
1048
|
url: entry.url,
|
|
172
1049
|
authType: entry.authType,
|
|
1050
|
+
headers: entry.headers,
|
|
173
1051
|
env: entry.env,
|
|
174
1052
|
};
|
|
175
1053
|
}
|
|
@@ -179,7 +1057,7 @@ async function resolveMcp(entries, registryRoot) {
|
|
|
179
1057
|
async function resolveRepos(entries, registryRoot) {
|
|
180
1058
|
return Promise.all(entries.map(async (entry) => {
|
|
181
1059
|
if (isSharedRef(entry)) {
|
|
182
|
-
const filePath =
|
|
1060
|
+
const filePath = await safeJoinUnder(registryRoot, entry.$ref);
|
|
183
1061
|
const content = await readFile(filePath, 'utf-8');
|
|
184
1062
|
const parsed = yaml.load(content);
|
|
185
1063
|
return {
|
|
@@ -215,42 +1093,43 @@ async function resolvePrompts(entries, templateDir, registryRoot) {
|
|
|
215
1093
|
let filePath;
|
|
216
1094
|
let sourcePath;
|
|
217
1095
|
if (isSharedRef(entry)) {
|
|
218
|
-
filePath =
|
|
1096
|
+
filePath = await safeJoinUnder(registryRoot, entry.$ref);
|
|
219
1097
|
sourcePath = entry.$ref;
|
|
220
1098
|
}
|
|
221
1099
|
else if (isLocalFileRef(entry)) {
|
|
222
|
-
filePath =
|
|
1100
|
+
filePath = await safeJoinUnder(templateDir, entry.file);
|
|
223
1101
|
sourcePath = entry.file;
|
|
224
1102
|
}
|
|
225
1103
|
else {
|
|
226
1104
|
throw new Error(`Unknown prompt entry type: ${JSON.stringify(entry)}`);
|
|
227
1105
|
}
|
|
228
1106
|
const raw = await readFile(filePath, 'utf-8');
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
1107
|
+
// Per U4: prompt metadata (name, description, agent, model,
|
|
1108
|
+
// tools) comes from the .prompt.md frontmatter only. The entry
|
|
1109
|
+
// declares routing (file path) and behavior flags (shared,
|
|
1110
|
+
// disableAutoInvoke) -- not metadata.
|
|
1111
|
+
const name = parseFrontmatterField(raw, 'name') ??
|
|
1112
|
+
basename(filePath, extname(filePath)).replace(/\.prompt$/, '');
|
|
1113
|
+
assertSafePathSegment('prompt', name, sourcePath);
|
|
1114
|
+
const description = parseFrontmatterField(raw, 'description');
|
|
1115
|
+
const localRef = isLocalFileRef(entry) ? entry : undefined;
|
|
237
1116
|
return {
|
|
238
1117
|
name,
|
|
239
1118
|
description,
|
|
240
1119
|
content: raw,
|
|
241
1120
|
sourcePath,
|
|
242
|
-
agent:
|
|
243
|
-
model:
|
|
244
|
-
tools:
|
|
245
|
-
shared: localRef
|
|
246
|
-
disableAutoInvoke: localRef
|
|
1121
|
+
agent: parseFrontmatterField(raw, 'agent'),
|
|
1122
|
+
model: parseFrontmatterField(raw, 'model'),
|
|
1123
|
+
tools: parseFrontmatterArrayField(raw, 'tools'),
|
|
1124
|
+
shared: localRef?.shared ?? false,
|
|
1125
|
+
disableAutoInvoke: localRef?.disableAutoInvoke ?? false,
|
|
247
1126
|
};
|
|
248
1127
|
}));
|
|
249
1128
|
}
|
|
250
1129
|
async function resolvePrerequisites(entries, registryRoot) {
|
|
251
1130
|
return Promise.all(entries.map(async (entry) => {
|
|
252
1131
|
if (isSharedRef(entry)) {
|
|
253
|
-
const filePath =
|
|
1132
|
+
const filePath = await safeJoinUnder(registryRoot, entry.$ref);
|
|
254
1133
|
const content = await readFile(filePath, 'utf-8');
|
|
255
1134
|
const parsed = yaml.load(content);
|
|
256
1135
|
return {
|
|
@@ -267,120 +1146,15 @@ async function resolvePrerequisites(entries, registryRoot) {
|
|
|
267
1146
|
return inline;
|
|
268
1147
|
}));
|
|
269
1148
|
}
|
|
270
|
-
// =============================================================================
|
|
271
|
-
// Nested agent support
|
|
272
|
-
// =============================================================================
|
|
273
|
-
/**
|
|
274
|
-
* Parse an agent manifest.yaml file (per-agent within a team template).
|
|
275
|
-
*/
|
|
276
|
-
export async function parseAgentManifest(manifestPath) {
|
|
277
|
-
const content = await readFile(manifestPath, 'utf-8');
|
|
278
|
-
const raw = yaml.load(content);
|
|
279
|
-
return raw;
|
|
280
|
-
}
|
|
281
|
-
/**
|
|
282
|
-
* Discover nested agent directories within a template.
|
|
283
|
-
* Returns agent directory names if the template has an agents/ subdirectory
|
|
284
|
-
* where each subdir contains a manifest.yaml.
|
|
285
|
-
*/
|
|
286
|
-
export async function discoverNestedAgents(templateDir) {
|
|
287
|
-
const { readdir, stat } = await import('node:fs/promises');
|
|
288
|
-
const agentsDir = resolve(templateDir, 'agents');
|
|
289
|
-
try {
|
|
290
|
-
const entries = await readdir(agentsDir);
|
|
291
|
-
const agentIds = [];
|
|
292
|
-
for (const entry of entries) {
|
|
293
|
-
const entryPath = resolve(agentsDir, entry);
|
|
294
|
-
const manifestPath = resolve(entryPath, 'manifest.yaml');
|
|
295
|
-
const s = await stat(entryPath).catch(() => null);
|
|
296
|
-
if (s?.isDirectory()) {
|
|
297
|
-
const hasManifest = await stat(manifestPath).then(() => true).catch(() => false);
|
|
298
|
-
if (hasManifest) {
|
|
299
|
-
agentIds.push(entry);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
return agentIds.sort();
|
|
304
|
-
}
|
|
305
|
-
catch {
|
|
306
|
-
return [];
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Merge a team manifest with one or more agent manifests to produce
|
|
311
|
-
* a combined manifest ready for resolving.
|
|
312
|
-
*
|
|
313
|
-
* Merge rules:
|
|
314
|
-
* - repos: agent only (team repos are NOT included)
|
|
315
|
-
* - mcp, instructions, skills, prompts, prerequisites: union (team + agent)
|
|
316
|
-
* - agents: built from agent.md files in selected agents
|
|
317
|
-
* - targets: inherited from team
|
|
318
|
-
*/
|
|
319
|
-
export function mergeTeamAndAgentManifests(teamManifest, agentManifests, agentDirs) {
|
|
320
|
-
// Build agent entries from the per-agent data
|
|
321
|
-
const agentEntries = agentManifests.map((am, i) => ({
|
|
322
|
-
file: `agents/${agentDirs[i]}/agent.md`,
|
|
323
|
-
model: am.agent.model,
|
|
324
|
-
tools: am.agent.tools,
|
|
325
|
-
}));
|
|
326
|
-
// Collect agent-specific MCP, instructions, skills, prompts, prerequisites
|
|
327
|
-
const agentMcps = [];
|
|
328
|
-
const agentInstructions = [];
|
|
329
|
-
const agentSkills = [];
|
|
330
|
-
const agentPrompts = [];
|
|
331
|
-
const agentPrereqs = [];
|
|
332
|
-
// Repos come ONLY from agents
|
|
333
|
-
const agentRepos = [];
|
|
334
|
-
for (const am of agentManifests) {
|
|
335
|
-
if (am.contents?.mcp)
|
|
336
|
-
agentMcps.push(...am.contents.mcp);
|
|
337
|
-
if (am.contents?.instructions)
|
|
338
|
-
agentInstructions.push(...am.contents.instructions);
|
|
339
|
-
if (am.contents?.skills)
|
|
340
|
-
agentSkills.push(...am.contents.skills);
|
|
341
|
-
if (am.contents?.prompts)
|
|
342
|
-
agentPrompts.push(...am.contents.prompts);
|
|
343
|
-
if (am.contents?.repos)
|
|
344
|
-
agentRepos.push(...am.contents.repos);
|
|
345
|
-
if (am.prerequisites)
|
|
346
|
-
agentPrereqs.push(...am.prerequisites);
|
|
347
|
-
}
|
|
348
|
-
// Deduplicate MCP by $ref value or id
|
|
349
|
-
const teamMcps = teamManifest.contents.mcp ?? [];
|
|
350
|
-
const allMcps = deduplicateEntries([...teamMcps, ...agentMcps], mcpEntryKey);
|
|
351
|
-
// Deduplicate skills
|
|
352
|
-
const teamSkills = teamManifest.contents.skills ?? [];
|
|
353
|
-
const allSkills = deduplicateEntries([...teamSkills, ...agentSkills], skillEntryKey);
|
|
354
|
-
// Deduplicate prerequisites
|
|
355
|
-
const teamPrereqs = teamManifest.prerequisites ?? [];
|
|
356
|
-
const allPrereqs = deduplicateEntries([...teamPrereqs, ...agentPrereqs], prereqEntryKey);
|
|
357
|
-
// Deduplicate repos
|
|
358
|
-
const allRepos = deduplicateEntries(agentRepos, repoEntryKey);
|
|
359
|
-
return {
|
|
360
|
-
version: teamManifest.version,
|
|
361
|
-
template: teamManifest.template,
|
|
362
|
-
targets: teamManifest.targets,
|
|
363
|
-
contents: {
|
|
364
|
-
instructions: [...(teamManifest.contents.instructions ?? []), ...agentInstructions],
|
|
365
|
-
skills: allSkills,
|
|
366
|
-
agents: agentEntries,
|
|
367
|
-
mcp: allMcps,
|
|
368
|
-
repos: allRepos,
|
|
369
|
-
prompts: [...(teamManifest.contents.prompts ?? []), ...agentPrompts],
|
|
370
|
-
},
|
|
371
|
-
prerequisites: allPrereqs,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
// --- Deduplication helpers ---
|
|
375
1149
|
function deduplicateEntries(entries, keyFn) {
|
|
376
1150
|
const seen = new Set();
|
|
377
1151
|
const result = [];
|
|
378
1152
|
for (const entry of entries) {
|
|
379
1153
|
const key = keyFn(entry);
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
1154
|
+
if (seen.has(key))
|
|
1155
|
+
continue;
|
|
1156
|
+
seen.add(key);
|
|
1157
|
+
result.push(entry);
|
|
384
1158
|
}
|
|
385
1159
|
return result;
|
|
386
1160
|
}
|
|
@@ -394,6 +1168,8 @@ function mcpEntryKey(entry) {
|
|
|
394
1168
|
function skillEntryKey(entry) {
|
|
395
1169
|
if (isRegistrySkillRef(entry))
|
|
396
1170
|
return `registry:${entry.registry}:${entry.ref}`;
|
|
1171
|
+
if (isDiscoverSkillsRef(entry))
|
|
1172
|
+
return `discover:${entry.discover.join('|')}`;
|
|
397
1173
|
if (isLocalFileRef(entry))
|
|
398
1174
|
return `file:${entry.file}`;
|
|
399
1175
|
return JSON.stringify(entry);
|
|
@@ -406,7 +1182,8 @@ function prereqEntryKey(entry) {
|
|
|
406
1182
|
function repoEntryKey(entry) {
|
|
407
1183
|
if (isSharedRef(entry))
|
|
408
1184
|
return `ref:${entry.$ref}`;
|
|
409
|
-
|
|
1185
|
+
const inline = entry;
|
|
1186
|
+
return inline.id ? `id:${inline.id}` : `url:${inline.url ?? JSON.stringify(entry)}`;
|
|
410
1187
|
}
|
|
411
1188
|
// =============================================================================
|
|
412
1189
|
// Frontmatter helpers
|
|
@@ -415,40 +1192,18 @@ function repoEntryKey(entry) {
|
|
|
415
1192
|
* Parse a single field from YAML frontmatter (--- delimited).
|
|
416
1193
|
*/
|
|
417
1194
|
function parseFrontmatterField(content, field) {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
try {
|
|
422
|
-
const fm = yaml.load(match[1]);
|
|
423
|
-
const value = fm[field];
|
|
424
|
-
return typeof value === 'string' ? value : undefined;
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
return undefined;
|
|
428
|
-
}
|
|
1195
|
+
const fm = parseFrontmatterRaw(content);
|
|
1196
|
+
const value = fm[field];
|
|
1197
|
+
return typeof value === 'string' ? value : undefined;
|
|
429
1198
|
}
|
|
430
1199
|
/**
|
|
431
1200
|
* Parse an array field from YAML frontmatter.
|
|
432
1201
|
*/
|
|
433
1202
|
function parseFrontmatterArrayField(content, field) {
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const value = fm[field];
|
|
440
|
-
if (Array.isArray(value))
|
|
441
|
-
return value.map(String);
|
|
442
|
-
return undefined;
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
return undefined;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* Strip YAML frontmatter from content, returning only the body.
|
|
450
|
-
*/
|
|
451
|
-
function stripFrontmatter(content) {
|
|
452
|
-
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '').trim();
|
|
1203
|
+
const fm = parseFrontmatterRaw(content);
|
|
1204
|
+
const value = fm[field];
|
|
1205
|
+
if (Array.isArray(value))
|
|
1206
|
+
return value.map(String);
|
|
1207
|
+
return undefined;
|
|
453
1208
|
}
|
|
454
1209
|
//# sourceMappingURL=manifest.js.map
|