@ree-auz/nook 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +422 -410
  3. package/bin/nk.mjs +3 -3
  4. package/docs/setup-hooks.md +107 -107
  5. package/integrations/linear/README.md +94 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +193 -147
  8. package/package.json +34 -34
  9. package/skills/nook/SKILL.md +71 -71
  10. package/src/agents.mjs +92 -92
  11. package/src/backup.mjs +74 -74
  12. package/src/ci.mjs +378 -378
  13. package/src/cli.mjs +151 -151
  14. package/src/commands/check-hooks.mjs +128 -128
  15. package/src/commands/clean.mjs +175 -175
  16. package/src/commands/doctor.mjs +100 -91
  17. package/src/commands/forget.mjs +57 -57
  18. package/src/commands/hooks.mjs +34 -27
  19. package/src/commands/init.mjs +154 -154
  20. package/src/commands/ls.mjs +107 -107
  21. package/src/commands/rm.mjs +190 -190
  22. package/src/commands/workspace.mjs +163 -163
  23. package/src/commands/write-hooks.mjs +185 -185
  24. package/src/compose-plan.mjs +228 -228
  25. package/src/compose.mjs +123 -123
  26. package/src/config.mjs +115 -115
  27. package/src/context.mjs +189 -189
  28. package/src/errors.mjs +8 -8
  29. package/src/forge.mjs +75 -75
  30. package/src/generate-hooks.mjs +367 -367
  31. package/src/git.mjs +86 -86
  32. package/src/home.mjs +48 -48
  33. package/src/hooks.mjs +135 -135
  34. package/src/names.mjs +65 -65
  35. package/src/occupancy-windows.ps1 +59 -59
  36. package/src/occupancy.mjs +131 -131
  37. package/src/ports.mjs +89 -45
  38. package/src/prompt.mjs +100 -100
  39. package/src/shared.mjs +97 -97
  40. package/src/status.mjs +30 -30
  41. package/src/tools.mjs +23 -23
  42. package/src/ui.mjs +60 -60
@@ -1,185 +1,185 @@
1
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { basename, join } from "node:path";
3
- import { parseEnv } from "node:util";
4
- import { resolveRepo } from "../context.mjs";
5
- import { exists } from "../home.mjs";
6
- import { gitTry } from "../git.mjs";
7
- import { HOOK_EVENTS, findHooks } from "../hooks.mjs";
8
- import { NookError } from "../errors.mjs";
9
- import { planFromCi } from "../ci.mjs";
10
- import { planCompose } from "../compose-plan.mjs";
11
- import { GENERATED_MARK, hookFiles, sources } from "../generate-hooks.mjs";
12
- import { listFiles } from "../shared.mjs";
13
- import { agentCommand, launch } from "../agents.mjs";
14
-
15
- /** Files that say how a project is installed, run or served. */
16
- const MARKERS = new Set([
17
- "package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb", "pnpm-workspace.yaml",
18
- "pyproject.toml", "uv.lock", "poetry.lock", "requirements.txt", "Pipfile.lock",
19
- "Gemfile.lock", "go.mod", "Cargo.toml", "composer.lock", "mix.lock",
20
- "compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml", "Dockerfile", "devcontainer.json",
21
- "Makefile", "justfile", "Taskfile.yml", "Procfile", "mise.toml", ".tool-versions", ".nvmrc", ".python-version",
22
- ]);
23
- const DOC = "https://github.com/ree-auz/nook/blob/main/docs/setup-hooks.md";
24
-
25
- /** CI configuration: it already lists, step by step, how a clean machine gets the project running. */
26
- const CI_FILE = /^(\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|bitbucket-pipelines\.yml|\.buildkite\/pipeline\.ya?ml|\.woodpecker\.ya?ml|\.woodpecker\/[^/]+\.ya?ml)$/;
27
-
28
- function ciFiles(repoPath) {
29
- const listed = gitTry(["ls-files"], { cwd: repoPath });
30
- return listed.ok ? listed.out.split(/\r?\n/).filter((file) => CI_FILE.test(file)) : [];
31
- }
32
-
33
- /** Tracked marker files up to two folders deep, skipping vendored code. */
34
- function markerFiles(repoPath) {
35
- const listed = gitTry(["ls-files"], { cwd: repoPath });
36
- if (!listed.ok) return [];
37
- return listed.out
38
- .split(/\r?\n/)
39
- .filter((file) => file.split("/").length <= 3 && !/(^|\/)(node_modules|vendor)\//.test(file) && MARKERS.has(basename(file)));
40
- }
41
-
42
- /** Names of the shared files, and keys in them left empty. Values are never read out. */
43
- function sharedSummary(sharedDir) {
44
- if (!exists(sharedDir)) return [];
45
- return listFiles(sharedDir).map((file) => {
46
- if (!/(^|\/)\.env[^/]*$/.test(file)) return file;
47
- try {
48
- const values = parseEnv(readFileSync(join(sharedDir, file), "utf8"));
49
- const empty = Object.keys(values).filter((key) => values[key] === "");
50
- return empty.length ? `${file} (empty: ${empty.join(", ")})` : file;
51
- } catch {
52
- return file;
53
- }
54
- });
55
- }
56
-
57
- /** Hook files in `<dir>/.nook`, with whether nk generated them. */
58
- function existingHooks(dir) {
59
- const folder = join(dir, ".nook");
60
- if (!exists(folder)) return [];
61
- return readdirSync(folder)
62
- .filter((file) => HOOK_EVENTS.some((event) => file.startsWith(`${event}.`)))
63
- .map((file) => ({ file, generated: readFileSync(join(folder, file), "utf8").startsWith(GENERATED_MARK) }));
64
- }
65
-
66
- /**
67
- * Writes hooks into `<dir>/.nook` from the repository's CI. Never replaces a
68
- * hook someone wrote by hand. Returns { plan, written } or { reason }.
69
- */
70
- export function generateHooks(dir) {
71
- // Compose services nk would start, so CI's addresses that point at them count as the workspace's own.
72
- const candidate = planCompose(dir);
73
- const known = candidate ? [...Object.values(candidate.remap).flat().map((entry) => entry.host), ...Object.values(candidate.ports).filter((port) => /^\d+$/.test(port))] : [];
74
- const ci = planFromCi(dir, known);
75
- // CI that starts its own services says what the project needs; the Compose file is then for other uses.
76
- const ciPorts = ci.best?.services.flatMap((service) => service.ports).length ?? 0;
77
- const compose = candidate && (ci.best?.services.length ? planCompose(dir, { start: false, reserved: ciPorts }) : candidate);
78
- if (!ci.files.length && !compose) return { reason: "no GitHub Actions workflow (.github/workflows/*.yml) and no Compose file" };
79
- if (!ci.best && !compose) return { reason: `could not read ${ci.errors.map((e) => `${e.source} (${e.error.split("\n")[0]})`).join(", ")}` };
80
- const base = ci.best ?? { source: compose.file, job: null, tools: [], env: {}, services: [], create: [], remove: [], skipped: [], untranslated: [] };
81
- const plan = compose ? { ...base, compose, untranslated: [...base.untranslated, ...compose.untranslated] } : base;
82
- const handWritten = existingHooks(dir).filter((hook) => !hook.generated);
83
- if (handWritten.length) return { reason: `.nook already has hooks written by hand: ${handWritten.map((h) => h.file).join(", ")}`, plan };
84
- const files = hookFiles(plan);
85
- const folder = join(dir, ".nook");
86
- mkdirSync(folder, { recursive: true });
87
- for (const hook of existingHooks(dir)) if (!files[hook.file]) rmSync(join(folder, hook.file));
88
- for (const [file, text] of Object.entries(files)) writeFileSync(join(folder, file), text);
89
- return { plan, written: Object.keys(files), others: ci.others };
90
- }
91
-
92
- function report(ctx, result) {
93
- const { plan } = result;
94
- ctx.ok(`Wrote ${result.written.map((file) => `.nook/${file}`).join(", ")} from ${sources(plan)}`);
95
- if (result.others.length) ctx.info(`Other CI jobs not used: ${result.others.slice(0, 5).join(", ")}${result.others.length > 5 ? ` and ${result.others.length - 5} more` : ""}`);
96
- if (plan.tools.length) ctx.info(`Checks the tools CI installs: ${plan.tools.map((t) => (t.version ? `${t.name} ${t.version}` : t.name)).join(", ")}`);
97
- if (plan.services.length) ctx.info(`Starts for each workspace, on its own ports: ${plan.services.map((s) => `${s.name} (${s.image.split("@")[0]})`).join(", ")}`);
98
- if (plan.compose) {
99
- const { file, start, ports, notStarted } = plan.compose;
100
- if (start.length) ctx.info(`Starts from ${file} in each workspace's own Compose project: ${start.join(", ")}`);
101
- if (Object.keys(ports).length) ctx.info(`Gives each workspace its own ports in .env: ${Object.keys(ports).join(", ")}`);
102
- const moved = Object.entries(plan.compose.remap).map(([name, entries]) => `${name} ${entries.map((e) => e.host).join(", ")}`);
103
- if (moved.length) ctx.info(`Moves fixed host ports to each workspace's own: ${moved.join("; ")}`);
104
- if (plan.compose.ownNetwork?.length) ctx.info(`Runs on each workspace's own network instead of the host's: ${plan.compose.ownNetwork.join(", ")}`);
105
- if (notStarted.length) ctx.info(`Not started (start them yourself when needed): ${notStarted.map((s) => `${s.name} (${s.why})`).join(", ")}`);
106
- }
107
- if (Object.keys(plan.env).length) ctx.info(`Adds to .env: ${Object.keys(plan.env).join(", ")}`);
108
- if (plan.create.length) {
109
- ctx.info("Setup steps (create):");
110
- for (const step of plan.create) ctx.item(step.run.split("\n")[0]);
111
- }
112
- if (plan.remove.length) {
113
- ctx.info("Cleanup steps (remove):");
114
- for (const step of plan.remove) ctx.item(step.run.split("\n")[0]);
115
- }
116
- if (plan.skipped.length) ctx.info(`Left out ${plan.skipped.length} CI-only steps (checkout, tests, lint, build, uploads)`);
117
- if (plan.untranslated.length) {
118
- ctx.warn("Not added; review these and add what is safe by hand (or nk hooks write <agent>):");
119
- for (const item of plan.untranslated) ctx.item(`${item.step}: ${item.why}`);
120
- }
121
- }
122
-
123
- /** The prompt for an agent that writes or finishes this repository's hooks, filled with what nk knows. */
124
- export function setupPrompt(ctx, { repoName, repo, dir = repo.path, generated = null }) {
125
- const markers = markerFiles(dir);
126
- const ci = ciFiles(dir);
127
- const shared = sharedSummary(join(ctx.paths.shared, repoName));
128
- const hooks = findHooks(dir).map((hook) => `.nook/${hook.name}`);
129
- const list = (items, none) => (items.length ? items.map((item) => `- ${item}`).join("\n") : `- ${none}`);
130
- const start = generated?.written
131
- ? `nk already wrote ${generated.written.map((f) => `.nook/${f}`).join(", ")} from ${sources(generated.plan)}, without AI. What it could not translate:
132
- ${list(generated.plan.untranslated.map((i) => `${i.step}: ${i.why}`), "nothing")}
133
-
134
- Your task, in this folder:
135
- 1. Read the generated hooks and the CI file. Add what was not translated, adapted to a workspace (its own ports from NOOK_PORT, its own container and project names, started again by open, cleaned up by remove). Keep the rest as generated. Once you edit a hook, remove its first line, so nk hooks write will not replace your work.`
136
- : `Your task, in this folder:
137
- 1. Start from the CI configuration: take its setup steps (runtime versions, dependency install, services such as databases, environment variables, migrations or seed data) and leave out what only CI needs (tests, lint, builds, releases, caches, uploads). Then adapt it to a workspace, which differs from a CI machine in three ways: several workspaces run at once, so ports and container or Compose project names must be this workspace's own; a workspace is kept and reopened, so services must start again; and nothing is thrown away afterwards, so remove has to clean up. Without CI, use the files below and the README.
138
- Write .nook/create.mjs and .nook/remove.mjs as a pair, and .nook/open.mjs if services must start again when the workspace is reopened (it runs every time, so it must be safe to repeat).`;
139
- return `Set up Nook hooks for the repository ${repoName}. Nook (nk) gives each task its own git worktree (a workspace) and runs scripts from .nook/ in it: create once, open on every open, remove before deletion. Rules and examples: ${DOC}
140
-
141
- ${start}
142
- 2. Never use the main checkout's .env or services, never print secrets, and never run docker compose config.
143
- 3. Run nk hooks test here. It runs create, open twice and remove in a temporary workspace and lists whatever remove left behind. Fix the hooks until it passes.
144
- 4. Do not commit or push. Tell me what each hook runs and what I must fill in once in the shared files.
145
-
146
- What nk knows about this repository:
147
-
148
- CI configuration:
149
- ${list(ci, "none found")}
150
-
151
- Other files that show how it is set up:
152
- ${list(markers, "none found; read the README")}
153
-
154
- Shared files, copied into every new workspace before create runs (from ~/nook/shared/${repoName}/):
155
- ${list(shared, "none")}
156
-
157
- Hooks there now:
158
- ${list(hooks, "none")}
159
-
160
- What nk does itself: it copies the shared files and gives the workspace ten free ports (NOOK_PORT to NOOK_PORT + 9). On removal it stops programs running in the folder and, as a safety net, removes Docker Compose projects started in it.`;
161
- }
162
-
163
- /**
164
- * nk hooks write [agent]. nk translates the repository's CI into hooks in the
165
- * folder you are in (usually the main checkout). With an agent, it then starts
166
- * the agent in the same folder to finish what was not translated.
167
- */
168
- export async function writeHooks(ctx, { repo: repoFlag, agent, extra = [] }) {
169
- const target = resolveRepo(ctx, repoFlag);
170
- const top = gitTry(["rev-parse", "--show-toplevel"], { cwd: ctx.cwd });
171
- const dir = top.ok ? top.out : target.repo.path;
172
- const result = generateHooks(dir);
173
- if (!agent) {
174
- if (!result.written)
175
- throw new NookError(`Cannot write hooks from CI: ${result.reason}\nTo have an agent write them instead: nk hooks write <agent>`);
176
- report(ctx, result);
177
- ctx.info(`Next: nk hooks test${result.plan.untranslated.length ? `, or nk hooks write <agent> to have an agent finish them` : ""}`);
178
- return 0;
179
- }
180
- if (result.written) report(ctx, result);
181
- else ctx.info(`The agent writes the hooks itself (${result.reason})`);
182
- const prompt = setupPrompt(ctx, { repoName: target.name, repo: target.repo, dir, generated: result });
183
- ctx.info(`Starting ${agent} in ${dir}`);
184
- return launch(agentCommand(ctx.config, agent, { prompt, extra }, ctx.env), { cwd: dir, env: ctx.env });
185
- }
1
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { parseEnv } from "node:util";
4
+ import { resolveRepo } from "../context.mjs";
5
+ import { exists } from "../home.mjs";
6
+ import { gitTry } from "../git.mjs";
7
+ import { HOOK_EVENTS, findHooks } from "../hooks.mjs";
8
+ import { NookError } from "../errors.mjs";
9
+ import { planFromCi } from "../ci.mjs";
10
+ import { planCompose } from "../compose-plan.mjs";
11
+ import { GENERATED_MARK, hookFiles, sources } from "../generate-hooks.mjs";
12
+ import { listFiles } from "../shared.mjs";
13
+ import { agentCommand, launch } from "../agents.mjs";
14
+
15
+ /** Files that say how a project is installed, run or served. */
16
+ const MARKERS = new Set([
17
+ "package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb", "pnpm-workspace.yaml",
18
+ "pyproject.toml", "uv.lock", "poetry.lock", "requirements.txt", "Pipfile.lock",
19
+ "Gemfile.lock", "go.mod", "Cargo.toml", "composer.lock", "mix.lock",
20
+ "compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml", "Dockerfile", "devcontainer.json",
21
+ "Makefile", "justfile", "Taskfile.yml", "Procfile", "mise.toml", ".tool-versions", ".nvmrc", ".python-version",
22
+ ]);
23
+ const DOC = "https://github.com/ree-auz/nook/blob/main/docs/setup-hooks.md";
24
+
25
+ /** CI configuration: it already lists, step by step, how a clean machine gets the project running. */
26
+ const CI_FILE = /^(\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|bitbucket-pipelines\.yml|\.buildkite\/pipeline\.ya?ml|\.woodpecker\.ya?ml|\.woodpecker\/[^/]+\.ya?ml)$/;
27
+
28
+ function ciFiles(repoPath) {
29
+ const listed = gitTry(["ls-files"], { cwd: repoPath });
30
+ return listed.ok ? listed.out.split(/\r?\n/).filter((file) => CI_FILE.test(file)) : [];
31
+ }
32
+
33
+ /** Tracked marker files up to two folders deep, skipping vendored code. */
34
+ function markerFiles(repoPath) {
35
+ const listed = gitTry(["ls-files"], { cwd: repoPath });
36
+ if (!listed.ok) return [];
37
+ return listed.out
38
+ .split(/\r?\n/)
39
+ .filter((file) => file.split("/").length <= 3 && !/(^|\/)(node_modules|vendor)\//.test(file) && MARKERS.has(basename(file)));
40
+ }
41
+
42
+ /** Names of the shared files, and keys in them left empty. Values are never read out. */
43
+ function sharedSummary(sharedDir) {
44
+ if (!exists(sharedDir)) return [];
45
+ return listFiles(sharedDir).map((file) => {
46
+ if (!/(^|\/)\.env[^/]*$/.test(file)) return file;
47
+ try {
48
+ const values = parseEnv(readFileSync(join(sharedDir, file), "utf8"));
49
+ const empty = Object.keys(values).filter((key) => values[key] === "");
50
+ return empty.length ? `${file} (empty: ${empty.join(", ")})` : file;
51
+ } catch {
52
+ return file;
53
+ }
54
+ });
55
+ }
56
+
57
+ /** Hook files in `<dir>/.nook`, with whether nk generated them. */
58
+ function existingHooks(dir) {
59
+ const folder = join(dir, ".nook");
60
+ if (!exists(folder)) return [];
61
+ return readdirSync(folder)
62
+ .filter((file) => HOOK_EVENTS.some((event) => file.startsWith(`${event}.`)))
63
+ .map((file) => ({ file, generated: readFileSync(join(folder, file), "utf8").startsWith(GENERATED_MARK) }));
64
+ }
65
+
66
+ /**
67
+ * Writes hooks into `<dir>/.nook` from the repository's CI. Never replaces a
68
+ * hook someone wrote by hand. Returns { plan, written } or { reason }.
69
+ */
70
+ export function generateHooks(dir) {
71
+ // Compose services nk would start, so CI's addresses that point at them count as the workspace's own.
72
+ const candidate = planCompose(dir);
73
+ const known = candidate ? [...Object.values(candidate.remap).flat().map((entry) => entry.host), ...Object.values(candidate.ports).filter((port) => /^\d+$/.test(port))] : [];
74
+ const ci = planFromCi(dir, known);
75
+ // CI that starts its own services says what the project needs; the Compose file is then for other uses.
76
+ const ciPorts = ci.best?.services.flatMap((service) => service.ports).length ?? 0;
77
+ const compose = candidate && (ci.best?.services.length ? planCompose(dir, { start: false, reserved: ciPorts }) : candidate);
78
+ if (!ci.files.length && !compose) return { reason: "no GitHub Actions workflow (.github/workflows/*.yml) and no Compose file" };
79
+ if (!ci.best && !compose) return { reason: `could not read ${ci.errors.map((e) => `${e.source} (${e.error.split("\n")[0]})`).join(", ")}` };
80
+ const base = ci.best ?? { source: compose.file, job: null, tools: [], env: {}, services: [], create: [], remove: [], skipped: [], untranslated: [] };
81
+ const plan = compose ? { ...base, compose, untranslated: [...base.untranslated, ...compose.untranslated] } : base;
82
+ const handWritten = existingHooks(dir).filter((hook) => !hook.generated);
83
+ if (handWritten.length) return { reason: `.nook already has hooks written by hand: ${handWritten.map((h) => h.file).join(", ")}`, plan };
84
+ const files = hookFiles(plan);
85
+ const folder = join(dir, ".nook");
86
+ mkdirSync(folder, { recursive: true });
87
+ for (const hook of existingHooks(dir)) if (!files[hook.file]) rmSync(join(folder, hook.file));
88
+ for (const [file, text] of Object.entries(files)) writeFileSync(join(folder, file), text);
89
+ return { plan, written: Object.keys(files), others: ci.others };
90
+ }
91
+
92
+ function report(ctx, result) {
93
+ const { plan } = result;
94
+ ctx.ok(`Wrote ${result.written.map((file) => `.nook/${file}`).join(", ")} from ${sources(plan)}`);
95
+ if (result.others.length) ctx.info(`Other CI jobs not used: ${result.others.slice(0, 5).join(", ")}${result.others.length > 5 ? ` and ${result.others.length - 5} more` : ""}`);
96
+ if (plan.tools.length) ctx.info(`Checks the tools CI installs: ${plan.tools.map((t) => (t.version ? `${t.name} ${t.version}` : t.name)).join(", ")}`);
97
+ if (plan.services.length) ctx.info(`Starts for each workspace, on its own ports: ${plan.services.map((s) => `${s.name} (${s.image.split("@")[0]})`).join(", ")}`);
98
+ if (plan.compose) {
99
+ const { file, start, ports, notStarted } = plan.compose;
100
+ if (start.length) ctx.info(`Starts from ${file} in each workspace's own Compose project: ${start.join(", ")}`);
101
+ if (Object.keys(ports).length) ctx.info(`Gives each workspace its own ports in .env: ${Object.keys(ports).join(", ")}`);
102
+ const moved = Object.entries(plan.compose.remap).map(([name, entries]) => `${name} ${entries.map((e) => e.host).join(", ")}`);
103
+ if (moved.length) ctx.info(`Moves fixed host ports to each workspace's own: ${moved.join("; ")}`);
104
+ if (plan.compose.ownNetwork?.length) ctx.info(`Runs on each workspace's own network instead of the host's: ${plan.compose.ownNetwork.join(", ")}`);
105
+ if (notStarted.length) ctx.info(`Not started (start them yourself when needed): ${notStarted.map((s) => `${s.name} (${s.why})`).join(", ")}`);
106
+ }
107
+ if (Object.keys(plan.env).length) ctx.info(`Adds to .env: ${Object.keys(plan.env).join(", ")}`);
108
+ if (plan.create.length) {
109
+ ctx.info("Setup steps (create):");
110
+ for (const step of plan.create) ctx.item(step.run.split("\n")[0]);
111
+ }
112
+ if (plan.remove.length) {
113
+ ctx.info("Cleanup steps (remove):");
114
+ for (const step of plan.remove) ctx.item(step.run.split("\n")[0]);
115
+ }
116
+ if (plan.skipped.length) ctx.info(`Left out ${plan.skipped.length} CI-only steps (checkout, tests, lint, build, uploads)`);
117
+ if (plan.untranslated.length) {
118
+ ctx.warn("Not added; review these and add what is safe by hand (or nk hooks write <agent>):");
119
+ for (const item of plan.untranslated) ctx.item(`${item.step}: ${item.why}`);
120
+ }
121
+ }
122
+
123
+ /** The prompt for an agent that writes or finishes this repository's hooks, filled with what nk knows. */
124
+ export function setupPrompt(ctx, { repoName, repo, dir = repo.path, generated = null }) {
125
+ const markers = markerFiles(dir);
126
+ const ci = ciFiles(dir);
127
+ const shared = sharedSummary(join(ctx.paths.shared, repoName));
128
+ const hooks = findHooks(dir).map((hook) => `.nook/${hook.name}`);
129
+ const list = (items, none) => (items.length ? items.map((item) => `- ${item}`).join("\n") : `- ${none}`);
130
+ const start = generated?.written
131
+ ? `nk already wrote ${generated.written.map((f) => `.nook/${f}`).join(", ")} from ${sources(generated.plan)}, without AI. What it could not translate:
132
+ ${list(generated.plan.untranslated.map((i) => `${i.step}: ${i.why}`), "nothing")}
133
+
134
+ Your task, in this folder:
135
+ 1. Read the generated hooks and the CI file. Add what was not translated, adapted to a workspace (its own ports from NOOK_PORT, its own container and project names, started again by open, cleaned up by remove). Keep the rest as generated. Once you edit a hook, remove its first line, so nk hooks write will not replace your work.`
136
+ : `Your task, in this folder:
137
+ 1. Start from the CI configuration: take its setup steps (runtime versions, dependency install, services such as databases, environment variables, migrations or seed data) and leave out what only CI needs (tests, lint, builds, releases, caches, uploads). Then adapt it to a workspace, which differs from a CI machine in three ways: several workspaces run at once, so ports and container or Compose project names must be this workspace's own; a workspace is kept and reopened, so services must start again; and nothing is thrown away afterwards, so remove has to clean up. Without CI, use the files below and the README.
138
+ Write .nook/create.mjs and .nook/remove.mjs as a pair, and .nook/open.mjs if services must start again when the workspace is reopened (it runs every time, so it must be safe to repeat).`;
139
+ return `Set up Nook hooks for the repository ${repoName}. Nook (nk) gives each task its own git worktree (a workspace) and runs scripts from .nook/ in it: create once, open on every open, remove before deletion. Rules and examples: ${DOC}
140
+
141
+ ${start}
142
+ 2. Never use the main checkout's .env or services, never print secrets, and never run docker compose config.
143
+ 3. Run nk hooks test here. It runs create, open twice and remove in a temporary workspace and lists whatever remove left behind. Fix the hooks until it passes.
144
+ 4. Do not commit or push. Tell me what each hook runs and what I must fill in once in the shared files.
145
+
146
+ What nk knows about this repository:
147
+
148
+ CI configuration:
149
+ ${list(ci, "none found")}
150
+
151
+ Other files that show how it is set up:
152
+ ${list(markers, "none found; read the README")}
153
+
154
+ Shared files, copied into every new workspace before create runs (from ~/nook/shared/${repoName}/):
155
+ ${list(shared, "none")}
156
+
157
+ Hooks there now:
158
+ ${list(hooks, "none")}
159
+
160
+ What nk does itself: it copies the shared files and gives the workspace ten free ports (NOOK_PORT to NOOK_PORT + 9). On removal it stops programs running in the folder and, as a safety net, removes Docker Compose projects started in it.`;
161
+ }
162
+
163
+ /**
164
+ * nk hooks write [agent]. nk translates the repository's CI into hooks in the
165
+ * folder you are in (usually the main checkout). With an agent, it then starts
166
+ * the agent in the same folder to finish what was not translated.
167
+ */
168
+ export async function writeHooks(ctx, { repo: repoFlag, agent, extra = [] }) {
169
+ const target = resolveRepo(ctx, repoFlag);
170
+ const top = gitTry(["rev-parse", "--show-toplevel"], { cwd: ctx.cwd });
171
+ const dir = top.ok ? top.out : target.repo.path;
172
+ const result = generateHooks(dir);
173
+ if (!agent) {
174
+ if (!result.written)
175
+ throw new NookError(`Cannot write hooks from CI: ${result.reason}\nTo have an agent write them instead: nk hooks write <agent>`);
176
+ report(ctx, result);
177
+ ctx.info(`Next: nk hooks test${result.plan.untranslated.length ? `, or nk hooks write <agent> to have an agent finish them` : ""}`);
178
+ return 0;
179
+ }
180
+ if (result.written) report(ctx, result);
181
+ else ctx.info(`The agent writes the hooks itself (${result.reason})`);
182
+ const prompt = setupPrompt(ctx, { repoName: target.name, repo: target.repo, dir, generated: result });
183
+ ctx.info(`Starting ${agent} in ${dir}`);
184
+ return launch(agentCommand(ctx.config, agent, { prompt, extra }, ctx.env), { cwd: dir, env: ctx.env });
185
+ }