@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 +30 -0
- package/bin/pigeon.mjs +64 -0
- package/lib/args.mjs +111 -0
- package/lib/commands.mjs +54 -0
- package/lib/connect.mjs +155 -0
- package/lib/exec.mjs +44 -0
- package/lib/home.mjs +201 -0
- package/lib/init.mjs +178 -0
- package/lib/mcp-config.mjs +138 -0
- package/lib/stop-hook.mjs +115 -0
- package/lib/tracker-template.mjs +63 -0
- package/lib/ui.mjs +51 -0
- package/package.json +35 -0
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
|
+
}
|
package/lib/commands.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/connect.mjs
ADDED
|
@@ -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
|
+
}
|