nylorun 0.18.3-beta → 0.20.0-beta

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,177 @@
1
+ /**
2
+ * Agent folders (track T6, D79) on the command line, offline: `nylorun check [dir…]` compiles each
3
+ * agent folder with the compiler the Runtime runs (`@nylorun/core/folder`) and prints every
4
+ * diagnostic by file, line and column (`--json` for CI), and `--fix` makes the edits the fixable
5
+ * ones carry (track T7); `nylorun init <path>` (`init.ts`) writes a new folder. Neither reads a
6
+ * Runtime. `nylorun apply` saves a folder (`apply.ts`).
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import { readdir, readFile, writeFile } from "node:fs/promises";
10
+ import { basename, join, relative, resolve } from "node:path";
11
+ import { AGENT_FOLDER_FILE, FolderError, checkFolder, diagnosticLine, isAgentFolder, packFolder, } from "@nylorun/agents";
12
+ import { applyFolderFixes } from "@nylorun/core/folder";
13
+ import { CliError } from "../errors.js";
14
+ import { parseStackFlags } from "../stack/commands.js";
15
+ import { usageError } from "./runtime.js";
16
+ export { initCommand, initUsage } from "./init.js";
17
+ export const checkUsage = `nylorun check [<dir>...] [--fix] [--json]
18
+
19
+ Compiles each agent folder (default .) offline, with the compiler the Runtime runs, and prints
20
+ every problem by file, line and column. A folder holds AGENT.md; a folder without one checks each
21
+ subfolder that has one. Exits 1 when a folder does not compile. Reads no Runtime.
22
+
23
+ A problem one edit fixes says (fixable with --fix): an unknown key with one did-you-mean, CRLF
24
+ line ends, and secrets in a SKILL.md, which go in AGENT.md's skills block.
25
+
26
+ --fix make those edits in the folder's files, print each, then
27
+ check again; nothing is written without it, and a file
28
+ that changed since it was checked is left as it is
29
+ --json print { "folders": [{ path, ok, manifest?, diagnostics }] }
30
+ for CI: whether each folder compiles, the manifest it
31
+ compiles to, and every diagnostic, each fixable one with
32
+ its fix; with --fix, also what was fixed and what was not`;
33
+ /** The agent folders under `dir`: `dir` when it holds an `AGENT.md`, else each subfolder that does. */
34
+ export async function agentFolders(dir) {
35
+ const root = resolve(dir);
36
+ if (await isAgentFolder(root))
37
+ return [root];
38
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
39
+ const found = [];
40
+ for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => (a.name < b.name ? -1 : 1)))
41
+ if (await isAgentFolder(join(root, entry.name)))
42
+ found.push(join(root, entry.name));
43
+ return found;
44
+ }
45
+ /** Packs the folder, or a `CliError` naming why it cannot be. */
46
+ export async function packed(folder) {
47
+ return packFolder(folder).catch((error) => {
48
+ if (error instanceof FolderError)
49
+ throw new CliError(error.message, 1);
50
+ throw error;
51
+ });
52
+ }
53
+ /** A folder's diagnostics as lines, each path relative to `cwd`; `fixable` marks those with a fix. */
54
+ export function diagnosticLines(folder, cwd, diagnostics, fixable = false) {
55
+ const prefix = relative(cwd, folder);
56
+ return diagnostics.map((item) => diagnosticLine(item.file && prefix ? { ...item, file: `${prefix}/${item.file}` } : item) + (fixable && item.fix ? " (fixable with --fix)" : ""));
57
+ }
58
+ /** How many times `check --fix` fixes and checks one folder, at most. */
59
+ const FIX_ROUNDS = 5;
60
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
61
+ const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((byte, index) => byte === b[index]);
62
+ /**
63
+ * Makes the fix-its `diagnostics` carry in the folder's files (`applyFolderFixes`). A file is
64
+ * edited only when its bytes on disk are the ones checked, and written only when they still are:
65
+ * when one has changed by then, nothing is written.
66
+ */
67
+ async function fixFolder(pack, diagnostics, cwd) {
68
+ const fixes = diagnostics.flatMap((item) => (item.fix ? [item.fix] : []));
69
+ if (fixes.length === 0)
70
+ return { fixed: [], unfixed: [] };
71
+ const filesOf = (fix) => (fix.kind === "move_skill_secrets" ? [fix.file, AGENT_FOLDER_FILE] : [fix.file]);
72
+ const read = new Map();
73
+ const texts = new Map();
74
+ const unreadable = new Map();
75
+ for (const path of new Set(fixes.flatMap(filesOf))) {
76
+ if (path === "runtime.json" && pack.extended) {
77
+ unreadable.set(path, "runtime.json extends another file, and was checked merged with it: fix it by hand");
78
+ continue;
79
+ }
80
+ const entry = pack.entries.find((item) => item.path === path);
81
+ const bytes = entry
82
+ ? await readFile(join(pack.dir, ...path.split("/"))).then((data) => new Uint8Array(data), () => undefined)
83
+ : undefined;
84
+ if (!entry || !bytes || sha256Hex(bytes) !== entry.sha256) {
85
+ unreadable.set(path, "the file changed since it was checked: check again");
86
+ continue;
87
+ }
88
+ read.set(path, bytes);
89
+ // A byte-order mark stays: the fixes keep it.
90
+ texts.set(path, new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes));
91
+ }
92
+ const unfixed = [];
93
+ const applicable = fixes.filter((fix) => {
94
+ const blocked = filesOf(fix).find((path) => unreadable.has(path));
95
+ if (blocked)
96
+ unfixed.push({ file: fix.file, reason: unreadable.get(blocked) });
97
+ return !blocked;
98
+ });
99
+ const result = applyFolderFixes(texts, applicable);
100
+ unfixed.push(...result.skipped.map(({ fix, reason }) => ({ file: fix.file, reason })));
101
+ const encoder = new TextEncoder();
102
+ const writes = [...result.files].map(([path, text]) => ({ target: join(pack.dir, ...path.split("/")), before: read.get(path), after: encoder.encode(text) }));
103
+ for (const { target, before } of writes)
104
+ if (!sameBytes(new Uint8Array(await readFile(target)), before))
105
+ throw new CliError(`${relative(cwd, target)} changed while it was being fixed: nothing was written. Check again.`, 1);
106
+ for (const { target, after } of writes)
107
+ await writeFile(target, after);
108
+ return { fixed: result.changes, unfixed };
109
+ }
110
+ /** `nylorun check [dir…] [--fix] [--json]` */
111
+ export async function checkCommand(deps, args) {
112
+ const flags = parseStackFlags(args, { booleans: ["--json", "--fix"] }, checkUsage);
113
+ const json = flags.booleans.has("--json");
114
+ const fix = flags.booleans.has("--fix");
115
+ const cwd = deps.cwd ?? process.cwd();
116
+ const folders = [];
117
+ for (const dir of flags.rest.length > 0 ? flags.rest : ["."]) {
118
+ const found = await agentFolders(resolve(cwd, dir));
119
+ if (found.length === 0)
120
+ throw usageError(`${dir} holds no ${AGENT_FOLDER_FILE}, and no subfolder does.`);
121
+ folders.push(...found);
122
+ }
123
+ let failed = false;
124
+ const report = [];
125
+ for (const folder of folders) {
126
+ let pack = await packed(folder);
127
+ let compiled = await checkFolder(pack);
128
+ const fixed = { fixed: [], unfixed: [] };
129
+ // Checked again after each round, so what is printed is the folder as it is now. The compiler
130
+ // stops at a stage with errors, so a fix can show the next stage's: a few rounds take them.
131
+ for (let round = 0; fix && round < FIX_ROUNDS; round++) {
132
+ const made = await fixFolder(pack, compiled.diagnostics, cwd);
133
+ fixed.fixed.push(...made.fixed);
134
+ fixed.unfixed = made.unfixed;
135
+ if (made.fixed.length === 0)
136
+ break;
137
+ pack = await packed(folder);
138
+ compiled = await checkFolder(pack);
139
+ }
140
+ if (!compiled.manifest)
141
+ failed = true;
142
+ if (json) {
143
+ report.push({
144
+ path: relative(cwd, folder) || ".",
145
+ ok: compiled.manifest !== undefined,
146
+ ...(compiled.manifest ? { manifest: compiled.manifest } : {}),
147
+ diagnostics: compiled.diagnostics,
148
+ ...(pack.skipped.length > 0 ? { skipped: pack.skipped } : {}),
149
+ ...(fix ? { fixed: fixed.fixed, unfixed: fixed.unfixed } : {}),
150
+ });
151
+ continue;
152
+ }
153
+ const shown = (file) => relative(cwd, join(folder, file));
154
+ for (const item of fixed.fixed)
155
+ deps.out(`fixed ${shown(item.file)}: ${item.change}`);
156
+ for (const item of fixed.unfixed)
157
+ deps.err(`not fixed ${shown(item.file)}: ${item.reason}`);
158
+ for (const skipped of pack.skipped)
159
+ deps.err(`${relative(cwd, folder) || "."}: skipped ${skipped}`);
160
+ for (const line of diagnosticLines(folder, cwd, compiled.diagnostics, !fix))
161
+ if (compiled.manifest)
162
+ deps.out(line);
163
+ else
164
+ deps.err(line);
165
+ const label = relative(cwd, folder) || basename(folder);
166
+ const files = `${pack.entries.length} ${pack.entries.length === 1 ? "file" : "files"}`;
167
+ if (compiled.manifest)
168
+ deps.out(`${label}: ok, agent manifest ${compiled.manifest.name} (${files}, ${compiled.compiler})`);
169
+ else {
170
+ const errors = compiled.diagnostics.filter((item) => item.severity === "error").length;
171
+ deps.err(`${label}: ${errors} ${errors === 1 ? "error" : "errors"}`);
172
+ }
173
+ }
174
+ if (json)
175
+ deps.out(JSON.stringify({ folders: report }, null, 2));
176
+ return failed ? 1 : 0;
177
+ }
@@ -0,0 +1,21 @@
1
+ import { type StackDeps } from "../stack/commands.js";
2
+ export declare const initUsage = "nylorun init <path> [--description <text>] [--example]\n\nWrites <path>/AGENT.md, the smallest agent folder: frontmatter with its name (the path's last\nsegment, so init agents/hello names it hello) and description, and its instructions below.\n\n --example also write tools/get_order.json, mcp.json and\n triggers/new-order.md, with a README.md that explains each\n\nThe first run in a project (the nearest directory with .git, else this one) also writes\n.gitattributes (* text=auto eol=lf), ignores .nylorun/credentials.json in .gitignore, and maps the\nfolder files to their JSON Schemas in .vscode/settings.json, each only when it is missing.";
3
+ /** Where the editor finds each agent folder file's schema (design §3.10). */
4
+ export declare const SCHEMA_BASE = "https://docs.nylorun.com/schemas/agent-folder/v1";
5
+ /** The project `init` sets up: the nearest directory from `cwd` up with `.git`, else `cwd`. */
6
+ export declare function projectRootOf(cwd: string): string;
7
+ /** `json.schemas` entries for the agent folders that sit where `folder` does, under `root`. */
8
+ export declare function schemaMappings(root: string, folder: string): {
9
+ fileMatch: string[];
10
+ url: string;
11
+ }[];
12
+ /**
13
+ * `settings` with `mappings` merged into its `json.schemas`: a mapping whose URL is there already
14
+ * gains the patterns it lacks. Undefined when nothing changes.
15
+ */
16
+ export declare function mergeSchemaSettings(settings: Record<string, unknown>, mappings: readonly {
17
+ fileMatch: string[];
18
+ url: string;
19
+ }[]): Record<string, unknown> | undefined;
20
+ /** `nylorun init <path> [--description <text>] [--example]` */
21
+ export declare function initCommand(deps: StackDeps, args: readonly string[]): Promise<number>;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `nylorun init <path>` (tracks T6 and T7): writes a new agent folder's `AGENT.md`, named after
3
+ * the path's last segment (`init agents/hello` names it `hello`). `--example` adds a tool, an
4
+ * MCP server and a trigger, each explained in the folder's README.md, and the result passes
5
+ * `nylorun check`. It also sets up the project, at the nearest directory with `.git` (else the
6
+ * working directory), once:
7
+ *
8
+ * - `.gitattributes` with `* text=auto eol=lf` when there is none, so a Windows checkout keeps
9
+ * LF line endings;
10
+ * - `.nylorun/credentials.json` in `.gitignore` when nothing ignores it yet;
11
+ * - the editor's schema mapping (design §3.10): `.vscode/settings.json` `json.schemas`, merged into
12
+ * the file, which is left alone when it is not plain JSON.
13
+ *
14
+ * It reads no Runtime.
15
+ */
16
+ import { existsSync } from "node:fs";
17
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
19
+ import { isAgentManifestName } from "@nylorun/core/contracts";
20
+ import { AGENT_FOLDER_FILE, ignoreMatcher } from "@nylorun/agents";
21
+ import { CliError } from "../errors.js";
22
+ import { parseStackFlags } from "../stack/commands.js";
23
+ import { usageError } from "./runtime.js";
24
+ export const initUsage = `nylorun init <path> [--description <text>] [--example]
25
+
26
+ Writes <path>/AGENT.md, the smallest agent folder: frontmatter with its name (the path's last
27
+ segment, so init agents/hello names it hello) and description, and its instructions below.
28
+
29
+ --example also write tools/get_order.json, mcp.json and
30
+ triggers/new-order.md, with a README.md that explains each
31
+
32
+ The first run in a project (the nearest directory with .git, else this one) also writes
33
+ .gitattributes (* text=auto eol=lf), ignores .nylorun/credentials.json in .gitignore, and maps the
34
+ folder files to their JSON Schemas in .vscode/settings.json, each only when it is missing.`;
35
+ /** Where the editor finds each agent folder file's schema (design §3.10). */
36
+ export const SCHEMA_BASE = "https://docs.nylorun.com/schemas/agent-folder/v1";
37
+ /** The project `init` sets up: the nearest directory from `cwd` up with `.git`, else `cwd`. */
38
+ export function projectRootOf(cwd) {
39
+ for (let directory = resolve(cwd);; directory = dirname(directory)) {
40
+ if (existsSync(join(directory, ".git")))
41
+ return directory;
42
+ if (dirname(directory) === directory)
43
+ return resolve(cwd);
44
+ }
45
+ }
46
+ /** `json.schemas` entries for the agent folders that sit where `folder` does, under `root`. */
47
+ export function schemaMappings(root, folder) {
48
+ const parent = relative(root, dirname(folder)).split(sep).join("/");
49
+ // Folders under a parent (agents/) share one pattern; a folder at the root is named alone.
50
+ const base = parent === "" ? relative(root, folder).split(sep).join("/") : `${parent}/**`;
51
+ return [
52
+ { fileMatch: [`${base}/runtime.json`], url: `${SCHEMA_BASE}/runtime.json` },
53
+ { fileMatch: [`${base}/tools/*.json`], url: `${SCHEMA_BASE}/tool.json` },
54
+ { fileMatch: [`${base}/flow.json`], url: `${SCHEMA_BASE}/flow.json` },
55
+ { fileMatch: [`${base}/mcp.json`, `!${base}/plugins/**`], url: `${SCHEMA_BASE}/mcp.json` },
56
+ ];
57
+ }
58
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
59
+ /**
60
+ * `settings` with `mappings` merged into its `json.schemas`: a mapping whose URL is there already
61
+ * gains the patterns it lacks. Undefined when nothing changes.
62
+ */
63
+ export function mergeSchemaSettings(settings, mappings) {
64
+ const existing = Array.isArray(settings["json.schemas"]) ? settings["json.schemas"] : [];
65
+ const schemas = existing.map((item) => (isObject(item) ? { ...item } : item));
66
+ let changed = !Array.isArray(settings["json.schemas"]);
67
+ for (const mapping of mappings) {
68
+ const found = schemas.find((item) => isObject(item) && item.url === mapping.url);
69
+ if (!found) {
70
+ schemas.push({ fileMatch: [...mapping.fileMatch], url: mapping.url });
71
+ changed = true;
72
+ continue;
73
+ }
74
+ const patterns = Array.isArray(found.fileMatch) ? found.fileMatch : [];
75
+ const missing = mapping.fileMatch.filter((pattern) => !patterns.includes(pattern));
76
+ if (missing.length === 0)
77
+ continue;
78
+ found.fileMatch = [...patterns, ...missing];
79
+ changed = true;
80
+ }
81
+ return changed ? { ...settings, "json.schemas": schemas } : undefined;
82
+ }
83
+ /** A file's text, or undefined when there is none. */
84
+ const textOf = (path) => readFile(path, "utf8").catch(() => undefined);
85
+ /** Sets up the project at `root` for the agent folder `folder`; each line says what it wrote. */
86
+ async function setUpProject(root, folder, show) {
87
+ const notes = [];
88
+ const attributes = join(root, ".gitattributes");
89
+ if (!existsSync(attributes)) {
90
+ await writeFile(attributes, "* text=auto eol=lf\n", { flag: "wx" });
91
+ notes.push(`Wrote ${show(attributes)} (* text=auto eol=lf), so a Windows checkout keeps LF line endings.`);
92
+ }
93
+ const ignore = join(root, ".gitignore");
94
+ const ignoreText = (await textOf(ignore)) ?? "";
95
+ if (!ignoreMatcher(ignoreText)(".nylorun/credentials.json")) {
96
+ const separator = ignoreText === "" || ignoreText.endsWith("\n") ? "" : "\n";
97
+ await writeFile(ignore, `${ignoreText}${separator}.nylorun/credentials.json\n`);
98
+ notes.push(`Added .nylorun/credentials.json to ${show(ignore)}.`);
99
+ }
100
+ const settingsPath = join(root, ".vscode", "settings.json");
101
+ const settingsText = await textOf(settingsPath);
102
+ let settings = {};
103
+ if (settingsText !== undefined) {
104
+ try {
105
+ settings = JSON.parse(settingsText);
106
+ }
107
+ catch {
108
+ settings = undefined;
109
+ }
110
+ }
111
+ const mappings = schemaMappings(root, folder);
112
+ const merged = isObject(settings) ? mergeSchemaSettings(settings, mappings) : undefined;
113
+ if (!isObject(settings))
114
+ notes.push(`${show(settingsPath)} is not plain JSON, so it was left alone: add the agent folder schemas to its json.schemas (${SCHEMA_BASE}/<kind>.json).`);
115
+ else if (merged) {
116
+ await mkdir(dirname(settingsPath), { recursive: true });
117
+ await writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`);
118
+ notes.push(`Mapped the agent folder files to their JSON Schemas in ${show(settingsPath)}.`);
119
+ }
120
+ return notes;
121
+ }
122
+ /** The files `--example` adds, by path in the folder. */
123
+ function exampleFiles(name) {
124
+ const tool = {
125
+ description: "Look up one order by its id: its status, items and total.",
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: { orderId: { type: "string", description: "The order's id, such as A-1001." } },
129
+ required: ["orderId"],
130
+ additionalProperties: false,
131
+ },
132
+ http: { url: "https://orders.example.com/lookup", method: "POST", timeoutMs: 15000, credential: "ORDERS_TOKEN" },
133
+ };
134
+ const mcp = {
135
+ mcpServers: {
136
+ orders: {
137
+ type: "streamable-http",
138
+ url: "https://orders.example.com/mcp",
139
+ headers: { Authorization: "Bearer ${ORDERS_MCP_TOKEN}" },
140
+ },
141
+ },
142
+ };
143
+ const trigger = `---
144
+ # A trigger starts a turn with no person in the chat. This one is a webhook: the Runtime gives it
145
+ # a URL and a signing secret (nylorun agents triggers new-secret ${name} new-order), and each signed
146
+ # POST runs the prompt below.
147
+ # On a schedule instead: schedule: "0 9 * * 1-5" with timezone: Europe/London, and no webhook.
148
+ # Once, at a set time: at: 2026-11-01T09:00:00+05:30, and no webhook.
149
+ webhook: standard-webhooks
150
+ # Each run opens a new session. A session id here sends every run to that one session.
151
+ session: new
152
+ ---
153
+ A new order arrived. Look it up with get_order and summarize it in two sentences.
154
+ `;
155
+ const readme = `# ${name}
156
+
157
+ An agent folder (\`nylorun init --example\`). \`nylorun check\` compiles it offline, and
158
+ \`nylorun apply --agent\` saves it and makes the Agent \`${name}\`. Every file but this one is part
159
+ of the agent; the example ones are placeholders to replace or delete.
160
+
161
+ - \`AGENT.md\`: the name and description (frontmatter), then the instructions.
162
+ - \`tools/get_order.json\`: an HTTP tool. The file name is the tool's name; the model reads its
163
+ \`description\` and \`inputSchema\`. \`http.credential\` names a secret slot, \`ORDERS_TOKEN\`, sent as
164
+ a bearer token: set it with \`nylorun agents secrets put ${name} ORDERS_TOKEN\` (piped in).
165
+ - \`mcp.json\`: MCP servers, in Agent Plugins syntax. \`\${ORDERS_MCP_TOKEN}\` in a header is a secret
166
+ slot too, never a value: never write a literal token here.
167
+ - \`triggers/new-order.md\`: a trigger. The frontmatter says when it fires (\`webhook\`, \`schedule\` or
168
+ \`at\`) and which session it runs in; the body is the prompt. Its comments show the other kinds.
169
+
170
+ Until a required secret is set, the Agent lists it under \`attention\` (\`nylorun agents get ${name}\`).
171
+ `;
172
+ return {
173
+ "tools/get_order.json": `${JSON.stringify(tool, null, 2)}\n`,
174
+ "mcp.json": `${JSON.stringify(mcp, null, 2)}\n`,
175
+ "triggers/new-order.md": trigger,
176
+ "README.md": readme,
177
+ };
178
+ }
179
+ /** `nylorun init <path> [--description <text>] [--example]` */
180
+ export async function initCommand(deps, args) {
181
+ const flags = parseStackFlags(args, { values: ["--description"], booleans: ["--example"] }, initUsage);
182
+ const [path, ...rest] = flags.rest;
183
+ if (path === undefined || rest.length > 0)
184
+ throw usageError(`Usage: ${initUsage.split("\n")[0]}`);
185
+ const cwd = deps.cwd ?? process.cwd();
186
+ const dir = resolve(cwd, path);
187
+ const name = basename(dir);
188
+ if (!isAgentManifestName(name))
189
+ throw usageError(`${name} is not an agent's name: 1-64 letters, digits, '.', '_' and '-', starting with a letter or digit.`);
190
+ const show = (file) => relative(cwd, file) || ".";
191
+ const file = join(dir, AGENT_FOLDER_FILE);
192
+ const example = flags.booleans.has("--example");
193
+ const files = {
194
+ [AGENT_FOLDER_FILE]: `---\nname: ${name}\ndescription: ${JSON.stringify(flags.values.get("--description") ?? `Describe what ${name} does in one sentence.`)}\n---\nYou are ${name}. Write the agent's instructions here, in Markdown.\n${example ? "\nLook an order up with get_order before you answer a question about it.\n" : ""}`,
195
+ ...(example ? exampleFiles(name) : {}),
196
+ };
197
+ for (const relativePath of Object.keys(files))
198
+ if (existsSync(join(dir, relativePath)))
199
+ throw new CliError(`${show(join(dir, relativePath))} exists already.`, 1);
200
+ for (const [relativePath, text] of Object.entries(files)) {
201
+ await mkdir(dirname(join(dir, relativePath)), { recursive: true });
202
+ await writeFile(join(dir, relativePath), text, { flag: "wx" });
203
+ }
204
+ deps.out(example ? `Wrote ${show(dir)}/: ${Object.keys(files).join(", ")}.` : `Wrote ${show(file)}.`);
205
+ for (const note of await setUpProject(projectRootOf(cwd), dir, show))
206
+ deps.out(note);
207
+ const at = show(dir);
208
+ deps.out(`Next: nylorun check ${at}, then nylorun apply ${at} --agent to save it and make the Agent ${name}, then nylorun chat ${name}.`);
209
+ return 0;
210
+ }
@@ -13,6 +13,9 @@
13
13
  *
14
14
  * It writes nothing over an existing file, reads nothing from a Runtime, and prints the gaps
15
15
  * `nylorun apply` would refuse, to fix in the files first.
16
+ *
17
+ * `nylorun migrate folder <dir>` (`convert.ts`, track T7) writes the agent folder of a three-file
18
+ * folder.
16
19
  */
17
20
  import { existsSync } from "node:fs";
18
21
  import { mkdir, writeFile } from "node:fs/promises";
@@ -22,6 +25,7 @@ import { AgentManifestSchema, RuntimeManifestFileSchema, WorkflowManifestSchema,
22
25
  import { harnessSectionOf, sectionGaps } from "@nylorun/core/define";
23
26
  import { CliError } from "../errors.js";
24
27
  import { parseStackFlags } from "../stack/commands.js";
28
+ import { migrateFolderCommand, migrateFolderUsage } from "./convert.js";
25
29
  import { readJson, usageError } from "./runtime.js";
26
30
  export const migrateUsage = `nylorun migrate v8 <agent.json> [--runtime <environment-manifest.json>] [--name <name>] [--out <dir>]
27
31
 
@@ -65,11 +69,13 @@ function hasSkills(loop) {
65
69
  export async function migrateCommand(deps, args) {
66
70
  const [version, ...rest] = args;
67
71
  if (version === "--help" || version === "-h") {
68
- deps.out(migrateUsage);
72
+ deps.out(`${migrateUsage}\n\n${migrateFolderUsage}`);
69
73
  return 0;
70
74
  }
75
+ if (version === "folder")
76
+ return migrateFolderCommand(deps, rest);
71
77
  if (version !== "v8")
72
- throw usageError(version === undefined ? migrateUsage : `nylorun migrate knows v8 only.\n${migrateUsage}`);
78
+ throw usageError(version === undefined ? migrateUsage : `nylorun migrate knows v8 and folder only.\n${migrateUsage}\n\n${migrateFolderUsage}`);
73
79
  const flags = parseStackFlags(rest, { values: ["--runtime", "--name", "--out"] }, migrateUsage);
74
80
  if (flags.rest.length !== 1)
75
81
  throw usageError(`Usage: ${migrateUsage.split("\n")[0]}`);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The text diff `nylorun apply` prints under a plan's change to the instructions (track T7): the
3
+ * plan carries the instructions before and after (`from`, `to`), and the lines that differ print
4
+ * as `-` and `+`, without the lines both keep. Pure.
5
+ */
6
+ import type { AgentManifestPlan } from "@nylorun/agents";
7
+ /**
8
+ * The lines that differ between `before` and `after`, in order: `- line` for a removed one and
9
+ * `+ line` for an added one, with `…` between changes that are apart. A longest common
10
+ * subsequence of lines, so a moved block shows as removed and added.
11
+ */
12
+ export declare function lineDiff(before: string, after: string): string[];
13
+ /** The diff lines to print under one of a plan's changes: the instructions' text, else none. */
14
+ export declare function changeDiffLines(change: AgentManifestPlan["changes"][number], indent?: string): string[];
@@ -0,0 +1,86 @@
1
+ /** The most diff lines one change prints. */
2
+ const MAX_LINES = 40;
3
+ /** Instructions as text: a string, or each block of an array, a blank line between. */
4
+ function textOf(value) {
5
+ if (value === undefined)
6
+ return "";
7
+ if (typeof value === "string")
8
+ return value;
9
+ if (Array.isArray(value) && value.every((item) => typeof item === "string"))
10
+ return value.join("\n\n");
11
+ return undefined;
12
+ }
13
+ /**
14
+ * The lines that differ between `before` and `after`, in order: `- line` for a removed one and
15
+ * `+ line` for an added one, with `…` between changes that are apart. A longest common
16
+ * subsequence of lines, so a moved block shows as removed and added.
17
+ */
18
+ export function lineDiff(before, after) {
19
+ const a = before === "" ? [] : before.split("\n");
20
+ const b = after === "" ? [] : after.split("\n");
21
+ // Lines both start and end with stay out of the table.
22
+ let start = 0;
23
+ while (start < a.length && start < b.length && a[start] === b[start])
24
+ start += 1;
25
+ let endA = a.length;
26
+ let endB = b.length;
27
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
28
+ endA -= 1;
29
+ endB -= 1;
30
+ }
31
+ const x = a.slice(start, endA);
32
+ const y = b.slice(start, endB);
33
+ const ops = [];
34
+ if (x.length * y.length > 4_000_000) {
35
+ ops.push(...x.map((line) => ({ op: "-", line })), ...y.map((line) => ({ op: "+", line })));
36
+ }
37
+ else {
38
+ // lengths[i][j]: the longest common subsequence of x[i..] and y[j..].
39
+ const lengths = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1));
40
+ for (let i = x.length - 1; i >= 0; i -= 1)
41
+ for (let j = y.length - 1; j >= 0; j -= 1)
42
+ lengths[i][j] = x[i] === y[j] ? lengths[i + 1][j + 1] + 1 : Math.max(lengths[i + 1][j], lengths[i][j + 1]);
43
+ let i = 0;
44
+ let j = 0;
45
+ while (i < x.length || j < y.length) {
46
+ if (i < x.length && j < y.length && x[i] === y[j]) {
47
+ ops.push({ op: "=", line: x[i] });
48
+ i += 1;
49
+ j += 1;
50
+ }
51
+ else if (i < x.length && (j === y.length || lengths[i + 1][j] >= lengths[i][j + 1])) {
52
+ ops.push({ op: "-", line: x[i] });
53
+ i += 1;
54
+ }
55
+ else {
56
+ ops.push({ op: "+", line: y[j] });
57
+ j += 1;
58
+ }
59
+ }
60
+ }
61
+ const lines = [];
62
+ let gap = false;
63
+ for (const { op, line } of ops) {
64
+ if (op === "=") {
65
+ gap = lines.length > 0;
66
+ continue;
67
+ }
68
+ if (gap)
69
+ lines.push("…");
70
+ gap = false;
71
+ lines.push(`${op} ${line}`.trimEnd());
72
+ }
73
+ return lines;
74
+ }
75
+ /** The diff lines to print under one of a plan's changes: the instructions' text, else none. */
76
+ export function changeDiffLines(change, indent = " ") {
77
+ if (change.section !== "harness" || change.path !== "instructions")
78
+ return [];
79
+ const before = textOf(change.from);
80
+ const after = textOf(change.to);
81
+ if (before === undefined || after === undefined)
82
+ return [];
83
+ const lines = lineDiff(before, after);
84
+ const shown = lines.length > MAX_LINES ? [...lines.slice(0, MAX_LINES), `… ${lines.length - MAX_LINES} more lines`] : lines;
85
+ return shown.map((line) => `${indent}${line}`);
86
+ }
@@ -0,0 +1,23 @@
1
+ import type { StackDeps } from "../stack/commands.js";
2
+ /** Watches `paths` (folders recursively, or files), calling `onChange` with a path that changed. */
3
+ export type FolderWatcher = (paths: readonly string[], onChange: (path: string) => void) => {
4
+ close(): void;
5
+ };
6
+ /** `node:fs` `watch`, recursive for a folder. */
7
+ export declare const nodeWatcher: FolderWatcher;
8
+ export interface WatchOptions {
9
+ watcher?: FolderWatcher;
10
+ /** Ends the watch; by default SIGINT or SIGTERM. */
11
+ signal?: AbortSignal;
12
+ /** How long changes settle before a cycle (default 200 ms). */
13
+ debounceMs?: number;
14
+ }
15
+ /**
16
+ * Checks and plans `dirs` (apply's other flags in `args`) now and on each change, saving with
17
+ * `save`, until the signal ends it.
18
+ */
19
+ export declare function watchApply(deps: StackDeps, run: {
20
+ dirs: readonly string[];
21
+ args: readonly string[];
22
+ save: boolean;
23
+ }, options?: WatchOptions): Promise<number>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `nylorun apply --watch [--save]` (track T7, design §6): checks the folders and plans their
3
+ * manifests at once, then again on each change to them (debounced), until interrupted. It saves
4
+ * only with `--save`: each save is a new immutable version that moves the Agents tracking the
5
+ * name, so `--watch --save` is for a development manifest. A cycle that fails (a Runtime that
6
+ * refuses, a folder that does not pack) prints why and the watch goes on. The file a
7
+ * `runtime.json` `extends` is watched too.
8
+ */
9
+ import { watch as watchFiles } from "node:fs";
10
+ import { relative, resolve } from "node:path";
11
+ import { applyCommand } from "./apply.js";
12
+ import { agentFolders, checkCommand, packed } from "./folder.js";
13
+ /** `node:fs` `watch`, recursive for a folder. */
14
+ export const nodeWatcher = (paths, onChange) => {
15
+ const watchers = paths.map((path) => watchFiles(path, { recursive: true }, (_event, file) => onChange(file ? resolve(path, String(file)) : path)));
16
+ return { close: () => watchers.forEach((watcher) => watcher.close()) };
17
+ };
18
+ /** What changes that never start a cycle: version control and dependencies. */
19
+ const QUIET = /(^|[\\/])(\.git|node_modules|\.hg|\.svn)([\\/]|$)/;
20
+ /**
21
+ * Checks and plans `dirs` (apply's other flags in `args`) now and on each change, saving with
22
+ * `save`, until the signal ends it.
23
+ */
24
+ export async function watchApply(deps, run, options = {}) {
25
+ const cwd = deps.cwd ?? process.cwd();
26
+ const dirs = run.dirs.length > 0 ? run.dirs : ["."];
27
+ const controller = new AbortController();
28
+ const signal = options.signal ?? controller.signal;
29
+ const stop = () => controller.abort();
30
+ if (!options.signal) {
31
+ process.once("SIGINT", stop);
32
+ process.once("SIGTERM", stop);
33
+ }
34
+ const cycle = async () => {
35
+ try {
36
+ if ((await checkCommand(deps, dirs)) === 0)
37
+ await applyCommand(deps, run.args, { planOnly: !run.save, checked: true });
38
+ }
39
+ catch (error) {
40
+ deps.err(error instanceof Error ? error.message : String(error));
41
+ }
42
+ deps.out(`Watching ${dirs.join(", ")} for changes${run.save ? ", saving each" : ""} (Ctrl-C stops).`);
43
+ };
44
+ // The folders, and the files their runtime.json extends.
45
+ const paths = new Set(dirs.map((dir) => resolve(cwd, dir)));
46
+ for (const dir of dirs)
47
+ for (const folder of await agentFolders(resolve(cwd, dir)).catch(() => []))
48
+ for (const file of (await packed(folder).catch(() => undefined))?.extended ?? [])
49
+ paths.add(file);
50
+ let running = cycle();
51
+ let timer;
52
+ let changed;
53
+ const watcher = (options.watcher ?? nodeWatcher)([...paths], (path) => {
54
+ if (QUIET.test(path))
55
+ return;
56
+ changed = path;
57
+ clearTimeout(timer);
58
+ timer = setTimeout(() => {
59
+ const what = changed;
60
+ running = running.then(async () => {
61
+ if (signal.aborted)
62
+ return;
63
+ deps.out("");
64
+ deps.out(`${what === undefined ? "A file" : relative(cwd, what) || "."} changed: checking again.`);
65
+ await cycle();
66
+ });
67
+ }, options.debounceMs ?? 200);
68
+ });
69
+ try {
70
+ await new Promise((done) => {
71
+ if (signal.aborted)
72
+ done();
73
+ else
74
+ signal.addEventListener("abort", () => done(), { once: true });
75
+ });
76
+ }
77
+ finally {
78
+ clearTimeout(timer);
79
+ watcher.close();
80
+ if (!options.signal) {
81
+ process.off("SIGINT", stop);
82
+ process.off("SIGTERM", stop);
83
+ }
84
+ }
85
+ await running;
86
+ return 0;
87
+ }