@karajan-family/atril 1.0.0 → 1.1.0

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 ADDED
@@ -0,0 +1,123 @@
1
+ # atril
2
+
3
+ Skills, commands and subagents for every AI CLI, on demand.
4
+
5
+ An agent loads every skill and command it finds when a session starts, and each one costs context whether the task needs it or not. atril keeps them in a **store** instead, finds the ones a task needs, and hands back the files to read. Claude Code, Codex, OpenCode and any CLI that reads Markdown can use the same store.
6
+
7
+ atril is a module of the [Karajan](https://karajancode.com) family. It imports nothing from Karajan: Karajan calls it exactly as any other project would.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install -g @karajan-family/atril
13
+ atril --version
14
+ ```
15
+
16
+ Node.js 22.12 or later.
17
+
18
+ ## The store
19
+
20
+ A store is a folder with one directory per kind, the layout the CLIs already use:
21
+
22
+ ```
23
+ .atril/
24
+ skills/<name>/SKILL.md a skill: a folder, so it can carry references
25
+ commands/<name>.md a command
26
+ agents/<name>.md a subagent
27
+ atril.json the store's config
28
+ ```
29
+
30
+ Every artifact is Markdown with frontmatter, and declares at least `name`, `description` and `kind`:
31
+
32
+ ```markdown
33
+ ---
34
+ name: review
35
+ description: Review a pull request for correctness and risk
36
+ kind: command
37
+ ---
38
+ Read the diff, then...
39
+ ```
40
+
41
+ The root is `.atril/` by default; `--root <dir>` or `ATRIL_ROOT` change it (Karajan uses `.karajan/`). A user-level store in `~/.atril` (or `ATRIL_USER_ROOT`) is read too; the project's artifact wins a clash.
42
+
43
+ ## Find and use
44
+
45
+ ```sh
46
+ atril scan # what the stores hold
47
+ atril search "flaky test" # ranked hits, installed and available
48
+ atril route "fix the flaky login test" --role coder
49
+ ```
50
+
51
+ `atril route` resolves a task into a **block**: the best artifact plus what it `uses` (and the agents it `delegates` to, as subagents), ordered so each piece comes after what it depends on, within a budget. It prints the files to read, not their content. `atril enrich` completes each artifact's frontmatter with the relations it discovers, and `atril graph` shows them.
52
+
53
+ ## Install and remove
54
+
55
+ ```sh
56
+ atril install ./my-skill # a local skill folder, command or agent file
57
+ atril install skill:lint # an artifact a catalog offers (see below)
58
+ atril remove command:review
59
+ ```
60
+
61
+ An install is validated first, refuses symlinks, and never overwrites different content.
62
+
63
+ ## Catalogs
64
+
65
+ A catalog lists what you can install without installing it. List catalogs in `atril.json`:
66
+
67
+ ```json
68
+ { "catalogs": ["vendor/kit", "catalogs/team.json"] }
69
+ ```
70
+
71
+ Each entry is either a folder in a store's layout, or a JSON file of git sources, each pinned to a commit with a note on what it is for:
72
+
73
+ ```json
74
+ {
75
+ "sources": [
76
+ { "source": "owner/repo/skills", "commit": "<40-character sha>", "note": "process skills" }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ Sources use the OpenSkills form `owner/repo[/path]`, read as GitHub repositories.
82
+
83
+ ```sh
84
+ atril catalog sync # fetch every pinned source into the cache, once
85
+ atril search lint # catalog hits come back with installed: false
86
+ atril install skill:lint # install one, from its pinned commit
87
+ ```
88
+
89
+ The fetch runs no hooks, lands symlinks as plain files and verifies the commit. Skills installed by OpenSkills into `.claude/skills` or `.agent/skills` move into the store with `atril import --from openskills`.
90
+
91
+ ## Persistent pieces through rulesync
92
+
93
+ CLIs only accept slash commands they know. atril registers **one** in each CLI, the bridge `/atril <name or task>`, plus a router rule that tells the agent to ask atril before a task. Everything else stays on demand, unless you choose to expose it permanently:
94
+
95
+ ```sh
96
+ atril expose command:review # recorded in atril.json
97
+ atril sync # write the bridge, the rule and the exposed artifacts
98
+ atril sync --check # for CI: exit 1 while .rulesync/ is out of date
99
+ ```
100
+
101
+ `atril sync` writes into [rulesync](https://github.com/dyoshikawa/rulesync)'s sources (`.rulesync/`), owning only what it wrote through rulesync's ownership ledger: a hand edit, a file of yours or another writer's entry stops it with nothing changed. With `"rulesync": { "generate": true }` in `atril.json` it then runs `rulesync generate`, which spreads the pieces to every CLI.
102
+
103
+ ## MCP server
104
+
105
+ ```sh
106
+ atril mcp --root .atril
107
+ ```
108
+
109
+ Serves `search`, `resolve`, `install` and `remove` as MCP tools over stdio. The store is fixed when the server starts; a tool call never chooses it.
110
+
111
+ ## API
112
+
113
+ ```js
114
+ import { search, resolve, install, sync, catalogSync } from "@karajan-family/atril";
115
+
116
+ const { block, available } = resolve({ task: "fix the flaky login test", role: "coder", root: ".atril" });
117
+ ```
118
+
119
+ The entry exports `search`, `resolve`, `resolveBlock`, `install`, `remove`, `expose`, `unexpose`, `persistentPieces`, `sync`, `generate`, `catalogSync`, `installFromCatalog`, `importSkills`, `MARKER` and `VERSION`.
120
+
121
+ ## License
122
+
123
+ MIT
package/bin/atril.js CHANGED
@@ -7,14 +7,69 @@ import { graph } from "../src/graph.js";
7
7
  import { scan } from "../src/scan.js";
8
8
  import { SEARCH_MODE, search } from "../src/search.js";
9
9
  import { install, remove } from "../src/store.js";
10
+ import { generate } from "../src/generate.js";
10
11
  import { readConfig } from "../src/config.js";
12
+ import { expose, unexpose } from "../src/bridge.js";
13
+ import { sync } from "../src/sync.js";
14
+ import { blockPaths } from "../src/resolve.js";
15
+ import { serve } from "../src/mcp.js";
16
+ import { catalogSync } from "../src/fetch.js";
17
+ import { installFromCatalog, isCatalogRef } from "../src/catalog-install.js";
18
+ import { importSkills } from "../src/import.js";
11
19
 
12
20
  const USAGE = "Usage: atril --version\n atril scan [--root <dir>] [--json]\n"
13
21
  + " atril enrich [--root <dir>] [--check]\n atril graph [<kind:name>] [--root <dir>] [--json]\n"
14
22
  + " atril search <query> [--kind <kind>] [--root <dir>] [--json]\n"
15
23
  + " atril route <task> [--role <role>] [--root <dir>] [--json]\n"
16
- + " atril install <path> [--kind <kind>] [--root <dir>]\n atril remove <kind:name> [--root <dir>]\n"
17
- + " atril config [--root <dir>] [--json]";
24
+ + " atril install <path | kind:name> [--kind <kind>] [--root <dir>]\n atril remove <kind:name> [--root <dir>]\n"
25
+ + " atril config [--root <dir>] [--json]\n"
26
+ + " atril expose <kind:name>... [--root <dir>]\n atril unexpose <kind:name>... [--root <dir>]\n"
27
+ + " atril sync [--check] [--root <dir>]\n atril mcp [--root <dir>]\n"
28
+ + " atril catalog sync [--root <dir>]\n atril import --from openskills [--root <dir>]";
29
+
30
+ function runImport(args) {
31
+ const { values } = parseArgs({ args, options: { from: { type: "string" }, root: { type: "string" } } });
32
+ if (!values.from) throw new Error(`import needs --from\n${USAGE}`);
33
+ const done = importSkills(values.from, { root: values.root });
34
+ if (done.length === 0) console.log(`nothing to import from ${values.from}`);
35
+ for (const { kind, name, from, path } of done) console.log(`imported\t${kind}:${name}\t${from} -> ${path}`);
36
+ }
37
+
38
+ function runCatalog(args) {
39
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { root: { type: "string" } } });
40
+ if (positionals.join(" ") !== "sync") throw new Error(USAGE);
41
+ const done = catalogSync({ root: values.root });
42
+ if (done.length === 0) console.log("no git sources to fetch");
43
+ for (const { status, source, commit, catalog } of done) console.log(`${status}\t${source}@${commit.slice(0, 12)}\t${catalog}`);
44
+ }
45
+
46
+ async function runMcp(args) {
47
+ const { values } = parseArgs({ args, options: { root: { type: "string" } } });
48
+ await serve({ root: values.root });
49
+ }
50
+
51
+ function runSync(args) {
52
+ const { values } = parseArgs({ args, options: { root: { type: "string" }, check: { type: "boolean" } } });
53
+ const changed = sync({ root: values.root, check: values.check }).filter(({ action }) => action !== "unchanged");
54
+ for (const { action, path } of changed) console.log(`${action}\t${path}`);
55
+ if (values.check) {
56
+ if (changed.length > 0) {
57
+ console.error(`atril: ${changed.length} piece(s) out of date: run atril sync`);
58
+ process.exitCode = 1;
59
+ }
60
+ return;
61
+ }
62
+ if (changed.length === 0) console.log("up to date");
63
+ if (generate({ root: values.root })) console.log("rulesync generate: done");
64
+ else console.error("atril: run rulesync generate to reach your agents, or declare rulesync.generate: true in atril.json");
65
+ }
66
+
67
+ function runExpose(command, args) {
68
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { root: { type: "string" } } });
69
+ if (positionals.length === 0) throw new Error(`${command} takes at least one kind:name\n${USAGE}`);
70
+ const done = (command === "expose" ? expose : unexpose)(positionals, { root: values.root });
71
+ for (const ref of done) console.log(`${command}d\t${ref}`);
72
+ }
18
73
 
19
74
  function runConfig(args) {
20
75
  const { values } = parseArgs({ args, options: { root: { type: "string" }, json: { type: "boolean" } } });
@@ -33,6 +88,11 @@ function runStore(command, args) {
33
88
  const { values, positionals } = parseArgs({ args, options: { root: { type: "string" }, kind: { type: "string" } }, allowPositionals: true });
34
89
  if (positionals.length !== 1) throw new Error(`${command} takes exactly one argument\n${USAGE}`);
35
90
  const [ref] = positionals;
91
+ if (command === "install" && isCatalogRef(ref)) {
92
+ const { status, kind, name, path, catalog } = installFromCatalog(ref, { root: values.root });
93
+ console.log(`${status}\t${kind}:${name}\t${path}\tfrom ${printable(catalog)}`);
94
+ return;
95
+ }
36
96
  if (command === "install") {
37
97
  const { status, kind, name, path } = install(ref, { root: values.root, kind: values.kind });
38
98
  console.log(`${status}\t${kind}:${name}\t${path}`);
@@ -92,11 +152,6 @@ function runSearch(args) {
92
152
  for (const hit of hits) console.log(`${hit.score}\t${hit.kind}\t${printable(hit.name)}\t${hit.source}\t${printable(hit.description)}`);
93
153
  }
94
154
 
95
- // The files to read, not their content: a subagent carries its own block.
96
- const pathsOf = (block) => block.map(({ name, kind, path, as, block: own }) => ({
97
- name, kind, path, as,
98
- ...(own && { block: pathsOf(own) }),
99
- }));
100
155
  const printBlock = (block, indent = "") => {
101
156
  for (const item of block) {
102
157
  console.log(`${indent}${item.as}\t${printable(item.path)}`);
@@ -109,10 +164,18 @@ function runRoute(args) {
109
164
  args, allowPositionals: true,
110
165
  options: { role: { type: "string" }, root: { type: "string" }, json: { type: "boolean" } },
111
166
  });
112
- const { block, suggested, left, reason } = resolve({ task: positionals.join(" "), role: values.role, root: values.root });
113
- if (values.json) console.log(JSON.stringify({ block: pathsOf(block), suggested, left, ...(reason && { reason }) }, null, 2));
114
- else if (reason) console.log(reason);
115
- else printBlock(block);
167
+ const { block, suggested, left, reason, available } = resolve({ task: positionals.join(" "), role: values.role, root: values.root });
168
+ if (values.json) {
169
+ console.log(JSON.stringify({ block: blockPaths(block), suggested, left, available, ...(reason && { reason }) }, null, 2));
170
+ return;
171
+ }
172
+ if (reason) {
173
+ console.log(reason);
174
+ } else {
175
+ printBlock(block);
176
+ }
177
+ // Either way, what fits from the catalogs follows.
178
+ for (const { kind, name, catalog } of available) console.log(`available\t${kind}:${name}\tfrom ${printable(catalog)}`);
116
179
  }
117
180
 
118
181
  const [command, ...rest] = process.argv.slice(2);
@@ -125,6 +188,11 @@ try {
125
188
  else if (command === "route") runRoute(rest);
126
189
  else if (command === "install" || command === "remove") runStore(command, rest);
127
190
  else if (command === "config") runConfig(rest);
191
+ else if (command === "expose" || command === "unexpose") runExpose(command, rest);
192
+ else if (command === "sync") runSync(rest);
193
+ else if (command === "mcp") await runMcp(rest);
194
+ else if (command === "catalog") runCatalog(rest);
195
+ else if (command === "import") runImport(rest);
128
196
  else throw new Error(command ? `unknown command "${command}"\n${USAGE}` : USAGE);
129
197
  } catch (err) {
130
198
  console.error(`atril: ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karajan-family/atril",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Skills, commands and subagents for every AI CLI, on demand: search, install and resolve the Markdown each task needs without loading it into the initial context.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -17,6 +17,9 @@
17
17
  "dependencies": {
18
18
  "js-yaml": "^4.2.0"
19
19
  },
20
+ "devDependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.29.0"
22
+ },
20
23
  "engines": {
21
24
  "node": ">=22.12.0"
22
25
  },
package/src/atomic.js ADDED
@@ -0,0 +1,17 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { renameSync, rmSync, writeFileSync } from "node:fs";
3
+
4
+ /** Write through a temp file and a rename, so a reader never sees half a file. */
5
+ export function writeAtomic(path, text) {
6
+ // A random name created exclusively: "wx" fails on anything already there, a symlink included.
7
+ const temp = `${path}.${randomUUID()}.tmp`;
8
+ let created = false;
9
+ try {
10
+ writeFileSync(temp, text, { flag: "wx" });
11
+ created = true;
12
+ renameSync(temp, path);
13
+ } catch (err) {
14
+ if (created) rmSync(temp, { force: true });
15
+ throw err;
16
+ }
17
+ }
package/src/bridge.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * atril's persistent pieces (ADR 0021, decision 10; plan step 8): the bridge command, the router
3
+ * rule and the artifacts exposed in atril.json, each a path under `.rulesync/` plus its content.
4
+ * Writing them is the sync's job (step 9).
5
+ */
6
+ import { readdirSync, readFileSync } from "node:fs";
7
+ import { dirname, join, relative, sep } from "node:path";
8
+ import { readConfig, writeConfig } from "./config.js";
9
+ import { FRONTMATTER, KINDS } from "./model.js";
10
+ import { keyOf, scan } from "./scan.js";
11
+
12
+ export const MARKER = "<!-- atril:managed -->";
13
+ const RULESYNC_DIR = { skill: "skills", command: "commands", agent: "subagents" };
14
+
15
+ /** The marker goes right after the frontmatter, in the file's own newline style. */
16
+ const withMarker = (text) => {
17
+ const match = FRONTMATTER.exec(text);
18
+ if (!match) throw new Error("a persistent piece needs frontmatter");
19
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
20
+ const head = text.slice(0, text.length - match[2].length);
21
+ return `${head}${head.endsWith("\n") ? "" : eol}${MARKER}${eol}${match[2]}`;
22
+ };
23
+
24
+ const bridgeCommand = () => ({
25
+ path: "commands/atril.md",
26
+ content: withMarker(`---
27
+ description: "Run any atril skill, command or agent: /atril <name or task>"
28
+ targets: ["*"]
29
+ ---
30
+ Run \`atril route --json "$ARGUMENTS"\` and follow the plan it prints:
31
+ read each block's file in order and apply it to the task; run an entry
32
+ of kind "subagent" as a subagent. If the route is empty, say so.
33
+ `),
34
+ });
35
+
36
+ const routerRule = () => ({
37
+ path: "rules/atril-router.md",
38
+ content: withMarker(`---
39
+ root: false
40
+ targets: ["*"]
41
+ description: "Ask atril which skills, commands and agents fit a task"
42
+ globs: ["**/*"]
43
+ ---
44
+ Before starting a task, run \`atril route "<the task>"\` and use what it
45
+ returns. Run \`/atril <name>\` to load one artifact on demand.
46
+ `),
47
+ });
48
+
49
+ /** A skill brings its whole folder; the marker goes only in SKILL.md. */
50
+ function exposedPiece(artifact) {
51
+ const base = RULESYNC_DIR[artifact.kind];
52
+ const own = withMarker(readFileSync(artifact.path, "utf8"));
53
+ if (artifact.kind !== "skill") return [{ path: `${base}/${artifact.name}.md`, content: own }];
54
+ const folder = dirname(artifact.path);
55
+ const files = readdirSync(folder, { recursive: true, withFileTypes: true }).filter((dirent) => dirent.isFile());
56
+ return files.map((dirent) => join(dirent.parentPath, dirent.name)).toSorted().map((file) => {
57
+ const rel = relative(folder, file).split(sep).join("/");
58
+ const text = readFileSync(file, "utf8");
59
+ return { path: `${base}/${artifact.name}/${rel}`, content: rel === "SKILL.md" ? withMarker(text) : text };
60
+ });
61
+ }
62
+
63
+ /** @param {{ projectDir?: string, root?: string, userRoot?: string, env?: object }} [opts] */
64
+ export function persistentPieces(opts) {
65
+ const { expose } = readConfig(opts);
66
+ const byKey = new Map(scan(opts).artifacts.map((artifact) => [keyOf(artifact), artifact]));
67
+ const pieces = [bridgeCommand(), routerRule()];
68
+ for (const kind of KINDS) {
69
+ for (const name of expose[kind]) {
70
+ const key = `${kind}:${name}`;
71
+ if (key === RESERVED) throw new Error(`atril.json exposes ${key}, reserved for the bridge: atril unexpose ${key}`);
72
+ if (!byKey.has(key)) throw new Error(`atril.json exposes ${key}, which is not in the store: atril unexpose ${key}`);
73
+ pieces.push(...exposedPiece(byKey.get(key)));
74
+ }
75
+ }
76
+ return pieces;
77
+ }
78
+
79
+ const RESERVED = "command:atril";
80
+ const parseRef = (ref) => {
81
+ const [kind, name, extra] = String(ref).split(":");
82
+ if (extra !== undefined || !KINDS.includes(kind) || !name) {
83
+ throw new Error(`"${ref}" is not kind:name (kind one of ${KINDS.join(", ")})`);
84
+ }
85
+ return { kind, name, key: `${kind}:${name}` };
86
+ };
87
+
88
+ /** Record the refs in atril.json's expose, each checked against the store. Returns the refs. */
89
+ export function expose(refs, opts) {
90
+ const parsed = refs.map(parseRef);
91
+ const held = new Set(scan(opts).artifacts.map(keyOf));
92
+ for (const { key } of parsed) {
93
+ if (key === RESERVED) throw new Error(`${key} is reserved for the bridge`);
94
+ if (!held.has(key)) throw new Error(`${key} is not in the store`);
95
+ }
96
+ const config = readConfig(opts);
97
+ for (const { kind, name } of parsed) if (!config.expose[kind].includes(name)) config.expose[kind].push(name);
98
+ writeConfig(config, opts);
99
+ return parsed.map(({ key }) => key);
100
+ }
101
+
102
+ /** Take the refs out of expose, unchecked on purpose: a removed artifact must stay retractable. */
103
+ export function unexpose(refs, opts) {
104
+ const parsed = refs.map(parseRef);
105
+ const config = readConfig(opts);
106
+ for (const { kind, name } of parsed) config.expose[kind] = config.expose[kind].filter((item) => item !== name);
107
+ writeConfig(config, opts);
108
+ return parsed.map(({ key }) => key);
109
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Install from a catalog (ADR 0021, decision 12; plan step 11c): `atril install kind:name`
3
+ * takes an available artifact into the store, through the same validated install as a local
4
+ * path. A fetched source's artifact comes from its pinned commit in the cache. An OpenSkills
5
+ * skill declares no kind; the store needs one, so the installed SKILL.md gets `kind: skill`.
6
+ */
7
+ import { cpSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { basename, dirname, join } from "node:path";
10
+ import { availableArtifacts } from "./catalog.js";
11
+ import { keyOf, scan } from "./scan.js";
12
+ import { install } from "./store.js";
13
+
14
+ const BOM = String.fromCodePoint(0xfeff);
15
+
16
+ /** A catalog reference, as opposed to a local path. */
17
+ export const isCatalogRef = (ref) => /^(skill|command|agent):[a-z0-9][a-z0-9._-]*$/.test(ref);
18
+
19
+ /**
20
+ * @param {string} ref kind:name of an available artifact
21
+ * @param {{ projectDir?: string, root?: string, userRoot?: string, env?: object }} [opts]
22
+ * @returns {{ kind: string, name: string, path: string, status: string, catalog: string }}
23
+ */
24
+ export function installFromCatalog(ref, opts = {}) {
25
+ const artifact = availableArtifacts(opts).find((candidate) => keyOf(candidate) === ref);
26
+ if (!artifact) {
27
+ const held = scan(opts).artifacts.some((candidate) => keyOf(candidate) === ref);
28
+ throw new Error(held ? `${ref} is already installed` : `${ref} is not in any catalog (atril catalog sync fetches the git sources)`);
29
+ }
30
+ if (artifact.kind === "skill") return { ...installSkillFolder(dirname(artifact.path), opts, artifact.kindDefaulted), catalog: artifact.catalog };
31
+ return { ...install(artifact.path, { ...opts, kind: artifact.kind }), catalog: artifact.catalog };
32
+ }
33
+
34
+ /**
35
+ * A skill folder that is a symlink, or holds one, is refused without following any link: run
36
+ * before reading, copying or rewriting anything in it, a link could reach outside the folder.
37
+ */
38
+ export function refuseLinks(source) {
39
+ if (lstatSync(source).isSymbolicLink()) throw new Error(`${source}: an artifact may not be a symlink`);
40
+ const link = readdirSync(source, { recursive: true, withFileTypes: true }).find((entry) => entry.isSymbolicLink());
41
+ if (link) throw new Error(`${join(link.parentPath, link.name)}: a skill may not hold symlinks`);
42
+ }
43
+
44
+ /**
45
+ * Install a skill folder. One whose SKILL.md declares no kind (OpenSkills' format) is copied to
46
+ * a temporary folder whose SKILL.md gets `kind: skill` first, because the store needs a kind.
47
+ * @param {string} source the skill folder
48
+ * @param {object} opts install()'s options
49
+ * @param {boolean} [kindDefaulted] the SKILL.md declares no kind
50
+ */
51
+ export function installSkillFolder(source, opts, kindDefaulted = false) {
52
+ refuseLinks(source);
53
+ if (!kindDefaulted) return install(source, { ...opts, kind: "skill" });
54
+ const stage = mkdtempSync(join(tmpdir(), "atril-install-"));
55
+ try {
56
+ const folder = join(stage, basename(source));
57
+ cpSync(source, folder, { recursive: true });
58
+ const file = join(folder, "SKILL.md");
59
+ const text = readFileSync(file, "utf8");
60
+ const bom = text.startsWith(BOM) ? BOM : "";
61
+ writeFileSync(file, bom + text.slice(bom.length).replace(/^(---(\r?\n))/, "$1kind: skill$2"));
62
+ return install(folder, { ...opts, kind: "skill" });
63
+ } finally {
64
+ rmSync(stage, { recursive: true, force: true });
65
+ }
66
+ }
package/src/catalog.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Catalogs (ADR 0021, decision 12; plan step 11a). Each entry of atril.json's `catalogs`,
3
+ * relative to the project, is either a folder in a store's layout (a bundled catalog, such as
4
+ * a consumer's own or the recommended set) or a JSON file of git sources:
5
+ * `{ "sources": [{ "source": "owner/repo[/path]", "commit": "<40 hex>", "note": "..." }] }`,
6
+ * each pinned to a commit and saying what it is for. What a catalog offers that the stores do
7
+ * not hold is "available": search shows it, installing it stays an explicit step.
8
+ */
9
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { basename, join, resolve as resolvePath } from "node:path";
11
+ import { readConfig } from "./config.js";
12
+ import { isMapping, parseArtifact } from "./model.js";
13
+ import { keyOf, resolveRoot, scan, scanStore } from "./scan.js";
14
+
15
+ const SEGMENT = /^[A-Za-z0-9_.-]+$/;
16
+ const COMMIT = /^[0-9a-f]{40}$/;
17
+ const isSource = (source) => typeof source === "string"
18
+ && source.split("/").length >= 2
19
+ && source.split("/").every((part) => SEGMENT.test(part) && part !== "." && part !== "..");
20
+
21
+ function readSources(ref, file) {
22
+ const fail = (why, cause) => { throw new Error(`catalog "${ref}": ${why}`, { cause }); };
23
+ let raw;
24
+ try {
25
+ raw = JSON.parse(readFileSync(file, "utf8"));
26
+ } catch (err) {
27
+ fail(`invalid JSON (${err.message})`, err);
28
+ }
29
+ if (!isMapping(raw) || !Array.isArray(raw.sources)) fail('must be { "sources": [...] }');
30
+ return raw.sources.map((entry, index) => {
31
+ if (!isMapping(entry)) fail(`sources[${index}] must be an object`);
32
+ const { source, commit, note } = entry;
33
+ if (!isSource(source)) fail(`source ${JSON.stringify(source)} is not owner/repo[/path]`);
34
+ if (typeof commit !== "string" || !COMMIT.test(commit)) fail(`source "${source}": commit must be 40 hex characters (a pinned commit)`);
35
+ if (typeof note !== "string" || !note.trim()) fail(`source "${source}": a note on what it is for is required`);
36
+ return { source, commit, note };
37
+ });
38
+ }
39
+
40
+ /** Where fetched sources live: under the store, never scanned as artifacts (a dot folder). */
41
+ export const cacheRoot = ({ projectDir = process.cwd(), root, env = process.env } = {}) =>
42
+ join(resolvePath(projectDir, resolveRoot({ root, env })), ".cache", "catalogs");
43
+
44
+ /** A source's pinned commit in the cache; owner and repo are validated segments. */
45
+ export function sourceDir(opts, { source, commit }) {
46
+ const [owner, repo] = source.split("/");
47
+ return join(cacheRoot(opts), owner, repo, commit);
48
+ }
49
+
50
+ /** @param {{ projectDir?: string, root?: string, env?: object }} [opts] */
51
+ export function catalogs(opts = {}) {
52
+ const projectDir = opts.projectDir ?? process.cwd();
53
+ return readConfig(opts).catalogs.map((ref) => {
54
+ const path = resolvePath(projectDir, ref);
55
+ if (!existsSync(path)) throw new Error(`catalog "${ref}": ${path} not found`);
56
+ if (statSync(path).isDirectory()) return { ref, type: "store", dir: path };
57
+ return { ref, type: "sources", sources: readSources(ref, path) };
58
+ });
59
+ }
60
+
61
+ /**
62
+ * A fetched source (step 11b): a store's layout, one OpenSkills skill folder, or a folder of
63
+ * them. A SKILL.md that declares no kind is a skill, and must be named after its folder.
64
+ */
65
+ function scanFetched(dir, source) {
66
+ if (["skills", "commands", "agents"].some((kind) => existsSync(join(dir, kind)))) return scanStore(dir, source);
67
+ const skillAt = (folder) => {
68
+ const artifact = parseArtifact(join(folder, "SKILL.md"), { defaultKind: "skill" });
69
+ if (artifact.kind !== "skill" || artifact.name !== basename(folder)) {
70
+ throw new Error(`${folder}: a catalog skill must be kind skill and named after its folder, not "${artifact.name}"`);
71
+ }
72
+ return { ...artifact, source };
73
+ };
74
+ if (existsSync(join(dir, "SKILL.md"))) return [skillAt(dir)];
75
+ return readdirSync(dir, { withFileTypes: true })
76
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && existsSync(join(dir, entry.name, "SKILL.md")))
77
+ .map((entry) => entry.name)
78
+ .toSorted()
79
+ .map((name) => skillAt(join(dir, name)));
80
+ }
81
+
82
+ /** The folders a catalog offers: a store catalog itself, or each source already fetched. */
83
+ function offered(opts, catalog) {
84
+ if (catalog.type === "store") return [{ dir: catalog.dir, scanOf: scanStore }];
85
+ return catalog.sources
86
+ .map((entry) => join(sourceDir(opts, entry), ...entry.source.split("/").slice(2)))
87
+ .filter((dir) => existsSync(dir))
88
+ .map((dir) => ({ dir, scanOf: scanFetched }));
89
+ }
90
+
91
+ /**
92
+ * What the catalogs offer that the stores do not hold, each with its `catalog`. The first
93
+ * catalog listed wins a name two of them share; a source not fetched yet offers nothing.
94
+ * @param {Parameters<typeof scan>[0]} [opts]
95
+ * @param {object[]} [held] the stores' artifacts, when the caller already scanned them
96
+ */
97
+ export function availableArtifacts(opts = {}, held = scan(opts).artifacts) {
98
+ const seen = new Set(held.map(keyOf));
99
+ const found = [];
100
+ for (const catalog of catalogs(opts)) {
101
+ for (const { dir, scanOf } of offered(opts, catalog)) {
102
+ for (const artifact of scanOf(dir, `catalog:${catalog.ref}`)) {
103
+ if (seen.has(keyOf(artifact))) continue;
104
+ seen.add(keyOf(artifact));
105
+ found.push({ ...artifact, catalog: catalog.ref });
106
+ }
107
+ }
108
+ }
109
+ return found;
110
+ }
package/src/config.js CHANGED
@@ -4,9 +4,9 @@
4
4
  * and the rulesync opt-in. An absent file is DEFAULT_CONFIG; a bad field
5
5
  * fails loudly, naming it. Writes are atomic and deterministic.
6
6
  */
7
- import { randomUUID } from "node:crypto";
8
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
9
8
  import { dirname, join, resolve } from "node:path";
9
+ import { writeAtomic } from "./atomic.js";
10
10
  import { isMapping, KINDS, SLUG, sortKeys } from "./model.js";
11
11
  import { resolveRoot } from "./scan.js";
12
12
 
@@ -85,16 +85,6 @@ export function writeConfig(config, opts) {
85
85
  const path = configPath(opts);
86
86
  const text = `${JSON.stringify(normalize(config, path), sortKeys, 2)}\n`;
87
87
  mkdirSync(dirname(path), { recursive: true });
88
- // A random name created exclusively: "wx" fails on anything already there, a symlink included.
89
- const temp = `${path}.${randomUUID()}.tmp`;
90
- let created = false;
91
- try {
92
- writeFileSync(temp, text, { flag: "wx" });
93
- created = true;
94
- renameSync(temp, path);
95
- } catch (err) {
96
- if (created) rmSync(temp, { force: true });
97
- throw err;
98
- }
88
+ writeAtomic(path, text);
99
89
  return path;
100
90
  }
package/src/fetch.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * atril catalog sync (ADR 0021, decision 12; plan step 11b): every git source of the catalogs
3
+ * is fetched into its cache folder (catalog.js `sourceDir`), at exactly its pinned commit, and
4
+ * nothing is installed. A cached commit is immutable, so it is fetched once. The fetch happens
5
+ * in a temporary folder renamed into place only once verified: a failure leaves nothing
6
+ * behind. Symlinks are checked out as plain files and no hook runs.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { catalogs, sourceDir } from "./catalog.js";
13
+
14
+ const defaultGit = (args, cwd) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
15
+ // Never a hook, never a symlink, never a detached-HEAD lecture.
16
+ const SAFE = ["-c", "core.hooksPath=/dev/null", "-c", "core.symlinks=false", "-c", "advice.detachedHead=false"];
17
+
18
+ function fetchInto(target, url, commit, git) {
19
+ const temp = `${target}.${randomUUID()}.tmp`;
20
+ mkdirSync(temp, { recursive: true });
21
+ try {
22
+ git([...SAFE, "init", "--quiet"], temp);
23
+ git([...SAFE, "fetch", "--quiet", "--depth", "1", url, commit], temp);
24
+ git([...SAFE, "checkout", "--quiet", "FETCH_HEAD"], temp);
25
+ const head = git([...SAFE, "rev-parse", "HEAD"], temp).trim();
26
+ if (head !== commit) throw new Error(`got ${head}, not the pinned commit`);
27
+ rmSync(join(temp, ".git"), { recursive: true, force: true });
28
+ renameSync(temp, target);
29
+ } catch (err) {
30
+ rmSync(temp, { recursive: true, force: true });
31
+ throw err;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {{ projectDir?: string, root?: string, env?: object }} [opts]
37
+ * @param {{ gitBase?: string, git?: (args: string[], cwd: string) => string }} [how] where
38
+ * owner/repo lives (GitHub, as OpenSkills reads it) and how git runs; for tests
39
+ * @returns {{ catalog: string, source: string, commit: string, status: "fetched" | "cached" }[]}
40
+ */
41
+ export function catalogSync(opts = {}, { gitBase = "https://github.com", git = defaultGit } = {}) {
42
+ const done = [];
43
+ for (const catalog of catalogs(opts).filter(({ type }) => type === "sources")) {
44
+ for (const { source, commit } of catalog.sources) {
45
+ const target = sourceDir(opts, { source, commit });
46
+ const status = existsSync(target) ? "cached" : "fetched";
47
+ if (status === "fetched") {
48
+ const [owner, repo] = source.split("/");
49
+ mkdirSync(dirname(target), { recursive: true });
50
+ try {
51
+ fetchInto(target, `${gitBase}/${owner}/${repo}.git`, commit, git);
52
+ } catch (err) {
53
+ throw new Error(`catalog "${catalog.ref}": ${source}@${commit.slice(0, 12)} could not be fetched (${err.message})`, { cause: err });
54
+ }
55
+ }
56
+ done.push({ catalog: catalog.ref, source, commit, status });
57
+ }
58
+ }
59
+ return done;
60
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * rulesync generate (ADR 0021, decision 11; ADR 0022): run only when atril.json declares
3
+ * `rulesync.generate: true`, with the canonical arguments (whole generation), and never a package
4
+ * fetched on the fly: the project's own binary, or `npx --no-install`.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { readConfig } from "./config.js";
10
+
11
+ const ARGS = ["generate", "--targets", "*", "--features", "*"];
12
+ const defaultRun = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
13
+
14
+ /**
15
+ * @param {{ projectDir?: string, root?: string, env?: object }} [opts]
16
+ * @param {(cmd: string, args: string[], cwd: string) => unknown} [run]
17
+ * @returns {boolean} whether rulesync generate ran
18
+ */
19
+ export function generate(opts = {}, run = defaultRun) {
20
+ if (readConfig(opts).rulesync.generate !== true) return false;
21
+ const projectDir = opts.projectDir ?? process.cwd();
22
+ const local = join(projectDir, "node_modules", ".bin", "rulesync");
23
+ const [cmd, args] = existsSync(local) ? [local, ARGS] : ["npx", ["--no-install", "rulesync", ...ARGS]];
24
+ try {
25
+ run(cmd, args, projectDir);
26
+ } catch (err) {
27
+ throw new Error(`rulesync generate failed (${err.message}); the pieces are in .rulesync/, run it yourself`, { cause: err });
28
+ }
29
+ return true;
30
+ }
package/src/import.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * atril import --from openskills (ADR 0021, decision 12; plan step 11d): the skills OpenSkills
3
+ * installed into folders the CLIs load on their own (`.claude/skills`, `.agent/skills`) move
4
+ * into atril's store, where they are used on demand. Each one leaves its folder only once it
5
+ * is installed (or the store already holds it, same content); a refusal keeps the original.
6
+ */
7
+ import { existsSync, readdirSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { installSkillFolder, refuseLinks } from "./catalog-install.js";
10
+ import { parseArtifact } from "./model.js";
11
+
12
+ const SOURCES = { openskills: [join(".claude", "skills"), join(".agent", "skills")] };
13
+
14
+ /**
15
+ * @param {string} from where the skills come from: "openskills"
16
+ * @param {{ projectDir?: string, root?: string, env?: object }} [opts]
17
+ * @returns {{ kind: string, name: string, path: string, status: string, from: string }[]}
18
+ */
19
+ export function importSkills(from, opts = {}) {
20
+ if (!Object.hasOwn(SOURCES, from)) throw new Error(`atril imports from ${Object.keys(SOURCES).join(", ")}, not "${from}"`);
21
+ const projectDir = opts.projectDir ?? process.cwd();
22
+ const done = [];
23
+ for (const rel of SOURCES[from]) {
24
+ const base = join(projectDir, rel);
25
+ if (!existsSync(base)) continue;
26
+ // A symlinked entry stays in the list so refuseLinks() refuses it out loud, before any read.
27
+ const entries = readdirSync(base, { withFileTypes: true })
28
+ .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith("."))
29
+ .map((entry) => entry.name)
30
+ .toSorted();
31
+ for (const name of entries) {
32
+ const folder = join(base, name);
33
+ refuseLinks(folder);
34
+ if (!existsSync(join(folder, "SKILL.md"))) continue;
35
+ const artifact = parseArtifact(join(folder, "SKILL.md"), { defaultKind: "skill" });
36
+ if (artifact.kind !== "skill" || artifact.name !== name) {
37
+ throw new Error(`${folder}: an imported skill must be kind skill and named after its folder, not "${artifact.name}"`);
38
+ }
39
+ const result = installSkillFolder(folder, opts, Boolean(artifact.kindDefaulted));
40
+ rmSync(folder, { recursive: true });
41
+ done.push({ ...result, from: join(rel, name) });
42
+ }
43
+ }
44
+ return done;
45
+ }
package/src/index.js CHANGED
@@ -8,3 +8,10 @@ import { readFileSync } from "node:fs";
8
8
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
9
9
  export { search } from "./search.js";
10
10
  export { resolve, resolveBlock } from "./resolve.js";
11
+ export { MARKER, expose, persistentPieces, unexpose } from "./bridge.js";
12
+ export { sync } from "./sync.js";
13
+ export { generate } from "./generate.js";
14
+ export { install, remove } from "./store.js";
15
+ export { catalogSync } from "./fetch.js";
16
+ export { installFromCatalog } from "./catalog-install.js";
17
+ export { importSkills } from "./import.js";
package/src/ledger.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The ownership ledger of `.rulesync/` (ADR 0022, decision 3), atril's own implementation of the
3
+ * contract it shares with kj and the user (ADR 0021, decision 11: no code shared with the core).
4
+ * `.rulesync/.owners.json` is { version: 1, entries: { "<posix path under .rulesync>[#block]":
5
+ * { owner, hash: "sha256:<hex>" } } }, changed only under `.rulesync/.owners.lock` and written
6
+ * atomically, in the same layout kj writes it, so the two writers never churn the file.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { writeAtomic } from "./atomic.js";
12
+ import { isMapping } from "./model.js";
13
+
14
+ export const LEDGER_FILE = join(".rulesync", ".owners.json");
15
+ export const LOCK_FILE = join(".rulesync", ".owners.lock");
16
+ export const OWNER = "atril";
17
+ export const USER_OWNER = "user";
18
+ const VERSION = 1;
19
+ const HASH = /^sha256:[0-9a-f]{64}$/;
20
+
21
+ export const hashContent = (content) => `sha256:${createHash("sha256").update(content).digest("hex")}`;
22
+
23
+ function validate(file, ledger) {
24
+ if (ledger?.version !== VERSION) throw new Error(`${file}: version ${ledger?.version} is not supported (expected ${VERSION})`);
25
+ if (!isMapping(ledger.entries)) throw new Error(`${file}: entries must be an object of key to { owner, hash }`);
26
+ for (const [key, entry] of Object.entries(ledger.entries)) {
27
+ if (typeof entry?.owner !== "string" || !entry.owner || !HASH.test(entry.hash)) {
28
+ throw new Error(`${file}: entry "${key}" needs a non-empty owner and a sha256:<hex> hash`);
29
+ }
30
+ }
31
+ }
32
+
33
+ /** The ledger, or an empty one when absent; a broken, malformed or unknown one fails loudly. */
34
+ export function readLedger(projectDir) {
35
+ const file = join(projectDir, LEDGER_FILE);
36
+ if (!existsSync(file)) return { version: VERSION, entries: {} };
37
+ let ledger;
38
+ try {
39
+ ledger = JSON.parse(readFileSync(file, "utf8"));
40
+ } catch (err) {
41
+ throw new Error(`${file}: not valid JSON (${err.message})`, { cause: err });
42
+ }
43
+ validate(file, ledger);
44
+ return ledger;
45
+ }
46
+
47
+ function takeLock(lock) {
48
+ try {
49
+ writeFileSync(lock, `${OWNER} ${process.pid}`, { flag: "wx" });
50
+ } catch (err) {
51
+ if (err.code === "ENOENT") throw new Error(`${lock}: .rulesync/ is missing (run rulesync init)`, { cause: err });
52
+ if (err.code !== "EEXIST") throw err;
53
+ let holder = "unknown";
54
+ try { holder = readFileSync(lock, "utf8").trim() || holder; } catch { /* released meanwhile */ }
55
+ throw new Error(`${lock}: held by ${holder}, another writer is updating the ledger; retry when it ends `
56
+ + `(if no writer is running, delete ${lock})`, { cause: err });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read, change and write the ledger under its lock. A change that throws, or leaves an entry
62
+ * malformed, writes nothing; a lock left by a crashed writer is never stolen.
63
+ * @param {string} projectDir
64
+ * @param {(ledger: object) => void} change - mutates the ledger in place
65
+ */
66
+ export function withLedger(projectDir, change) {
67
+ const lock = join(projectDir, LOCK_FILE);
68
+ takeLock(lock);
69
+ try {
70
+ const ledger = readLedger(projectDir);
71
+ change(ledger);
72
+ validate(join(projectDir, LEDGER_FILE), ledger);
73
+ writeAtomic(join(projectDir, LEDGER_FILE), JSON.stringify(ledger, null, 2));
74
+ return ledger;
75
+ } finally {
76
+ rmSync(lock, { force: true });
77
+ }
78
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * atril mcp (ADR 0021, plan step 14): the store's search, resolve, install and remove as MCP tools
3
+ * over stdio. The store is fixed when the server starts (`atril mcp --root <dir>`): a tool call
4
+ * never chooses it. A failed call comes back as an error result; the server stays up. No MCP SDK:
5
+ * four methods over JSON lines do not justify its dependency tree in a package that installs offline.
6
+ */
7
+ import { createInterface } from "node:readline";
8
+ import { VERSION } from "./index.js";
9
+ import { KINDS } from "./model.js";
10
+ import { blockPaths, resolve } from "./resolve.js";
11
+ import { SEARCH_MODE, search } from "./search.js";
12
+ import { install, remove } from "./store.js";
13
+
14
+ const kind = { type: "string", enum: KINDS };
15
+ const text = (description) => ({ type: "string", description });
16
+ const input = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
17
+
18
+ export const TOOLS = [
19
+ { name: "search", description: "Search atril's store for skills, commands and agents",
20
+ inputSchema: input({ query: text("what to look for"), kind }, ["query"]) },
21
+ { name: "resolve", description: "The artifacts that fit a task, as the files to read in order",
22
+ inputSchema: input({ task: text("the task at hand"), role: text("the role that will use them") }, ["task"]) },
23
+ { name: "install", description: "Install a local skill folder, command or agent file into the store",
24
+ inputSchema: input({ path: text("the skill folder or .md file"), kind }, ["path"]) },
25
+ { name: "remove", description: "Remove an artifact from the store",
26
+ inputSchema: input({ kind, name: text("the artifact's name") }, ["kind", "name"]) },
27
+ ];
28
+
29
+ const string = (args, field, required = false) => {
30
+ if (args[field] === undefined && !required) return undefined;
31
+ if (typeof args[field] !== "string" || !args[field].trim()) throw new Error(`${field} must be a non-empty string`);
32
+ return args[field];
33
+ };
34
+
35
+ const HANDLERS = {
36
+ search: (args, opts) => ({ mode: SEARCH_MODE, hits: search(string(args, "query", true), { ...opts, kind: string(args, "kind") }) }),
37
+ resolve: (args, opts) => {
38
+ const result = resolve({ ...opts, task: string(args, "task", true), role: string(args, "role") });
39
+ return { ...result, block: blockPaths(result.block) };
40
+ },
41
+ install: (args, opts) => install(string(args, "path", true), { ...opts, kind: string(args, "kind") }),
42
+ remove: (args, opts) => remove(string(args, "name", true), { ...opts, kind: string(args, "kind", true) }),
43
+ };
44
+
45
+ /** One tool call: its result as JSON text, or its error as an error result. */
46
+ export function callTool(name, args, opts) {
47
+ try {
48
+ if (!Object.hasOwn(HANDLERS, name)) throw new Error(`unknown tool "${name}"`);
49
+ return { content: [{ type: "text", text: JSON.stringify(HANDLERS[name](args ?? {}, opts), null, 2) }] };
50
+ } catch (err) {
51
+ return { isError: true, content: [{ type: "text", text: `atril: ${err.message}` }] };
52
+ }
53
+ }
54
+
55
+ // The protocol versions this server speaks; a client asking for another gets the latest.
56
+ const PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
57
+
58
+ function answer({ method, params }, opts) {
59
+ if (method === "initialize") {
60
+ const protocolVersion = PROTOCOLS.includes(params?.protocolVersion) ? params.protocolVersion : PROTOCOLS[0];
61
+ return { protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "atril", version: VERSION } };
62
+ }
63
+ if (method === "ping") return {};
64
+ if (method === "tools/list") return { tools: TOOLS };
65
+ if (method === "tools/call") return callTool(params?.name, params?.arguments, opts);
66
+ throw Object.assign(new Error(`method "${method}" not found`), { code: -32601 });
67
+ }
68
+
69
+ /**
70
+ * MCP's stdio transport: one JSON-RPC message per line. A notification (no id) needs no answer.
71
+ * @param {{ root?: string, projectDir?: string }} opts the store, fixed for the server's life
72
+ * @returns {Promise<void>} settles when the input closes
73
+ */
74
+ export function serve(opts, { input = process.stdin, output = process.stdout } = {}) {
75
+ const send = (message) => output.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
76
+ const lines = createInterface({ input, crlfDelay: Infinity });
77
+ lines.on("line", (line) => {
78
+ if (!line.trim()) return;
79
+ let message;
80
+ try {
81
+ message = JSON.parse(line);
82
+ } catch {
83
+ send({ id: null, error: { code: -32700, message: "parse error" } });
84
+ return;
85
+ }
86
+ if (message?.id === undefined) return;
87
+ try {
88
+ send({ id: message.id, result: answer(message, opts) });
89
+ } catch (err) {
90
+ send({ id: message.id, error: { code: err.code ?? -32603, message: err.message } });
91
+ }
92
+ });
93
+ return new Promise((done) => lines.on("close", done));
94
+ }
package/src/model.js CHANGED
@@ -54,8 +54,10 @@ function validate(path, front) {
54
54
  * Parse and validate one artifact file. Invalid frontmatter fails loudly,
55
55
  * naming the file and the field.
56
56
  * @param {string} path
57
+ * @param {{ defaultKind?: string }} [opts] the kind of a file that declares none: catalog
58
+ * sources in OpenSkills' format write a SKILL.md without `kind` (step 11b)
57
59
  */
58
- export function parseArtifact(path) {
60
+ export function parseArtifact(path, { defaultKind } = {}) {
59
61
  const match = FRONTMATTER.exec(readFileSync(path, "utf8").replace(/^\uFEFF/, ""));
60
62
  if (!match) throw new Error(`${path}: no frontmatter (--- … --- lines at the top)`);
61
63
  let front;
@@ -64,6 +66,8 @@ export function parseArtifact(path) {
64
66
  } catch (err) {
65
67
  throw new Error(`${path}: invalid frontmatter (${err.message})`, { cause: err });
66
68
  }
69
+ const kindDefaulted = defaultKind !== undefined && isMapping(front) && front.kind === undefined;
70
+ if (kindDefaulted) front = { ...front, kind: defaultKind };
67
71
  validate(path, front);
68
72
  const body = match[2];
69
73
  const { atril = {}, ...author } = front;
@@ -77,5 +81,7 @@ export function parseArtifact(path) {
77
81
  atril,
78
82
  body,
79
83
  hash,
84
+ // The file itself declares no kind (an OpenSkills skill): installing it must write one.
85
+ ...(kindDefaulted && { kindDefaulted }),
80
86
  };
81
87
  }
package/src/resolve.js CHANGED
@@ -146,10 +146,20 @@ export function resolve({ task, role, budget = {}, projectDir, root, userRoot, e
146
146
  if (typeof task !== "string" || !task.trim()) throw new Error("resolve needs a non-empty task");
147
147
  if (role !== undefined && (typeof role !== "string" || !role.trim())) throw new Error("role must be a non-empty string");
148
148
  const byKey = new Map(scan(scanOpts).artifacts.map((a) => [keyOf(a), a]));
149
- const best = search(task, scanOpts).find((hit) => servesRole(byKey.get(keyOf(hit)), role));
149
+ const hits = search(task, scanOpts);
150
+ const best = hits.find((hit) => hit.installed && servesRole(byKey.get(keyOf(hit)), role));
151
+ // Step 11a: what fits from the catalogs is proposed, never loaded; installing stays explicit.
152
+ const available = hits.filter((hit) => !hit.installed).slice(0, 3)
153
+ .map(({ name, kind, description, catalog }) => ({ name, kind, description, catalog }));
150
154
  if (!best) {
151
155
  const reason = `no artifact matches ${JSON.stringify(task)}${role === undefined ? "" : ` for role ${role}`}`;
152
- return { block: [], suggested: [], left: [], reason };
156
+ return { block: [], suggested: [], left: [], reason, available };
153
157
  }
154
- return resolveBlock(keyOf(best), scanOpts, budget);
158
+ return { ...resolveBlock(keyOf(best), scanOpts, budget), available };
155
159
  }
160
+
161
+ /** A block as the files to read, not their content: a subagent carries its own block. */
162
+ export const blockPaths = (block) => block.map(({ name, kind, path, as, block: own }) => ({
163
+ name, kind, path, as,
164
+ ...(own && { block: blockPaths(own) }),
165
+ }));
package/src/search.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * A query term scores by the field it appears in, the name weighing most
5
5
  * and the body least; ties break on kind, then name.
6
6
  */
7
+ import { availableArtifacts } from "./catalog.js";
7
8
  import { KINDS } from "./model.js";
8
9
  import { keyOf, scan } from "./scan.js";
9
10
 
@@ -30,20 +31,25 @@ const scoreOf = (fields, terms) =>
30
31
  Object.entries(WEIGHTS).reduce((sum, [field, weight]) => sum + weight * terms.filter((term) => fields[field].has(term)).length, 0);
31
32
 
32
33
  /**
34
+ * The stores' artifacts, and (step 11a) what the catalogs offer, marked `installed: false`
35
+ * with its `catalog`.
33
36
  * @param {string} query
34
- * @param {{ kind?: string } & Parameters<typeof scan>[0]} [opts] the rest goes to scan()
35
- * @returns {{ name: string, kind: string, description: string, source: string, installed: boolean, score: number }[]}
37
+ * @param {{ kind?: string, catalogs?: boolean } & Parameters<typeof scan>[0]} [opts] the rest goes to scan()
38
+ * @returns {{ name: string, kind: string, description: string, source: string, installed: boolean, score: number, catalog?: string }[]}
36
39
  */
37
- export function search(query, { kind, ...scanOpts } = {}) {
40
+ export function search(query, { kind, catalogs: withCatalogs = true, ...scanOpts } = {}) {
38
41
  const terms = typeof query === "string" ? [...tokenize(query)] : [];
39
42
  if (terms.length === 0) throw new Error("search needs a non-empty query");
40
43
  if (kind !== undefined && !KINDS.includes(kind)) throw new Error(`kind ${JSON.stringify(kind)} is not one of ${KINDS.join(", ")}`);
41
- return scan(scanOpts)
42
- .artifacts.filter((artifact) => kind === undefined || artifact.kind === kind)
44
+ const { artifacts } = scan(scanOpts);
45
+ const pool = withCatalogs ? [...artifacts, ...availableArtifacts(scanOpts, artifacts)] : artifacts;
46
+ return pool
47
+ .filter((artifact) => kind === undefined || artifact.kind === kind)
43
48
  .map((artifact) => ({ artifact, score: scoreOf(fieldsOf(artifact), terms) }))
44
49
  .filter(({ score }) => score > 0)
45
50
  .toSorted((a, b) => b.score - a.score || (keyOf(a.artifact) < keyOf(b.artifact) ? -1 : 1))
46
- .map(({ artifact: { name, kind: artifactKind, description, source }, score }) => ({
47
- name, kind: artifactKind, description, source, installed: true, score,
51
+ .map(({ artifact: { name, kind: artifactKind, description, source, catalog }, score }) => ({
52
+ name, kind: artifactKind, description, source, installed: catalog === undefined, score,
53
+ ...(catalog !== undefined && { catalog }),
48
54
  }));
49
55
  }
package/src/sync.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * atril sync (ADR 0021, decision 11; plan step 9b): write the persistent pieces into `.rulesync/`
3
+ * through the ownership ledger of ADR 0022. Every piece is judged before anything is written, so
4
+ * a hand edit, a user's file or another writer's entry stops the whole sync with nothing changed.
5
+ * A piece no longer wanted is removed when it is still as atril wrote it.
6
+ */
7
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { writeAtomic } from "./atomic.js";
10
+ import { MARKER, persistentPieces } from "./bridge.js";
11
+ import { hashContent, OWNER, readLedger, withLedger } from "./ledger.js";
12
+
13
+ const RULESYNC = ".rulesync";
14
+ // A key atril writes: a whole file under one of rulesync's kind folders, never `..` nor a block.
15
+ const OWN_KEY = /^(commands|rules|skills|subagents)\/[^#]+$/;
16
+ const ownKey = (key) => OWN_KEY.test(key) && !key.split("/").some((part) => part === "" || part === "." || part === "..");
17
+
18
+ /** What happens to one key; throws on anything atril must not touch. */
19
+ function judge(key, entry, current, wanted) {
20
+ const file = join(RULESYNC, key);
21
+ if (!ownKey(key)) throw new Error(`"${key}" is not a path atril writes; fix .rulesync/.owners.json`);
22
+ if (entry && entry.owner !== OWNER) throw new Error(`${file}: belongs to ${entry.owner}; atril will not touch it`);
23
+ // Already gone and no longer wanted: only its entry is left to drop.
24
+ if (entry && current === null && wanted === undefined) return "removed";
25
+ if (entry && (current === null || hashContent(current) !== entry.hash)) {
26
+ throw new Error(`${file}: edited by hand (or deleted) since atril wrote it; atril will not overwrite it. `
27
+ + "Restore it as it was, or move your change into atril's store");
28
+ }
29
+ if (!entry && current !== null && !current.includes(MARKER)) {
30
+ throw new Error(`${file}: not written by atril; atril will not write over it`);
31
+ }
32
+ if (wanted === undefined) return "removed";
33
+ if (!entry) return current === null ? "written" : "claimed";
34
+ return current === wanted ? "unchanged" : "written";
35
+ }
36
+
37
+ const readOrNull = (path) => (existsSync(path) ? readFileSync(path, "utf8") : null);
38
+
39
+ // A symlink anywhere below .rulesync/ would carry a write outside it: refused before any write.
40
+ function refuseSymlinks(projectDir, key) {
41
+ let at = join(projectDir, RULESYNC);
42
+ for (const part of key.split("/")) {
43
+ at = join(at, part);
44
+ if (lstatSync(at, { throwIfNoEntry: false })?.isSymbolicLink()) {
45
+ throw new Error(`${join(RULESYNC, key)}: ${at} is a symlink; atril writes only inside .rulesync/`);
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Every step of a sync, judged and nothing written. */
51
+ export function plan(projectDir, ledger, wanted) {
52
+ const stale = Object.entries(ledger.entries).filter(([key, { owner }]) => owner === OWNER && !wanted.has(key));
53
+ return [...wanted.keys(), ...stale.map(([key]) => key)].map((key) => {
54
+ if (ownKey(key)) refuseSymlinks(projectDir, key);
55
+ const current = readOrNull(join(projectDir, RULESYNC, key));
56
+ return { path: key, action: judge(key, ledger.entries[key], current, wanted.get(key)) };
57
+ });
58
+ }
59
+
60
+ // After removing a skill's file, its folders go too while empty, up to the kind folder.
61
+ function pruneEmpty(start, stop) {
62
+ for (let at = start; at !== stop && existsSync(at) && readdirSync(at).length === 0; at = dirname(at)) rmdirSync(at);
63
+ }
64
+
65
+ /**
66
+ * @param {{ projectDir?: string, root?: string, userRoot?: string, env?: object, check?: boolean }} [opts]
67
+ * @returns {{ path: string, action: "written"|"claimed"|"removed"|"unchanged" }[]}
68
+ */
69
+ export function sync(opts = {}) {
70
+ const projectDir = opts.projectDir ?? process.cwd();
71
+ if (!existsSync(join(projectDir, RULESYNC))) {
72
+ throw new Error(`${projectDir}: no .rulesync/; atril installs nothing persistent without rulesync (run rulesync init)`);
73
+ }
74
+ const wanted = new Map(persistentPieces({ ...opts, projectDir }).map(({ path, content }) => [path, content]));
75
+ // --check (step 9c): the same judgment, read-only and without the lock.
76
+ if (opts.check) return plan(projectDir, readLedger(projectDir), wanted);
77
+ let steps;
78
+ withLedger(projectDir, (ledger) => {
79
+ steps = plan(projectDir, ledger, wanted);
80
+ for (const { path, action } of steps) {
81
+ const target = join(projectDir, RULESYNC, path);
82
+ if (action === "removed") {
83
+ rmSync(target, { force: true });
84
+ pruneEmpty(dirname(target), join(projectDir, RULESYNC, path.split("/")[0]));
85
+ delete ledger.entries[path];
86
+ } else if (action !== "unchanged") {
87
+ mkdirSync(dirname(target), { recursive: true });
88
+ writeAtomic(target, wanted.get(path));
89
+ ledger.entries[path] = { owner: OWNER, hash: hashContent(wanted.get(path)) };
90
+ }
91
+ }
92
+ });
93
+ return steps;
94
+ }