@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
package/src/agents.mjs CHANGED
@@ -1,92 +1,92 @@
1
- import { readFileSync, statSync } from "node:fs";
2
- import { delimiter, dirname, extname, isAbsolute, join } from "node:path";
3
- import { NookError } from "./errors.mjs";
4
- import spawn from "cross-spawn";
5
-
6
- /** Command-line coding agents init looks for. Any other command works too. */
7
- export const KNOWN_AGENTS = ["claude", "codex", "gemini", "grok", "opencode", "pi", "aider", "amp", "cursor-agent", "qwen"];
8
-
9
- function isFile(path) {
10
- try {
11
- return statSync(path).isFile();
12
- } catch {
13
- return false;
14
- }
15
- }
16
-
17
- /** Finds a command on PATH, like `which` / `where`. */
18
- export function which(command, env = process.env, platform = process.platform) {
19
- const extensions =
20
- platform === "win32" ? ["", ...(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((ext) => ext.toLowerCase())] : [""];
21
- if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
22
- for (const ext of extensions) if (isFile(command + ext)) return command + ext;
23
- return null;
24
- }
25
- const dirs = (env.PATH || env.Path || "").split(platform === "win32" ? ";" : delimiter).filter(Boolean);
26
- for (const dir of dirs)
27
- for (const ext of extensions) {
28
- if (platform === "win32" && ext === "" && !extname(command)) continue;
29
- const full = join(dir, command + ext);
30
- if (isFile(full)) return full;
31
- }
32
- return null;
33
- }
34
-
35
- export function commandVersion(path) {
36
- const result = spawn.sync(path, ["--version"], { encoding: "utf8", timeout: 10000, windowsHide: true });
37
- if (result.status !== 0 || !result.stdout) return "";
38
- return result.stdout.trim().split(/\r?\n/)[0].slice(0, 80);
39
- }
40
-
41
- export function detectAgents(env = process.env, names = KNOWN_AGENTS) {
42
- const found = [];
43
- for (const name of names) {
44
- const path = which(name, env);
45
- if (path) found.push({ name, path, version: commandVersion(path) });
46
- }
47
- return found;
48
- }
49
-
50
- /** Command for `nk <name> <agent> [-p prompt] [-- extra]`. */
51
- export function agentCommand(config, agent, { prompt, extra = [] } = {}, env = process.env) {
52
- const entry = config.agents?.[agent];
53
- const command = entry?.path && isFile(entry.path) ? entry.path : which(agent, env) ?? agent;
54
- const args = [...(entry?.args ?? []), ...extra];
55
- if (prompt) args.push(prompt);
56
- return { command, args };
57
- }
58
-
59
- /**
60
- * The program behind an npm .cmd shim on Windows, read the way npm's own
61
- * read-cmd-shim does: the quoted path after %dp0% (or %~dp0) followed by %*.
62
- */
63
- export function cmdShimTarget(shimPath) {
64
- let text;
65
- try {
66
- text = readFileSync(shimPath, "utf8");
67
- } catch {
68
- return null;
69
- }
70
- const match = /"%(?:~dp0|dp0%)\\?([^"]+?)"\s+%\*/.exec(text);
71
- return match ? join(dirname(shimPath), ...match[1].split(/[\\/]/)) : null;
72
- }
73
-
74
- /**
75
- * cmd.exe ends a command at the first line break, so a .cmd agent would get
76
- * only the first line of a multi-line prompt. Run the program behind an npm
77
- * shim directly instead.
78
- */
79
- export function windowsDirect({ command, args }, platform = process.platform) {
80
- if (platform !== "win32" || !/\.(cmd|bat)$/i.test(command) || !args.some((arg) => /[\r\n]/.test(arg))) return { command, args };
81
- const target = cmdShimTarget(command);
82
- if (target && /\.(c|m)?js$/i.test(target) && isFile(target)) return { command: process.execPath, args: [target, ...args] };
83
- if (target && /\.exe$/i.test(target) && isFile(target)) return { command: target, args };
84
- throw new NookError(`${command} runs through cmd.exe, which cannot pass a prompt with line breaks; put the prompt on one line`);
85
- }
86
-
87
- export function launch(spec, { cwd, env }) {
88
- const { command, args } = windowsDirect(spec);
89
- const result = spawn.sync(command, args, { cwd, env, stdio: "inherit" });
90
- if (result.error) throw result.error;
91
- return result.status ?? 1;
92
- }
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { delimiter, dirname, extname, isAbsolute, join } from "node:path";
3
+ import { NookError } from "./errors.mjs";
4
+ import spawn from "cross-spawn";
5
+
6
+ /** Command-line coding agents init looks for. Any other command works too. */
7
+ export const KNOWN_AGENTS = ["claude", "codex", "gemini", "grok", "opencode", "pi", "aider", "amp", "cursor-agent", "qwen"];
8
+
9
+ function isFile(path) {
10
+ try {
11
+ return statSync(path).isFile();
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ /** Finds a command on PATH, like `which` / `where`. */
18
+ export function which(command, env = process.env, platform = process.platform) {
19
+ const extensions =
20
+ platform === "win32" ? ["", ...(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((ext) => ext.toLowerCase())] : [""];
21
+ if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
22
+ for (const ext of extensions) if (isFile(command + ext)) return command + ext;
23
+ return null;
24
+ }
25
+ const dirs = (env.PATH || env.Path || "").split(platform === "win32" ? ";" : delimiter).filter(Boolean);
26
+ for (const dir of dirs)
27
+ for (const ext of extensions) {
28
+ if (platform === "win32" && ext === "" && !extname(command)) continue;
29
+ const full = join(dir, command + ext);
30
+ if (isFile(full)) return full;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function commandVersion(path) {
36
+ const result = spawn.sync(path, ["--version"], { encoding: "utf8", timeout: 10000, windowsHide: true });
37
+ if (result.status !== 0 || !result.stdout) return "";
38
+ return result.stdout.trim().split(/\r?\n/)[0].slice(0, 80);
39
+ }
40
+
41
+ export function detectAgents(env = process.env, names = KNOWN_AGENTS) {
42
+ const found = [];
43
+ for (const name of names) {
44
+ const path = which(name, env);
45
+ if (path) found.push({ name, path, version: commandVersion(path) });
46
+ }
47
+ return found;
48
+ }
49
+
50
+ /** Command for `nk <name> <agent> [-p prompt] [-- extra]`. */
51
+ export function agentCommand(config, agent, { prompt, extra = [] } = {}, env = process.env) {
52
+ const entry = config.agents?.[agent];
53
+ const command = entry?.path && isFile(entry.path) ? entry.path : which(agent, env) ?? agent;
54
+ const args = [...(entry?.args ?? []), ...extra];
55
+ if (prompt) args.push(prompt);
56
+ return { command, args };
57
+ }
58
+
59
+ /**
60
+ * The program behind an npm .cmd shim on Windows, read the way npm's own
61
+ * read-cmd-shim does: the quoted path after %dp0% (or %~dp0) followed by %*.
62
+ */
63
+ export function cmdShimTarget(shimPath) {
64
+ let text;
65
+ try {
66
+ text = readFileSync(shimPath, "utf8");
67
+ } catch {
68
+ return null;
69
+ }
70
+ const match = /"%(?:~dp0|dp0%)\\?([^"]+?)"\s+%\*/.exec(text);
71
+ return match ? join(dirname(shimPath), ...match[1].split(/[\\/]/)) : null;
72
+ }
73
+
74
+ /**
75
+ * cmd.exe ends a command at the first line break, so a .cmd agent would get
76
+ * only the first line of a multi-line prompt. Run the program behind an npm
77
+ * shim directly instead.
78
+ */
79
+ export function windowsDirect({ command, args }, platform = process.platform) {
80
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(command) || !args.some((arg) => /[\r\n]/.test(arg))) return { command, args };
81
+ const target = cmdShimTarget(command);
82
+ if (target && /\.(c|m)?js$/i.test(target) && isFile(target)) return { command: process.execPath, args: [target, ...args] };
83
+ if (target && /\.exe$/i.test(target) && isFile(target)) return { command: target, args };
84
+ throw new NookError(`${command} runs through cmd.exe, which cannot pass a prompt with line breaks; put the prompt on one line`);
85
+ }
86
+
87
+ export function launch(spec, { cwd, env }) {
88
+ const { command, args } = windowsDirect(spec);
89
+ const result = spawn.sync(command, args, { cwd, env, stdio: "inherit" });
90
+ if (result.error) throw result.error;
91
+ return result.status ?? 1;
92
+ }
package/src/backup.mjs CHANGED
@@ -1,74 +1,74 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { exists } from "./home.mjs";
4
- import { git, gitTry, refExists, statusLines } from "./git.mjs";
5
- import { copyFile } from "./shared.mjs";
6
-
7
- /** Commits on `branch` that are on no remote and not in the base branch. */
8
- export function unpushedCommits(repo, branch, base) {
9
- if (!refExists(repo, `refs/heads/${branch}`)) return [];
10
- const args = ["rev-list", `refs/heads/${branch}`, "--not", "--remotes"];
11
- if (base && base !== branch) args.push(base);
12
- const result = gitTry(args, { cwd: repo });
13
- return result.ok && result.out ? result.out.split(/\r?\n/) : [];
14
- }
15
-
16
- export function isMerged(repo, branch, base) {
17
- if (!refExists(repo, `refs/heads/${branch}`)) return true;
18
- return gitTry(["merge-base", "--is-ancestor", `refs/heads/${branch}`, base], { cwd: repo }).ok;
19
- }
20
-
21
- /** What a removal would lose: uncommitted changes, unpushed commits, unmerged branch. */
22
- export function inspectWorkspace({ repo, path, branch, base }) {
23
- const present = exists(path);
24
- return {
25
- present,
26
- changes: present ? statusLines(path) : [],
27
- unpushed: unpushedCommits(repo, branch, base),
28
- merged: isMerged(repo, branch, base),
29
- };
30
- }
31
-
32
- export function needsBackup(state) {
33
- return state.changes.length > 0 || state.unpushed.length > 0;
34
- }
35
-
36
- function stamp(date = new Date()) {
37
- const pad = (n) => String(n).padStart(2, "0");
38
- return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
39
- }
40
-
41
- export function backupDir(backupRoot, name, date = new Date()) {
42
- let dir = join(backupRoot, `${name}-${stamp(date)}`);
43
- for (let n = 2; exists(dir); n++) dir = join(backupRoot, `${name}-${stamp(date)}-${n}`);
44
- return dir;
45
- }
46
-
47
- /**
48
- * Saves what removal would lose:
49
- * changes.patch tracked changes (staged and unstaged), apply with `git apply`
50
- * untracked/ new files git does not ignore
51
- * commits.bundle unpushed commits, restore with `git fetch <bundle> <branch>`
52
- * info.txt workspace, branch, HEAD, time
53
- */
54
- export function createBackup({ repo, path, branch, base, state, dir }) {
55
- mkdirSync(dir, { recursive: true });
56
- const info = [`workspace: ${branch}`, `path: ${path}`, `time: ${new Date().toISOString()}`];
57
- if (state.present) {
58
- const head = gitTry(["rev-parse", "HEAD"], { cwd: path });
59
- if (head.ok) info.push(`head: ${head.out}`);
60
- const diff = gitTry(["diff", "--binary", "HEAD"], { cwd: path });
61
- if (diff.ok && diff.out) writeFileSync(join(dir, "changes.patch"), diff.out + "\n");
62
- const untracked = git(["ls-files", "-z", "--others", "--exclude-standard"], { cwd: path }).split("\0").filter(Boolean);
63
- for (const file of untracked) copyFile(join(path, file), join(dir, "untracked", file));
64
- if (untracked.length) info.push(`untracked files: ${untracked.length}`);
65
- }
66
- if (state.unpushed.length) {
67
- const args = ["bundle", "create", join(dir, "commits.bundle"), `refs/heads/${branch}`, "--not", "--remotes"];
68
- if (base && base !== branch) args.push(base);
69
- git(args, { cwd: repo });
70
- info.push(`unpushed commits: ${state.unpushed.length}`);
71
- }
72
- writeFileSync(join(dir, "info.txt"), info.join("\n") + "\n");
73
- return dir;
74
- }
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { exists } from "./home.mjs";
4
+ import { git, gitTry, refExists, statusLines } from "./git.mjs";
5
+ import { copyFile } from "./shared.mjs";
6
+
7
+ /** Commits on `branch` that are on no remote and not in the base branch. */
8
+ export function unpushedCommits(repo, branch, base) {
9
+ if (!refExists(repo, `refs/heads/${branch}`)) return [];
10
+ const args = ["rev-list", `refs/heads/${branch}`, "--not", "--remotes"];
11
+ if (base && base !== branch) args.push(base);
12
+ const result = gitTry(args, { cwd: repo });
13
+ return result.ok && result.out ? result.out.split(/\r?\n/) : [];
14
+ }
15
+
16
+ export function isMerged(repo, branch, base) {
17
+ if (!refExists(repo, `refs/heads/${branch}`)) return true;
18
+ return gitTry(["merge-base", "--is-ancestor", `refs/heads/${branch}`, base], { cwd: repo }).ok;
19
+ }
20
+
21
+ /** What a removal would lose: uncommitted changes, unpushed commits, unmerged branch. */
22
+ export function inspectWorkspace({ repo, path, branch, base }) {
23
+ const present = exists(path);
24
+ return {
25
+ present,
26
+ changes: present ? statusLines(path) : [],
27
+ unpushed: unpushedCommits(repo, branch, base),
28
+ merged: isMerged(repo, branch, base),
29
+ };
30
+ }
31
+
32
+ export function needsBackup(state) {
33
+ return state.changes.length > 0 || state.unpushed.length > 0;
34
+ }
35
+
36
+ function stamp(date = new Date()) {
37
+ const pad = (n) => String(n).padStart(2, "0");
38
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
39
+ }
40
+
41
+ export function backupDir(backupRoot, name, date = new Date()) {
42
+ let dir = join(backupRoot, `${name}-${stamp(date)}`);
43
+ for (let n = 2; exists(dir); n++) dir = join(backupRoot, `${name}-${stamp(date)}-${n}`);
44
+ return dir;
45
+ }
46
+
47
+ /**
48
+ * Saves what removal would lose:
49
+ * changes.patch tracked changes (staged and unstaged), apply with `git apply`
50
+ * untracked/ new files git does not ignore
51
+ * commits.bundle unpushed commits, restore with `git fetch <bundle> <branch>`
52
+ * info.txt workspace, branch, HEAD, time
53
+ */
54
+ export function createBackup({ repo, path, branch, base, state, dir }) {
55
+ mkdirSync(dir, { recursive: true });
56
+ const info = [`workspace: ${branch}`, `path: ${path}`, `time: ${new Date().toISOString()}`];
57
+ if (state.present) {
58
+ const head = gitTry(["rev-parse", "HEAD"], { cwd: path });
59
+ if (head.ok) info.push(`head: ${head.out}`);
60
+ const diff = gitTry(["diff", "--binary", "HEAD"], { cwd: path });
61
+ if (diff.ok && diff.out) writeFileSync(join(dir, "changes.patch"), diff.out + "\n");
62
+ const untracked = git(["ls-files", "-z", "--others", "--exclude-standard"], { cwd: path }).split("\0").filter(Boolean);
63
+ for (const file of untracked) copyFile(join(path, file), join(dir, "untracked", file));
64
+ if (untracked.length) info.push(`untracked files: ${untracked.length}`);
65
+ }
66
+ if (state.unpushed.length) {
67
+ const args = ["bundle", "create", join(dir, "commits.bundle"), `refs/heads/${branch}`, "--not", "--remotes"];
68
+ if (base && base !== branch) args.push(base);
69
+ git(args, { cwd: repo });
70
+ info.push(`unpushed commits: ${state.unpushed.length}`);
71
+ }
72
+ writeFileSync(join(dir, "info.txt"), info.join("\n") + "\n");
73
+ return dir;
74
+ }