@2nspired/pigeon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @2nspired/pigeon
2
+
3
+ The bootstrap CLI for [Pigeon](https://github.com/2nspired/pigeon) — a local-first kanban board + MCP server that gives AI coding agents durable context across sessions.
4
+
5
+ ```bash
6
+ npx @2nspired/pigeon init
7
+ ```
8
+
9
+ One command, run from inside your project repo. It:
10
+
11
+ 1. Clones the latest Pigeon release into `~/.pigeon` (override with `PIGEON_HOME` or `--home`) and installs its dependencies.
12
+ 2. Creates/migrates the SQLite database.
13
+ 3. Registers the Pigeon MCP server with Claude Code (user scope via `claude mcp add`; falls back to a project-scoped `.mcp.json`).
14
+ 4. Registers your repo with Pigeon so `briefMe()` auto-detects it, writes a starter `tracker.md`, and installs the `/brief-me`, `/handoff`, and `/plan-card` slash commands plus the token-tracking Stop hook.
15
+ 5. On macOS, installs the always-on background service at `http://localhost:3100`.
16
+
17
+ Re-running is safe — every step is idempotent.
18
+
19
+ ## Flags
20
+
21
+ | Flag | Meaning |
22
+ | --- | --- |
23
+ | `--ref <branch\|tag>` | Check out a specific ref instead of the latest release tag |
24
+ | `--home <dir>` | Pigeon home checkout location (same as `PIGEON_HOME`) |
25
+ | `--agent-name <name>` | Agent display name on the board (default `Claude`) |
26
+ | `--no-claude` | Skip `claude mcp add`; write a project-scoped `.mcp.json` instead |
27
+ | `--no-service` | Skip the macOS launchd service install |
28
+ | `--no-register` | Skip registering the current repo with Pigeon |
29
+
30
+ This package is only the installer — the app itself runs from the git checkout it creates. Full docs: <https://2nspired.github.io/pigeon/>.
package/bin/pigeon.mjs ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `pigeon` — the published bootstrap for Pigeon (#314 Phase B).
4
+ *
5
+ * npx @2nspired/pigeon init full install (home checkout, DB, MCP,
6
+ * project bind, macOS service, doctor)
7
+ * npx @2nspired/pigeon connect [dir] bind a project to an existing install
8
+ *
9
+ * This package is deliberately thin: Node built-ins only, no build step. The
10
+ * app itself runs from the git checkout `init` creates — see cli/lib/home.mjs
11
+ * for why it isn't shipped on npm.
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import { parseCliArgs, USAGE } from "../lib/args.mjs";
16
+ import { connectProject, printConnectSnippet } from "../lib/connect.mjs";
17
+ import { resolvePigeonHome } from "../lib/home.mjs";
18
+ import { runInit } from "../lib/init.mjs";
19
+ import * as ui from "../lib/ui.mjs";
20
+
21
+ function main() {
22
+ const args = parseCliArgs(process.argv.slice(2));
23
+
24
+ if (args.help || (args.command === null && args.errors.length === 0)) {
25
+ console.log(USAGE);
26
+ return 0;
27
+ }
28
+ if (args.errors.length > 0) {
29
+ for (const error of args.errors) console.error(ui.color("red", `error: ${error}`));
30
+ console.error(`\n${USAGE}`);
31
+ return 1;
32
+ }
33
+
34
+ if (args.command === "init") {
35
+ runInit(args);
36
+ return 0;
37
+ }
38
+
39
+ // connect: requires an existing home checkout.
40
+ const home = resolvePigeonHome(process.env, args.home);
41
+ if (!existsSync(home)) {
42
+ console.error(
43
+ ui.color("red", `error: no Pigeon checkout at ${home} — run \`npx @2nspired/pigeon init\` first.`),
44
+ );
45
+ return 1;
46
+ }
47
+ const target = args.target ?? process.cwd();
48
+ const project = connectProject({
49
+ home,
50
+ targetDir: target,
51
+ agentName: args.agentName,
52
+ register: args.register,
53
+ writeMcpJson: true,
54
+ });
55
+ if (project) printConnectSnippet(home);
56
+ return 0;
57
+ }
58
+
59
+ try {
60
+ process.exit(main());
61
+ } catch (err) {
62
+ console.error(ui.color("red", `\npigeon: ${err instanceof Error ? err.message : err}`));
63
+ process.exit(1);
64
+ }
package/lib/args.mjs ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Argument parsing for the `pigeon` CLI — pure, no I/O, unit-tested.
3
+ *
4
+ * Zero-question by default: every flag is an opt-out or an override, never a
5
+ * prompt. `--yes` is accepted (and ignored) so scripted callers can be
6
+ * explicit about the default they're already getting.
7
+ */
8
+
9
+ export const KNOWN_COMMANDS = ["init", "connect"];
10
+
11
+ export const USAGE = `pigeon — one-command Pigeon install
12
+
13
+ Usage:
14
+ npx @2nspired/pigeon init install Pigeon + connect this repo
15
+ npx @2nspired/pigeon connect [dir] connect a project to an existing install
16
+
17
+ Options:
18
+ --ref <branch|tag> checkout ref for the home clone (default: latest release tag)
19
+ --home <dir> Pigeon home checkout (default: $PIGEON_HOME or ~/.pigeon)
20
+ --agent-name <name> agent display name on the board (default: Claude)
21
+ --no-claude skip \`claude mcp add\`; write project .mcp.json instead
22
+ --no-service skip the macOS launchd service install
23
+ --no-register skip registering the current repo with Pigeon
24
+ --yes accepted for scripting; init is already zero-question
25
+ -h, --help show this help
26
+ `;
27
+
28
+ /**
29
+ * @typedef {object} CliArgs
30
+ * @property {string | null} command
31
+ * @property {string | null} target Positional target dir (connect only).
32
+ * @property {string | null} ref
33
+ * @property {string | null} home
34
+ * @property {string} agentName
35
+ * @property {boolean} claude False when --no-claude was passed.
36
+ * @property {boolean} service False when --no-service was passed.
37
+ * @property {boolean} register False when --no-register was passed.
38
+ * @property {boolean} help
39
+ * @property {string[]} errors
40
+ */
41
+
42
+ /**
43
+ * Parse `process.argv.slice(2)`. Never throws — collect errors so the caller
44
+ * can print usage plus every problem at once.
45
+ *
46
+ * @param {string[]} argv
47
+ * @returns {CliArgs}
48
+ */
49
+ export function parseCliArgs(argv) {
50
+ /** @type {CliArgs} */
51
+ const args = {
52
+ command: null,
53
+ target: null,
54
+ ref: null,
55
+ home: null,
56
+ agentName: "Claude",
57
+ claude: true,
58
+ service: true,
59
+ register: true,
60
+ help: false,
61
+ errors: [],
62
+ };
63
+
64
+ const takesValue = new Map([
65
+ ["--ref", "ref"],
66
+ ["--home", "home"],
67
+ ["--agent-name", "agentName"],
68
+ ]);
69
+
70
+ for (let i = 0; i < argv.length; i++) {
71
+ const token = argv[i];
72
+ if (token === "-h" || token === "--help") {
73
+ args.help = true;
74
+ } else if (token === "--no-claude") {
75
+ args.claude = false;
76
+ } else if (token === "--no-service") {
77
+ args.service = false;
78
+ } else if (token === "--no-register") {
79
+ args.register = false;
80
+ } else if (token === "--yes" || token === "-y") {
81
+ // Zero-question is already the default; accepted for scripting.
82
+ } else if (takesValue.has(token)) {
83
+ const value = argv[i + 1];
84
+ if (value === undefined || value.startsWith("--")) {
85
+ args.errors.push(`${token} requires a value`);
86
+ } else {
87
+ args[/** @type {"ref"|"home"|"agentName"} */ (takesValue.get(token))] = value;
88
+ i++;
89
+ }
90
+ } else if (token.startsWith("-")) {
91
+ args.errors.push(`Unknown option: ${token}`);
92
+ } else if (args.command === null) {
93
+ if (KNOWN_COMMANDS.includes(token)) {
94
+ args.command = token;
95
+ } else {
96
+ args.errors.push(`Unknown command: ${token}`);
97
+ }
98
+ } else if (args.command === "connect" && args.target === null) {
99
+ args.target = token;
100
+ } else {
101
+ args.errors.push(`Unexpected argument: ${token}`);
102
+ }
103
+ }
104
+
105
+ // AGENT_NAME lands verbatim in JSON we write — keep it JSON-safe.
106
+ if (/["\\\n\r]/.test(args.agentName)) {
107
+ args.errors.push("--agent-name must not contain quotes, backslashes, or newlines");
108
+ }
109
+
110
+ return args;
111
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Slash-command install — the ONE implementation (port of
3
+ * `install_slash_commands` from `scripts/connect.sh`).
4
+ *
5
+ * Copies `<home>/.claude/commands/*.md` (/brief-me, /handoff, /plan-card)
6
+ * into the target project's `.claude/commands/`, as-is. Idempotent: a
7
+ * pre-existing file with the same name is left untouched so local edits
8
+ * survive re-runs.
9
+ */
10
+
11
+ import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ /**
15
+ * Pure install plan: which command files to copy vs. leave alone.
16
+ *
17
+ * @param {string[]} sourceNames `.md` filenames shipped by the checkout.
18
+ * @param {string[]} existingNames Filenames already in the target dir.
19
+ * @returns {{ install: string[], skip: string[] }}
20
+ */
21
+ export function planCommandInstall(sourceNames, existingNames) {
22
+ const existing = new Set(existingNames);
23
+ const install = [];
24
+ const skip = [];
25
+ for (const name of sourceNames) {
26
+ if (!name.endsWith(".md")) continue;
27
+ (existing.has(name) ? skip : install).push(name);
28
+ }
29
+ return { install, skip };
30
+ }
31
+
32
+ /**
33
+ * Install the checkout's slash commands into a project.
34
+ *
35
+ * @param {{ home: string, targetDir: string }} options
36
+ * @returns {{ installed: string[], skipped: string[] }}
37
+ */
38
+ export function installSlashCommands({ home, targetDir }) {
39
+ const sourceDir = join(home, ".claude", "commands");
40
+ const destDir = join(targetDir, ".claude", "commands");
41
+
42
+ if (!existsSync(sourceDir)) return { installed: [], skipped: [] };
43
+ const sourceNames = readdirSync(sourceDir).filter((name) => name.endsWith(".md"));
44
+ if (sourceNames.length === 0) return { installed: [], skipped: [] };
45
+
46
+ const existingNames = existsSync(destDir) ? readdirSync(destDir) : [];
47
+ const plan = planCommandInstall(sourceNames, existingNames);
48
+
49
+ if (plan.install.length > 0) mkdirSync(destDir, { recursive: true });
50
+ for (const name of plan.install) {
51
+ copyFileSync(join(sourceDir, name), join(destDir, name));
52
+ }
53
+ return { installed: plan.install, skipped: plan.skip };
54
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Project connection — the shared implementation behind `pigeon init` (when
3
+ * run inside a project repo), `pigeon connect`, `scripts/connect.sh`, and
4
+ * `scripts/setup.mts` step 3.
5
+ *
6
+ * Steps (each independently idempotent):
7
+ * 1. Register the repo with Pigeon (`scripts/register-repo.ts` in the home
8
+ * checkout) so `Project.repoPath` is set and briefMe auto-detection
9
+ * works — the #154 failure mode this CLI exists to close.
10
+ * 2. Starter `tracker.md` at the repo root (if absent).
11
+ * 3. Slash commands into `.claude/commands/` (existing files untouched).
12
+ * 4. Token-tracking Stop hook into user-level settings.json.
13
+ * 5. Optionally a project-scoped `.mcp.json` (the fallback when user-scope
14
+ * `claude mcp add` isn't used).
15
+ */
16
+
17
+ import { existsSync, realpathSync } from "node:fs";
18
+ import { basename, join } from "node:path";
19
+ import { installSlashCommands } from "./commands.mjs";
20
+ import { commandOnPath, run } from "./exec.mjs";
21
+ import { manualMcpSnippet, writeProjectMcpJson } from "./mcp-config.mjs";
22
+ import { installStopHook, resolveUserSettingsPath } from "./stop-hook.mjs";
23
+ import { writeStarterTrackerMd } from "./tracker-template.mjs";
24
+ import * as ui from "./ui.mjs";
25
+
26
+ /**
27
+ * Git toplevel for `dir`, or null when not in a repo.
28
+ *
29
+ * @param {string} dir
30
+ */
31
+ export function detectGitRoot(dir) {
32
+ const result = run("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
33
+ capture: true,
34
+ allowFailure: true,
35
+ });
36
+ if (result.status !== 0) return null;
37
+ const top = result.stdout.trim();
38
+ return top ? realpathSync(top) : null;
39
+ }
40
+
41
+ /**
42
+ * Connect a project directory to a Pigeon home checkout.
43
+ *
44
+ * @param {{
45
+ * home: string,
46
+ * targetDir: string,
47
+ * agentName?: string,
48
+ * register?: boolean,
49
+ * writeMcpJson?: boolean,
50
+ * }} options
51
+ * @returns {{ projectRoot: string | null, registered: boolean }}
52
+ */
53
+ export function connectProject({
54
+ home,
55
+ targetDir,
56
+ agentName = "Claude",
57
+ register = true,
58
+ writeMcpJson = true,
59
+ }) {
60
+ const resolvedTarget = realpathSync(targetDir);
61
+ if (resolvedTarget === realpathSync(home)) {
62
+ throw new Error(
63
+ "That's the Pigeon checkout itself — run this from the project you want to connect.",
64
+ );
65
+ }
66
+
67
+ const gitRoot = detectGitRoot(resolvedTarget);
68
+ const projectRoot = gitRoot ?? resolvedTarget;
69
+ const projectName = basename(projectRoot);
70
+ let registered = false;
71
+
72
+ // 1. Repo registration — sets Project.repoPath so briefMe auto-detects.
73
+ if (!gitRoot) {
74
+ ui.warn(`${resolvedTarget} is not inside a git repo — skipping registration.`);
75
+ ui.info("briefMe auto-detection needs a git root; run `git init` and re-run.");
76
+ } else if (!register) {
77
+ ui.skip("Repo registration skipped (--no-register).");
78
+ } else {
79
+ // Non-fatal: the remaining steps (tracker.md, commands, hook, .mcp.json)
80
+ // are still worth landing, and registration can be redone from a
81
+ // session via registerRepo (see docs/AGENT-GUIDE.md).
82
+ try {
83
+ run("npx", ["tsx", "scripts/register-repo.ts", gitRoot, projectName], { cwd: home });
84
+ registered = true;
85
+ } catch (err) {
86
+ ui.warn(`Repo registration failed: ${err instanceof Error ? err.message : err}`);
87
+ ui.info("Fix the install (npm run doctor in the checkout), then re-run connect.");
88
+ }
89
+ }
90
+
91
+ // 2. Starter tracker.md.
92
+ const trackerStatus = writeStarterTrackerMd({ targetDir: projectRoot, projectName });
93
+ if (trackerStatus === "created") {
94
+ ui.ok(`Starter tracker.md written to ${join(projectRoot, "tracker.md")} — edit it, agents read it live.`);
95
+ } else {
96
+ ui.skip("tracker.md already present (left as-is).");
97
+ }
98
+
99
+ // 3. Slash commands.
100
+ const commands = installSlashCommands({ home, targetDir: projectRoot });
101
+ if (commands.installed.length > 0) {
102
+ ui.ok(
103
+ `Installed ${commands.installed.map((n) => `/${n.replace(/\.md$/, "")}`).join(" ")} into .claude/commands/.`,
104
+ );
105
+ }
106
+ if (commands.skipped.length > 0) {
107
+ ui.skip(
108
+ `Slash commands already present: ${commands.skipped.map((n) => `/${n.replace(/\.md$/, "")}`).join(" ")}.`,
109
+ );
110
+ }
111
+
112
+ // 4. Stop hook (user-level, once per machine).
113
+ const settingsPath = resolveUserSettingsPath();
114
+ const hook = installStopHook({
115
+ settingsPath,
116
+ hookCommand: join(home, "scripts", "stop-hook.sh"),
117
+ });
118
+ if (hook.status === "installed") {
119
+ ui.ok(`Token-tracking Stop hook installed into ${settingsPath}.`);
120
+ } else if (hook.status === "already-installed") {
121
+ ui.skip(`Stop hook already installed in ${settingsPath}.`);
122
+ } else {
123
+ ui.warn(`Could not install the Stop hook: ${hook.detail}`);
124
+ }
125
+
126
+ // 5. Project-scoped .mcp.json (fallback / connect path).
127
+ if (writeMcpJson) {
128
+ const mcpStatus = writeProjectMcpJson({ targetDir: projectRoot, home, agentName });
129
+ if (mcpStatus === "created" || mcpStatus === "added") {
130
+ ui.ok(`Pigeon MCP server ${mcpStatus === "created" ? "written to" : "added to"} ${join(projectRoot, ".mcp.json")}.`);
131
+ } else if (mcpStatus === "already-configured") {
132
+ ui.skip(".mcp.json already lists Pigeon (left as-is).");
133
+ } else {
134
+ ui.warn(`.mcp.json exists but isn't valid JSON — add this under "mcpServers" yourself:`);
135
+ console.log(manualMcpSnippet(home));
136
+ }
137
+ }
138
+
139
+ return { projectRoot, registered };
140
+ }
141
+
142
+ /**
143
+ * Print the CLAUDE.md snippet (derived from the live tool manifest by the
144
+ * home checkout — see scripts/print-connect-snippet.ts, #187). Non-fatal.
145
+ *
146
+ * @param {string} home
147
+ */
148
+ export function printConnectSnippet(home) {
149
+ if (!existsSync(join(home, "scripts", "print-connect-snippet.ts"))) return;
150
+ if (!commandOnPath("npx")) return;
151
+ console.log("");
152
+ console.log("Snippet for this project's CLAUDE.md / AGENTS.md:");
153
+ console.log("");
154
+ run("npx", ["tsx", "scripts/print-connect-snippet.ts"], { cwd: home, allowFailure: true });
155
+ }
package/lib/exec.mjs ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Thin child-process helpers — Node built-ins only.
3
+ */
4
+
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ /**
8
+ * Run a command synchronously.
9
+ *
10
+ * @param {string} cmd
11
+ * @param {string[]} args
12
+ * @param {{ cwd?: string, env?: NodeJS.ProcessEnv, capture?: boolean, allowFailure?: boolean }} [options]
13
+ * @returns {{ status: number | null, stdout: string, stderr: string }}
14
+ */
15
+ export function run(cmd, args, options = {}) {
16
+ const { cwd, env, capture = false, allowFailure = false } = options;
17
+ const result = spawnSync(cmd, args, {
18
+ cwd,
19
+ env: env ?? process.env,
20
+ stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
21
+ encoding: "utf8",
22
+ });
23
+ if (result.error) throw result.error;
24
+ if (result.status !== 0 && !allowFailure) {
25
+ const detail = capture && result.stderr ? `\n${result.stderr.trim()}` : "";
26
+ throw new Error(`\`${cmd} ${args.join(" ")}\` exited with code ${result.status}${detail}`);
27
+ }
28
+ return {
29
+ status: result.status,
30
+ stdout: result.stdout ?? "",
31
+ stderr: result.stderr ?? "",
32
+ };
33
+ }
34
+
35
+ /**
36
+ * True when `name` resolves on PATH.
37
+ *
38
+ * @param {string} name
39
+ */
40
+ export function commandOnPath(name) {
41
+ const probe = process.platform === "win32" ? "where" : "which";
42
+ const result = spawnSync(probe, [name], { stdio: "ignore" });
43
+ return result.status === 0;
44
+ }
package/lib/home.mjs ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The Pigeon home checkout — a full git clone the app actually runs from.
3
+ *
4
+ * `pigeon init` deliberately does NOT ship the app on npm: the launchd plist
5
+ * points at a directory, `pigeon-start.sh` execs `tsx` out of that
6
+ * directory's `node_modules`, and updates flow through `git pull` +
7
+ * `npm run service:update`. So this module's whole job is to make
8
+ * `~/.pigeon` (or `$PIGEON_HOME`) exist, be a Pigeon checkout, have deps
9
+ * installed, and have its SQLite schema migrated.
10
+ *
11
+ * Release-tag constraint (#314 Phase B): the default clone ref is the latest
12
+ * `vX.Y.Z` release tag, but releases up to and including v6.6.0 predate the
13
+ * native migrations helper (`scripts/db-migrate.ts`, Phase A). Rather than
14
+ * silently falling back to that checkout's legacy `db push` path — which the
15
+ * schema engine can't run against a DB any MCP server has touched — init
16
+ * fails with a message naming the fix (`--ref main` until the first
17
+ * post-v6.6.0 release is tagged).
18
+ */
19
+
20
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join, resolve } from "node:path";
23
+ import { run } from "./exec.mjs";
24
+
25
+ export const PIGEON_REPO_URL = "https://github.com/2nspired/pigeon";
26
+ export const MIGRATE_HELPER = "scripts/db-migrate.ts";
27
+ /** Newest release that does NOT support `pigeon init` (predates Phase A). */
28
+ export const LAST_UNSUPPORTED_RELEASE = "v6.6.0";
29
+
30
+ /** Package names the home checkout may carry (post/pre-rebrand). */
31
+ const PIGEON_PACKAGE_NAMES = new Set(["pigeon-mcp", "project-tracker"]);
32
+
33
+ /**
34
+ * Resolve the home checkout path: `--home` flag, then `PIGEON_HOME`, then
35
+ * `~/.pigeon`.
36
+ *
37
+ * @param {Record<string, string | undefined>} [env]
38
+ * @param {string | null} [homeFlag]
39
+ */
40
+ export function resolvePigeonHome(env = process.env, homeFlag = null) {
41
+ if (homeFlag) return resolve(homeFlag);
42
+ if (env.PIGEON_HOME) return resolve(env.PIGEON_HOME);
43
+ return join(homedir(), ".pigeon");
44
+ }
45
+
46
+ /**
47
+ * Release tags (`vX.Y.Z`, no pre-releases) out of `git ls-remote --tags`
48
+ * output. Peeled `^{}` refs are folded into their tag name.
49
+ *
50
+ * @param {string} lsRemoteOutput
51
+ * @returns {string[]}
52
+ */
53
+ export function parseReleaseTags(lsRemoteOutput) {
54
+ const tags = new Set();
55
+ for (const line of lsRemoteOutput.split("\n")) {
56
+ const match = /refs\/tags\/(v\d+\.\d+\.\d+)(\^\{\})?$/.exec(line.trim());
57
+ if (match) tags.add(match[1]);
58
+ }
59
+ return [...tags];
60
+ }
61
+
62
+ /**
63
+ * Numeric semver compare for `vX.Y.Z` tags. Positive when `a > b`.
64
+ *
65
+ * @param {string} a
66
+ * @param {string} b
67
+ */
68
+ export function compareReleaseTags(a, b) {
69
+ const pa = a.slice(1).split(".").map(Number);
70
+ const pb = b.slice(1).split(".").map(Number);
71
+ for (let i = 0; i < 3; i++) {
72
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
73
+ }
74
+ return 0;
75
+ }
76
+
77
+ /**
78
+ * Highest release tag, or null when none exist.
79
+ *
80
+ * @param {string[]} tags
81
+ * @returns {string | null}
82
+ */
83
+ export function latestReleaseTag(tags) {
84
+ if (tags.length === 0) return null;
85
+ return tags.reduce((best, tag) => (compareReleaseTags(tag, best) > 0 ? tag : best));
86
+ }
87
+
88
+ /**
89
+ * Classify what's at the home path. Pure given the answers, unit-tested via
90
+ * the wrapper below.
91
+ *
92
+ * @param {{ exists: boolean, empty: boolean, hasGitDir: boolean, packageName: string | null }} probe
93
+ * @returns {"clone" | "reuse" | "conflict"}
94
+ */
95
+ export function planHomeCheckout(probe) {
96
+ if (!probe.exists || probe.empty) return "clone";
97
+ if (probe.hasGitDir && probe.packageName !== null && PIGEON_PACKAGE_NAMES.has(probe.packageName)) {
98
+ return "reuse";
99
+ }
100
+ return "conflict";
101
+ }
102
+
103
+ /**
104
+ * @param {string} home
105
+ * @returns {"clone" | "reuse" | "conflict"}
106
+ */
107
+ export function inspectHome(home) {
108
+ const exists = existsSync(home);
109
+ let empty = false;
110
+ let hasGitDir = false;
111
+ let packageName = null;
112
+ if (exists) {
113
+ empty = readdirSync(home).length === 0;
114
+ hasGitDir = existsSync(join(home, ".git"));
115
+ const packageJson = join(home, "package.json");
116
+ if (existsSync(packageJson)) {
117
+ try {
118
+ const parsed = JSON.parse(readFileSync(packageJson, "utf8"));
119
+ if (typeof parsed?.name === "string") packageName = parsed.name;
120
+ } catch {
121
+ // Unreadable package.json → treated as a conflict below.
122
+ }
123
+ }
124
+ }
125
+ return planHomeCheckout({ exists, empty, hasGitDir, packageName });
126
+ }
127
+
128
+ /**
129
+ * Ensure the home checkout exists. Returns what happened plus the ref used.
130
+ *
131
+ * @param {{ home: string, ref: string | null, repoUrl?: string }} options
132
+ * @returns {{ status: "cloned" | "reused", ref: string | null }}
133
+ */
134
+ export function ensureHomeCheckout({ home, ref, repoUrl = PIGEON_REPO_URL }) {
135
+ const plan = inspectHome(home);
136
+
137
+ if (plan === "reuse") {
138
+ return { status: "reused", ref: null };
139
+ }
140
+ if (plan === "conflict") {
141
+ throw new Error(
142
+ `${home} exists but is not a Pigeon checkout. ` +
143
+ `Move it aside, or point --home / PIGEON_HOME somewhere else.`,
144
+ );
145
+ }
146
+
147
+ let cloneRef = ref;
148
+ if (!cloneRef) {
149
+ const lsRemote = run("git", ["ls-remote", "--tags", repoUrl], { capture: true });
150
+ cloneRef = latestReleaseTag(parseReleaseTags(lsRemote.stdout));
151
+ if (!cloneRef) {
152
+ throw new Error(
153
+ `No release tags found at ${repoUrl}. Pass --ref <branch|tag> (e.g. --ref main).`,
154
+ );
155
+ }
156
+ }
157
+
158
+ run("git", ["clone", "--depth", "1", "--branch", cloneRef, repoUrl, home]);
159
+ return { status: "cloned", ref: cloneRef };
160
+ }
161
+
162
+ /**
163
+ * Error text for a checkout that predates the Phase-A migrations helper.
164
+ * Exported so the message stays pinned by a unit test.
165
+ *
166
+ * @param {string} home
167
+ * @param {string | null} ref
168
+ */
169
+ export function unsupportedCheckoutMessage(home, ref) {
170
+ const refLabel = ref ? `ref ${ref}` : "this checkout";
171
+ return (
172
+ `Pigeon at ${home} (${refLabel}) predates \`pigeon init\` — it has no ` +
173
+ `${MIGRATE_HELPER}, which ships in releases after ${LAST_UNSUPPORTED_RELEASE}. ` +
174
+ `Re-run with \`npx @2nspired/pigeon init --ref main\` (or a newer release tag once one exists), ` +
175
+ `or update the checkout: cd ${home} && git pull.`
176
+ );
177
+ }
178
+
179
+ /**
180
+ * `npm install` in the home checkout (idempotent; Prisma's postinstall
181
+ * regenerates the client).
182
+ *
183
+ * @param {string} home
184
+ */
185
+ export function installHomeDeps(home) {
186
+ run("npm", ["install"], { cwd: home });
187
+ }
188
+
189
+ /**
190
+ * Apply pending migrations by invoking the Phase-A helper from the home
191
+ * checkout. Throws with a named minimum release when the checkout predates
192
+ * the helper (see module docs).
193
+ *
194
+ * @param {{ home: string, ref: string | null }} options
195
+ */
196
+ export function applyHomeMigrations({ home, ref }) {
197
+ if (!existsSync(join(home, MIGRATE_HELPER))) {
198
+ throw new Error(unsupportedCheckoutMessage(home, ref));
199
+ }
200
+ run("npx", ["tsx", MIGRATE_HELPER], { cwd: home });
201
+ }
package/lib/init.mjs ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `pigeon init` — the whole install, in order, zero questions:
3
+ *
4
+ * a. Home checkout at ~/.pigeon (PIGEON_HOME / --home; latest release tag,
5
+ * --ref override)
6
+ * b. npm install in the checkout
7
+ * c. Apply DB migrations via the checkout's own Phase-A helper
8
+ * d. Register the MCP server with Claude Code (user scope via
9
+ * `claude mcp add`; project .mcp.json fallback)
10
+ * e. Inside a project repo: repo registration + starter tracker.md +
11
+ * slash commands + Stop hook
12
+ * f. macOS: launchd service install (opt out with --no-service)
13
+ * g. Doctor pass + colophon summary
14
+ */
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join, resolve } from "node:path";
19
+ import { connectProject, detectGitRoot, printConnectSnippet } from "./connect.mjs";
20
+ import { commandOnPath, run } from "./exec.mjs";
21
+ import {
22
+ applyHomeMigrations,
23
+ ensureHomeCheckout,
24
+ installHomeDeps,
25
+ resolvePigeonHome,
26
+ } from "./home.mjs";
27
+ import { buildClaudeMcpAddArgs, buildClaudeMcpGetArgs } from "./mcp-config.mjs";
28
+ import * as ui from "./ui.mjs";
29
+
30
+ const SERVICE_PLIST = "Library/LaunchAgents/com.2nspired.pigeon.plist";
31
+ const BOARD_URL = "http://localhost:3100";
32
+
33
+ /**
34
+ * Register the MCP server in Claude Code user scope. Returns true when user
35
+ * scope now covers this machine (so no project `.mcp.json` is needed).
36
+ *
37
+ * @param {{ home: string, agentName: string, useClaude: boolean }} options
38
+ */
39
+ export function registerUserScope({ home, agentName, useClaude }) {
40
+ if (!useClaude) {
41
+ ui.skip("Claude Code user-scope registration skipped (--no-claude).");
42
+ return false;
43
+ }
44
+ if (!commandOnPath("claude")) {
45
+ ui.skip("`claude` not on PATH — falling back to a project-scoped .mcp.json.");
46
+ return false;
47
+ }
48
+
49
+ const probe = run("claude", buildClaudeMcpGetArgs(), { capture: true, allowFailure: true });
50
+ if (probe.status === 0) {
51
+ ui.skip("Pigeon is already registered with Claude Code.");
52
+ return true;
53
+ }
54
+
55
+ const add = run("claude", buildClaudeMcpAddArgs(home, agentName), {
56
+ capture: true,
57
+ allowFailure: true,
58
+ });
59
+ if (add.status === 0) {
60
+ ui.ok("Registered the Pigeon MCP server with Claude Code (user scope).");
61
+ return true;
62
+ }
63
+ ui.warn(`\`claude mcp add\` failed — falling back to a project-scoped .mcp.json.`);
64
+ if (add.stderr.trim()) ui.info(add.stderr.trim());
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * @param {import("./args.mjs").CliArgs} args
70
+ * @param {{ cwd?: string, platform?: NodeJS.Platform }} [context]
71
+ */
72
+ export function runInit(args, context = {}) {
73
+ const cwd = context.cwd ?? process.cwd();
74
+ const platform = context.platform ?? process.platform;
75
+ const home = resolvePigeonHome(process.env, args.home);
76
+
77
+ // ─── a. Home checkout ────────────────────────────────────────────
78
+ ui.step("1/6 · Pigeon home checkout");
79
+ const checkout = ensureHomeCheckout({ home, ref: args.ref });
80
+ if (checkout.status === "cloned") {
81
+ ui.ok(`Cloned ${checkout.ref} into ${home}.`);
82
+ } else {
83
+ ui.skip(`Using the existing checkout at ${home}.`);
84
+ ui.info(`Update it any time: cd ${home} && git pull && npm run service:update`);
85
+ }
86
+
87
+ // ─── b. Dependencies ─────────────────────────────────────────────
88
+ ui.step("2/6 · Dependencies");
89
+ installHomeDeps(home);
90
+ ui.ok("npm install complete (Prisma client generated).");
91
+
92
+ // ─── c. Database migrations ──────────────────────────────────────
93
+ ui.step("3/6 · Database");
94
+ applyHomeMigrations({ home, ref: args.ref ?? checkout.ref });
95
+ ui.ok("Database ready.");
96
+
97
+ // ─── d. MCP registration ─────────────────────────────────────────
98
+ ui.step("4/6 · Claude Code");
99
+ const userScope = registerUserScope({
100
+ home,
101
+ agentName: args.agentName,
102
+ useClaude: args.claude,
103
+ });
104
+
105
+ // ─── e. Project connection ───────────────────────────────────────
106
+ ui.step("5/6 · This project");
107
+ const gitRoot = detectGitRoot(cwd);
108
+ const insideHome = gitRoot !== null && resolve(gitRoot) === resolve(home);
109
+ let project = null;
110
+ if (gitRoot && !insideHome) {
111
+ project = connectProject({
112
+ home,
113
+ targetDir: cwd,
114
+ agentName: args.agentName,
115
+ register: args.register,
116
+ writeMcpJson: !userScope,
117
+ });
118
+ } else if (insideHome) {
119
+ ui.skip("You're inside the Pigeon checkout itself — no project to connect here.");
120
+ } else {
121
+ ui.skip("Not inside a git repo — no project connected.");
122
+ ui.info("Later, from any project: npx @2nspired/pigeon connect");
123
+ }
124
+ if (!userScope && !project) {
125
+ ui.info("No .mcp.json written either — run `connect` from a project to get one.");
126
+ }
127
+
128
+ // ─── f. Background service ───────────────────────────────────────
129
+ ui.step("6/6 · Board service");
130
+ if (platform !== "darwin") {
131
+ ui.skip("Background service is macOS-only on this release.");
132
+ ui.info(`Run the board in the foreground: cd ${home} && npm run dev`);
133
+ } else if (!args.service) {
134
+ ui.skip("Service install skipped (--no-service).");
135
+ ui.info(`Install it later: cd ${home} && npm run service:install`);
136
+ } else if (existsSync(join(homedir(), SERVICE_PLIST))) {
137
+ ui.skip(`Service already installed — board at ${BOARD_URL}.`);
138
+ } else {
139
+ run("npm", ["run", "service:install"], { cwd: home });
140
+ ui.ok(`Board running at ${BOARD_URL} (launchd keeps it alive).`);
141
+ }
142
+
143
+ // ─── g. Doctor + colophon ────────────────────────────────────────
144
+ if (existsSync(join(home, "scripts", "doctor.ts"))) {
145
+ const doctor = run("npx", ["tsx", "scripts/doctor.ts"], { cwd: home, allowFailure: true });
146
+ if (doctor.status !== 0) {
147
+ ui.warn("Doctor flagged issues above — each comes with a copy-pasteable fix.");
148
+ }
149
+ }
150
+
151
+ if (project) printConnectSnippet(home);
152
+
153
+ printColophon({ home, platform, project, service: args.service });
154
+ }
155
+
156
+ /**
157
+ * Closing summary in colophon voice (#310): brief, warm, factual.
158
+ *
159
+ * @param {{ home: string, platform: NodeJS.Platform, project: { projectRoot: string | null, registered: boolean } | null, service: boolean }} options
160
+ */
161
+ function printColophon({ home, platform, project, service }) {
162
+ const line = "─".repeat(56);
163
+ console.log("");
164
+ console.log(ui.color("dim", line));
165
+ console.log(`Pigeon lives in ${home}.`);
166
+ if (platform === "darwin" && service) {
167
+ console.log(`The board is at ${BOARD_URL} — it survives reboots.`);
168
+ }
169
+ if (project?.registered) {
170
+ console.log("This repo is registered; briefMe() will find it by path.");
171
+ console.log('Open Claude Code here and say "brief me" to start.');
172
+ } else if (project) {
173
+ console.log("This repo is connected. Register it any time with /brief-me.");
174
+ }
175
+ console.log(`Update later: cd ${home} && git pull && npm run service:update`);
176
+ console.log(ui.color("dim", line));
177
+ console.log("");
178
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * MCP registration — the ONE implementation of `.mcp.json` writing (used by
3
+ * `pigeon init`, `pigeon connect`, `scripts/connect.sh`, and
4
+ * `scripts/setup.mts`) plus the `claude mcp add` command construction for the
5
+ * preferred user-scope path.
6
+ *
7
+ * Per #154: NEVER hand-edit `~/.claude.json`. User-scope registration always
8
+ * shells out to the `claude` CLI; the only file this module writes is a
9
+ * project-scoped `.mcp.json`, which is a documented, project-owned surface.
10
+ */
11
+
12
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ export const MCP_SERVER_KEY = "pigeon";
16
+ /** Pre-rebrand key — presence means "already configured", never rewritten here. */
17
+ export const LEGACY_MCP_KEY = "project-tracker";
18
+
19
+ /**
20
+ * Canonical MCP launch command for a home checkout.
21
+ *
22
+ * @param {string} home
23
+ */
24
+ export function pigeonStartCommand(home) {
25
+ return join(home, "scripts", "pigeon-start.sh");
26
+ }
27
+
28
+ /**
29
+ * The `.mcp.json` server entry — same shape `scripts/connect.sh` has always
30
+ * written.
31
+ *
32
+ * @param {string} home
33
+ * @param {string} [agentName]
34
+ */
35
+ export function pigeonServerEntry(home, agentName = "Claude") {
36
+ return {
37
+ command: pigeonStartCommand(home),
38
+ args: [],
39
+ env: { AGENT_NAME: agentName },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * argv (after the binary name) for user-scope registration via the Claude
45
+ * Code CLI: `claude mcp add --scope user … pigeon -- <pigeon-start.sh>`.
46
+ *
47
+ * @param {string} home
48
+ * @param {string} [agentName]
49
+ * @returns {string[]}
50
+ */
51
+ export function buildClaudeMcpAddArgs(home, agentName = "Claude") {
52
+ return [
53
+ "mcp",
54
+ "add",
55
+ "--scope",
56
+ "user",
57
+ "--env",
58
+ `AGENT_NAME=${agentName}`,
59
+ MCP_SERVER_KEY,
60
+ "--",
61
+ pigeonStartCommand(home),
62
+ ];
63
+ }
64
+
65
+ /** argv to probe whether the server is already registered with Claude Code. */
66
+ export function buildClaudeMcpGetArgs() {
67
+ return ["mcp", "get", MCP_SERVER_KEY];
68
+ }
69
+
70
+ /**
71
+ * Decide what to do with a project's `.mcp.json`. Pure — `rawText` is the
72
+ * current file contents or null when the file doesn't exist.
73
+ *
74
+ * @param {string | null} rawText
75
+ * @param {object} entry Server entry from {@link pigeonServerEntry}.
76
+ * @returns {{ status: "create" | "add" | "already-configured" | "unparseable", json?: object }}
77
+ */
78
+ export function planMcpJsonUpdate(rawText, entry) {
79
+ if (rawText === null || rawText.trim() === "") {
80
+ return { status: "create", json: { mcpServers: { [MCP_SERVER_KEY]: entry } } };
81
+ }
82
+
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(rawText);
86
+ } catch {
87
+ return { status: "unparseable" };
88
+ }
89
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
90
+ return { status: "unparseable" };
91
+ }
92
+
93
+ const servers = parsed.mcpServers;
94
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
95
+ if (MCP_SERVER_KEY in servers || LEGACY_MCP_KEY in servers) {
96
+ return { status: "already-configured" };
97
+ }
98
+ }
99
+
100
+ const json = {
101
+ ...parsed,
102
+ mcpServers: {
103
+ ...(servers && typeof servers === "object" && !Array.isArray(servers) ? servers : {}),
104
+ [MCP_SERVER_KEY]: entry,
105
+ },
106
+ };
107
+ return { status: "add", json };
108
+ }
109
+
110
+ /**
111
+ * Create or merge `<targetDir>/.mcp.json` with the Pigeon server entry.
112
+ * Atomic write (tempfile + rename); preserves every other key in the file.
113
+ *
114
+ * @param {{ targetDir: string, home: string, agentName?: string }} options
115
+ * @returns {"created" | "added" | "already-configured" | "unparseable"}
116
+ */
117
+ export function writeProjectMcpJson({ targetDir, home, agentName = "Claude" }) {
118
+ const mcpFile = join(targetDir, ".mcp.json");
119
+ const rawText = existsSync(mcpFile) ? readFileSync(mcpFile, "utf8") : null;
120
+ const plan = planMcpJsonUpdate(rawText, pigeonServerEntry(home, agentName));
121
+
122
+ if (plan.status === "create" || plan.status === "add") {
123
+ const tmp = `${mcpFile}.pigeon.tmp`;
124
+ writeFileSync(tmp, `${JSON.stringify(plan.json, null, 2)}\n`, "utf8");
125
+ renameSync(tmp, mcpFile);
126
+ return plan.status === "create" ? "created" : "added";
127
+ }
128
+ return plan.status;
129
+ }
130
+
131
+ /**
132
+ * Manual snippet for the unparseable-`.mcp.json` case.
133
+ *
134
+ * @param {string} home
135
+ */
136
+ export function manualMcpSnippet(home) {
137
+ return JSON.stringify({ [MCP_SERVER_KEY]: pigeonServerEntry(home) }, null, 2);
138
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Stop-hook install — the ONE implementation (port of the inline Node script
3
+ * that used to live in `scripts/connect.sh`, #217).
4
+ *
5
+ * Wires Pigeon's token-tracking Stop hook into the user-level Claude Code
6
+ * `settings.json` once per machine. Idempotent: an existing hook whose
7
+ * command ends in `stop-hook.sh` (any install path) is recognized and left
8
+ * alone; everything else in the file is preserved.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+
15
+ /**
16
+ * User-level settings.json to install the hook into. Order:
17
+ * 1. `PIGEON_USER_SETTINGS` — explicit override; also the test/CI escape
18
+ * hatch so dry-runs never mutate a real settings file (#217).
19
+ * 2. `$CLAUDE_CONFIG_DIR/settings.json` when the user relocated their
20
+ * Claude config.
21
+ * 3. `~/.claude-alt/settings.json` when that directory exists (mirrors
22
+ * `resolveClaudeConfigPaths` in src/lib/doctor/config-paths.ts).
23
+ * 4. `~/.claude/settings.json` — the default.
24
+ *
25
+ * @param {Record<string, string | undefined>} [env]
26
+ * @param {string} [home] Home directory override for tests.
27
+ */
28
+ export function resolveUserSettingsPath(env = process.env, home = homedir()) {
29
+ if (env.PIGEON_USER_SETTINGS) return resolve(env.PIGEON_USER_SETTINGS);
30
+ if (env.CLAUDE_CONFIG_DIR) return resolve(env.CLAUDE_CONFIG_DIR, "settings.json");
31
+ const claudeAlt = join(home, ".claude-alt");
32
+ if (existsSync(claudeAlt)) return join(claudeAlt, "settings.json");
33
+ return join(home, ".claude", "settings.json");
34
+ }
35
+
36
+ /**
37
+ * True when the settings object already carries a Pigeon Stop hook. Matches
38
+ * by suffix so moved checkouts are still recognized — mirrors
39
+ * `configHasTokenHook()` in src/server/services/token-usage-service.ts.
40
+ *
41
+ * @param {unknown} settings
42
+ */
43
+ export function hasPigeonStopHook(settings) {
44
+ if (settings === null || typeof settings !== "object") return false;
45
+ const stop = /** @type {{ hooks?: { Stop?: unknown } }} */ (settings).hooks?.Stop;
46
+ if (!Array.isArray(stop)) return false;
47
+ for (const group of stop) {
48
+ const inner = group?.hooks;
49
+ if (!Array.isArray(inner)) continue;
50
+ for (const hook of inner) {
51
+ if (
52
+ hook &&
53
+ typeof hook === "object" &&
54
+ hook.type === "command" &&
55
+ typeof hook.command === "string" &&
56
+ (hook.command.endsWith("/stop-hook.sh") || hook.command.endsWith("\\stop-hook.sh"))
57
+ ) {
58
+ return true;
59
+ }
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ /**
66
+ * Merge the Pigeon Stop hook into a settings object. Pure: returns a new
67
+ * object; `changed: false` means the hook was already present.
68
+ *
69
+ * @param {Record<string, unknown>} settings
70
+ * @param {string} hookCommand Absolute path to `<home>/scripts/stop-hook.sh`.
71
+ * @returns {{ changed: boolean, settings: Record<string, unknown> }}
72
+ */
73
+ export function mergeStopHook(settings, hookCommand) {
74
+ if (hasPigeonStopHook(settings)) return { changed: false, settings };
75
+
76
+ const hooks =
77
+ settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
78
+ ? { .../** @type {Record<string, unknown>} */ (settings.hooks) }
79
+ : {};
80
+ const stop = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
81
+ stop.push({ hooks: [{ type: "command", command: hookCommand }] });
82
+ hooks.Stop = stop;
83
+
84
+ return { changed: true, settings: { ...settings, hooks } };
85
+ }
86
+
87
+ /**
88
+ * Install the Stop hook into `settingsPath` (atomic tempfile + rename).
89
+ *
90
+ * @param {{ settingsPath: string, hookCommand: string }} options
91
+ * @returns {{ status: "installed" | "already-installed" | "error", detail?: string }}
92
+ */
93
+ export function installStopHook({ settingsPath, hookCommand }) {
94
+ let existing = {};
95
+ if (existsSync(settingsPath)) {
96
+ try {
97
+ const raw = readFileSync(settingsPath, "utf8");
98
+ if (raw.trim().length > 0) existing = JSON.parse(raw);
99
+ } catch (err) {
100
+ return { status: "error", detail: `cannot parse ${settingsPath}: ${err.message}` };
101
+ }
102
+ if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
103
+ return { status: "error", detail: `${settingsPath} is not a JSON object — left untouched` };
104
+ }
105
+ }
106
+
107
+ const { changed, settings } = mergeStopHook(existing, hookCommand);
108
+ if (!changed) return { status: "already-installed" };
109
+
110
+ mkdirSync(dirname(settingsPath), { recursive: true });
111
+ const tmp = `${settingsPath}.pigeon.tmp`;
112
+ writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
113
+ renameSync(tmp, settingsPath);
114
+ return { status: "installed" };
115
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Starter `tracker.md` — the runtime board-policy surface, hot-reloaded on
3
+ * every MCP call. The template ships the day-one schema (schema_version 1)
4
+ * with the optional keys present as commented examples, so a human can turn
5
+ * policy on by uncommenting rather than by reading docs first.
6
+ *
7
+ * Must stay parseable by `loadTrackerPolicy` in
8
+ * src/lib/services/tracker-policy.ts (front-matter + body).
9
+ */
10
+
11
+ import { existsSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ /**
15
+ * Same slug shape `scripts/register-repo.ts` derives from the project name.
16
+ *
17
+ * @param {string} name
18
+ */
19
+ export function slugify(name) {
20
+ return name
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, "-")
23
+ .replace(/^-|-$/g, "");
24
+ }
25
+
26
+ /**
27
+ * @param {string} projectName
28
+ * @returns {string}
29
+ */
30
+ export function starterTrackerMd(projectName) {
31
+ const slug = slugify(projectName) || "my-project";
32
+ return `---
33
+ schema_version: 1
34
+ project_slug: ${slug}
35
+ # Require a short \`intent\` (the "why", ≤120 chars) on these MCP tools:
36
+ # intent_required_on:
37
+ # - moveCard
38
+ # - deleteCard
39
+ # Per-column guidance agents see when moving cards:
40
+ # columns:
41
+ # "In Progress":
42
+ # prompt: Move a card here before starting work on it, with a short intent.
43
+ ---
44
+
45
+ ${projectName} — describe the project in a few sentences: what it is, what
46
+ "done" looks like, and anything an AI agent should know before touching the
47
+ board. Agents read this file on every MCP call, so edits take effect
48
+ immediately. Keep it short; the board itself carries the work.
49
+ `;
50
+ }
51
+
52
+ /**
53
+ * Write a starter `tracker.md` at the project root if absent.
54
+ *
55
+ * @param {{ targetDir: string, projectName: string }} options
56
+ * @returns {"created" | "exists"}
57
+ */
58
+ export function writeStarterTrackerMd({ targetDir, projectName }) {
59
+ const path = join(targetDir, "tracker.md");
60
+ if (existsSync(path)) return "exists";
61
+ writeFileSync(path, starterTrackerMd(projectName), "utf8");
62
+ return "created";
63
+ }
package/lib/ui.mjs ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Terminal output helpers — mirrors the palette in `scripts/doctor.ts` so the
3
+ * CLI and the doctor pass read as one voice.
4
+ */
5
+
6
+ const COLORS = {
7
+ reset: "\x1b[0m",
8
+ dim: "\x1b[2m",
9
+ bold: "\x1b[1m",
10
+ green: "\x1b[32m",
11
+ red: "\x1b[31m",
12
+ yellow: "\x1b[33m",
13
+ cyan: "\x1b[36m",
14
+ gray: "\x1b[90m",
15
+ };
16
+
17
+ const noColor = process.env.NO_COLOR === "1" || !process.stdout.isTTY;
18
+
19
+ /**
20
+ * @param {keyof typeof COLORS} c
21
+ * @param {string} s
22
+ */
23
+ export function color(c, s) {
24
+ return noColor ? s : `${COLORS[c]}${s}${COLORS.reset}`;
25
+ }
26
+
27
+ /** @param {string} title */
28
+ export function step(title) {
29
+ console.log("");
30
+ console.log(color("bold", title));
31
+ }
32
+
33
+ /** @param {string} message */
34
+ export function ok(message) {
35
+ console.log(`${color("green", "✓")} ${message}`);
36
+ }
37
+
38
+ /** @param {string} message */
39
+ export function skip(message) {
40
+ console.log(`${color("gray", "·")} ${message}`);
41
+ }
42
+
43
+ /** @param {string} message */
44
+ export function warn(message) {
45
+ console.log(`${color("yellow", "!")} ${message}`);
46
+ }
47
+
48
+ /** @param {string} message */
49
+ export function info(message) {
50
+ console.log(` ${color("dim", message)}`);
51
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@2nspired/pigeon",
3
+ "version": "0.1.0",
4
+ "description": "One-command installer for Pigeon — a local-first kanban board + MCP server for AI-assisted development. Run `npx @2nspired/pigeon init`.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "pigeon": "bin/pigeon.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/2nspired/pigeon.git",
21
+ "directory": "cli"
22
+ },
23
+ "homepage": "https://2nspired.github.io/pigeon/",
24
+ "bugs": {
25
+ "url": "https://github.com/2nspired/pigeon/issues"
26
+ },
27
+ "keywords": [
28
+ "pigeon",
29
+ "kanban",
30
+ "mcp",
31
+ "claude-code",
32
+ "ai-agents",
33
+ "local-first"
34
+ ]
35
+ }