@ree-auz/nook 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +422 -410
- package/bin/nk.mjs +3 -3
- package/docs/setup-hooks.md +107 -107
- package/integrations/linear/README.md +84 -84
- package/integrations/linear/coding-tools.json +7 -7
- package/integrations/linear/open-issue.mjs +147 -147
- package/package.json +34 -34
- package/skills/nook/SKILL.md +71 -71
- package/src/agents.mjs +92 -92
- package/src/backup.mjs +74 -74
- package/src/ci.mjs +378 -378
- package/src/cli.mjs +151 -151
- package/src/commands/check-hooks.mjs +128 -128
- package/src/commands/clean.mjs +175 -175
- package/src/commands/doctor.mjs +100 -91
- package/src/commands/forget.mjs +57 -57
- package/src/commands/hooks.mjs +34 -27
- package/src/commands/init.mjs +154 -154
- package/src/commands/ls.mjs +107 -107
- package/src/commands/rm.mjs +190 -190
- package/src/commands/workspace.mjs +163 -163
- package/src/commands/write-hooks.mjs +185 -185
- package/src/compose-plan.mjs +228 -228
- package/src/compose.mjs +123 -123
- package/src/config.mjs +115 -115
- package/src/context.mjs +189 -189
- package/src/errors.mjs +8 -8
- package/src/forge.mjs +75 -75
- package/src/generate-hooks.mjs +367 -367
- package/src/git.mjs +86 -86
- package/src/home.mjs +48 -48
- package/src/hooks.mjs +135 -135
- package/src/names.mjs +65 -65
- package/src/occupancy-windows.ps1 +59 -59
- package/src/occupancy.mjs +131 -131
- package/src/ports.mjs +89 -45
- package/src/prompt.mjs +100 -100
- package/src/shared.mjs +97 -97
- package/src/status.mjs +30 -30
- package/src/tools.mjs +23 -23
- 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
|
+
}
|