@skanl/brambo-projection 0.1.1

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.
@@ -0,0 +1,165 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ // Skills materialisation, one trait record per executor.
5
+ //
6
+ // A skill is a directory `<root>/<id>/SKILL.md`, and all three shipped
7
+ // executors read exactly that shape. VERIFIED BY EXECUTION against each real
8
+ // binary, not by reading a document — `test/skills-discovery.live.test.ts`
9
+ // plants a skill through brambo under an injected home and then asks the
10
+ // executor itself what it found:
11
+ //
12
+ // claude-code `<home>/.claude/skills`
13
+ // measured by pointing `ANTHROPIC_BASE_URL` at a local stub and
14
+ // reading the request claude sends: the planted skill is in its
15
+ // own `available skills` block. The executor declares what it
16
+ // discovered, so nothing is inferred.
17
+ // codex `<home>/.codex/skills`
18
+ // measured with `codex debug prompt-input`, which renders the
19
+ // model-visible prompt and names the planted SKILL.md by path.
20
+ // opencode `<home>/.config/opencode/skills`
21
+ // measured with `opencode debug skill`, which lists every skill
22
+ // it can see with the absolute location it loaded each from.
23
+ // Note the PLURAL directory name; `skill` is the command, not
24
+ // the folder.
25
+ //
26
+ // What is deliberately NOT here: OpenCode's `skills.paths[]`. Story 2.9's
27
+ // inherited criteria named "a directory plus its `skills.paths[]` entry", and
28
+ // the installed `opencode.json` has no `skills` key at all — OpenCode finds this
29
+ // root by convention. Writing that key would be brambo inventing vocabulary at a
30
+ // location the vendor does not read, which is the whole of correction-01.
31
+ //
32
+ // Brambo COPIES; it never authors. A registry skill entry carries `entryPath`, a
33
+ // POINTER, and what that points at is placed verbatim.
34
+ /** The file name every one of the three executors requires. */
35
+ export const SKILL_ENTRY_FILE = 'SKILL.md';
36
+ export const CLAUDE_SKILLS_TARGET_ID = 'claude-skills';
37
+ export const CODEX_SKILLS_TARGET_ID = 'codex-skills';
38
+ export const OPENCODE_SKILLS_TARGET_ID = 'opencode-skills';
39
+ export const CLAUDE_SKILLS_TRAITS = {
40
+ targetId: CLAUDE_SKILLS_TARGET_ID,
41
+ defaultRoot: join(homedir(), '.claude', 'skills'),
42
+ };
43
+ export const CODEX_SKILLS_TRAITS = {
44
+ targetId: CODEX_SKILLS_TARGET_ID,
45
+ defaultRoot: join(homedir(), '.codex', 'skills'),
46
+ };
47
+ export const OPENCODE_SKILLS_TRAITS = {
48
+ targetId: OPENCODE_SKILLS_TARGET_ID,
49
+ defaultRoot: join(homedir(), '.config', 'opencode', 'skills'),
50
+ };
51
+ /**
52
+ * Whether an id can be one directory name under the root.
53
+ *
54
+ * A registry id is an arbitrary non-empty string, and this is the projection
55
+ * that turns one into a PATH. Without this, `../../.ssh` in an id would make
56
+ * brambo write — and later delete — outside the root it owns. The engine repeats
57
+ * the containment check on the resolved path; this one exists so the entry is
58
+ * REPORTED with a reason rather than failing the whole target.
59
+ */
60
+ function isSafeSegment(id) {
61
+ // The dot-only case covers `.`, `..` and `...`: the first two are the obvious
62
+ // traversal, and the third reached the engine's containment guard as a THROWN
63
+ // target failure, so one oddly named skill unmaterialised every skill for
64
+ // that executor instead of being reported as one entry.
65
+ return /^[A-Za-z0-9._-]+$/.test(id) && !/^[.]+$/.test(id);
66
+ }
67
+ /** A source brambo can see and still cannot materialise; reported, never guessed at. */
68
+ class SourceUnusable extends Error {
69
+ }
70
+ async function collectFiles(directory, prefix) {
71
+ const files = [];
72
+ // Sorted, so the same source tree always plans in the same order and the tree
73
+ // hash of an unchanged skill is stable across runs and machines.
74
+ const listing = [...(await readdir(directory, { withFileTypes: true }))].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
75
+ for (const item of listing) {
76
+ const source = join(directory, item.name);
77
+ // `stat`, not the dirent's own kind: a symlink inside a skill is followed,
78
+ // because copying what a link points at is what "copy this tree" means. The
79
+ // DESTINATION is always built from the root and the relative path, so a link
80
+ // aimed anywhere can still only land inside brambo's own directory.
81
+ const stats = await stat(source);
82
+ if (stats.isDirectory()) {
83
+ files.push(...(await collectFiles(source, `${prefix}/${item.name}`)));
84
+ }
85
+ else if (stats.isFile()) {
86
+ files.push({ relativePath: `${prefix}/${item.name}`, sourcePath: resolve(source) });
87
+ }
88
+ }
89
+ return files;
90
+ }
91
+ /**
92
+ * The files one registry entry contributes.
93
+ *
94
+ * `entryPath` is documented as the skill's ENTRY FILE, and a file is placed as
95
+ * the `SKILL.md` every executor looks for — brambo renames the destination, it
96
+ * never rewrites the content. A directory is copied whole, because a real skill
97
+ * keeps references beside its entry file and copying only one of them would
98
+ * materialise something that half works.
99
+ */
100
+ async function filesFor(entryPath, id) {
101
+ const source = resolve(entryPath);
102
+ const stats = await stat(source);
103
+ if (stats.isFile())
104
+ return [{ relativePath: `${id}/${SKILL_ENTRY_FILE}`, sourcePath: source }];
105
+ if (!stats.isDirectory()) {
106
+ throw new SourceUnusable(`'${entryPath}' is neither a file nor a directory`);
107
+ }
108
+ return await collectFiles(source, id);
109
+ }
110
+ export function createSkillsTargetFromTraits(traits, options = {}) {
111
+ const rootPath = options.rootPath ?? traits.defaultRoot;
112
+ return {
113
+ kind: 'materialise',
114
+ targetId: traits.targetId,
115
+ rootPath,
116
+ async plan(request) {
117
+ const skills = [...request.entries.skill].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
118
+ const entries = [];
119
+ const skipped = [];
120
+ for (const entry of skills) {
121
+ const reason = (detail) => {
122
+ skipped.push({ entryId: entry.id, reason: detail });
123
+ };
124
+ if (!isSafeSegment(entry.id)) {
125
+ reason(`'${entry.id}' cannot be a directory name under '${rootPath}', so brambo will not materialise it`);
126
+ continue;
127
+ }
128
+ const entryPath = entry.entryPath;
129
+ if (entryPath === undefined) {
130
+ reason(`the skill entry declares no entryPath, so there is nothing to copy into '${rootPath}'`);
131
+ continue;
132
+ }
133
+ let files;
134
+ try {
135
+ files = await filesFor(entryPath, entry.id);
136
+ }
137
+ catch (error) {
138
+ const detail = error instanceof SourceUnusable
139
+ ? error.message
140
+ : `'${entryPath}' cannot be read (${error?.code ?? 'unknown error'})`;
141
+ reason(`${detail}; nothing was materialised for '${entry.id}'`);
142
+ continue;
143
+ }
144
+ // A tree with no SKILL.md is a tree no executor discovers. Writing it
145
+ // would be the inertness correction-01 exists to prevent, so it is
146
+ // reported instead — brambo does not author the missing file either.
147
+ if (!files.some((file) => file.relativePath.endsWith(`/${SKILL_ENTRY_FILE}`))) {
148
+ reason(`'${entryPath}' holds no ${SKILL_ENTRY_FILE}, so no executor would discover it; brambo will not invent one`);
149
+ continue;
150
+ }
151
+ entries.push({ entryId: entry.id, location: entry.id, files });
152
+ }
153
+ return { entries, presentEntryIds: skills.map((entry) => entry.id), skipped };
154
+ },
155
+ };
156
+ }
157
+ export function createClaudeSkillsTarget(options = {}) {
158
+ return createSkillsTargetFromTraits(CLAUDE_SKILLS_TRAITS, options);
159
+ }
160
+ export function createCodexSkillsTarget(options = {}) {
161
+ return createSkillsTargetFromTraits(CODEX_SKILLS_TRAITS, options);
162
+ }
163
+ export function createOpenCodeSkillsTarget(options = {}) {
164
+ return createSkillsTargetFromTraits(OPENCODE_SKILLS_TRAITS, options);
165
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@skanl/brambo-projection",
3
+ "version": "0.1.1",
4
+ "description": "Projects Registry entries into executors' native configuration, and owns the ledger that can take them back.",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "brambo",
8
+ "projection",
9
+ "configuration",
10
+ "jsonc",
11
+ "toml"
12
+ ],
13
+ "homepage": "https://github.com/SKANL/brambo#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/SKANL/brambo/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/SKANL/brambo.git",
20
+ "directory": "packages/projection"
21
+ },
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "brambo-source": "./src/index.ts",
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "dependencies": {
38
+ "@skanl/brambo-contracts": "0.1.1",
39
+ "@skanl/brambo-lock": "0.1.1",
40
+ "jsonc-parser": "^3.3.1"
41
+ },
42
+ "devDependencies": {
43
+ "@skanl/brambo-registry": "0.1.1",
44
+ "@types/node": "^24.13.3",
45
+ "typescript": "~7.0.2",
46
+ "vitest": "^4.1.11"
47
+ },
48
+ "files": [
49
+ "dist"
50
+ ],
51
+ "scripts": {
52
+ "typecheck": "tsc --noEmit",
53
+ "test": "vitest run",
54
+ "lint": "eslint .",
55
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
56
+ }
57
+ }