chapterhouse 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/setup.js CHANGED
@@ -1,7 +1,8 @@
1
- import * as readline from "readline";
2
- import { existsSync, readFileSync, writeFileSync } from "fs";
1
+ import * as readline from "node:readline";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { CopilotClient } from "@github/copilot-sdk";
4
- import { ensureChapterhouseHome, ENV_PATH, CHAPTERHOUSE_HOME } from "./paths.js";
5
+ import { CHAPTERHOUSE_HOME, ensureChapterhouseHome, ENV_PATH, WIKI_DIR } from "./paths.js";
5
6
  import { getExampleProjectPath } from "./home-path.js";
6
7
  const BOLD = "\x1b[1m";
7
8
  const DIM = "\x1b[2m";
@@ -21,11 +22,11 @@ async function fetchModels() {
21
22
  await client.start();
22
23
  const models = await client.listModels();
23
24
  return models
24
- .filter((m) => m.policy?.state === "enabled" && !m.name.includes("(Internal only)"))
25
- .map((m) => {
26
- const mult = m.billing?.multiplier;
25
+ .filter((model) => model.policy?.state === "enabled" && !model.name.includes("(Internal only)"))
26
+ .map((model) => {
27
+ const mult = model.billing?.multiplier;
27
28
  const desc = mult === 0 || mult === undefined ? "Included with Copilot" : `Premium (${mult}x)`;
28
- return { id: m.id, label: m.name, desc };
29
+ return { id: model.id, label: model.name, desc };
29
30
  });
30
31
  }
31
32
  catch {
@@ -35,7 +36,9 @@ async function fetchModels() {
35
36
  try {
36
37
  await client?.stop();
37
38
  }
38
- catch { /* best-effort */ }
39
+ catch {
40
+ // best-effort cleanup
41
+ }
39
42
  }
40
43
  }
41
44
  function ask(rl, question) {
@@ -43,107 +46,161 @@ function ask(rl, question) {
43
46
  }
44
47
  async function askPicker(rl, label, options, defaultId) {
45
48
  console.log(`${BOLD}${label}${RESET}\n`);
46
- const defaultIdx = Math.max(0, options.findIndex((o) => o.id === defaultId));
47
- for (let i = 0; i < options.length; i++) {
48
- const marker = i === defaultIdx ? `${GREEN}▸${RESET}` : " ";
49
- const tag = i === defaultIdx ? ` ${DIM}(default)${RESET}` : "";
50
- console.log(` ${marker} ${CYAN}${i + 1}${RESET} ${options[i].label}${tag}`);
51
- console.log(` ${DIM}${options[i].desc}${RESET}`);
49
+ const defaultIdx = Math.max(0, options.findIndex((option) => option.id === defaultId));
50
+ for (let index = 0; index < options.length; index++) {
51
+ const option = options[index];
52
+ const marker = index === defaultIdx ? `${GREEN}▸${RESET}` : " ";
53
+ const tag = index === defaultIdx ? ` ${DIM}(default)${RESET}` : "";
54
+ console.log(` ${marker} ${CYAN}${index + 1}${RESET} ${option.label}${tag}`);
55
+ console.log(` ${DIM}${option.desc}${RESET}`);
52
56
  }
53
57
  console.log();
54
58
  const input = await ask(rl, ` Pick a number ${DIM}(1-${options.length}, Enter for default)${RESET}: `);
55
- const num = parseInt(input.trim(), 10);
56
- if (num >= 1 && num <= options.length)
59
+ const num = Number.parseInt(input.trim(), 10);
60
+ if (num >= 1 && num <= options.length) {
57
61
  return options[num - 1].id;
62
+ }
58
63
  return options[defaultIdx].id;
59
64
  }
60
- async function main() {
65
+ function readExistingEnv() {
66
+ const lines = existsSync(ENV_PATH)
67
+ ? readFileSync(ENV_PATH, "utf-8").split("\n").filter((line) => line.length > 0)
68
+ : [];
69
+ const values = {};
70
+ for (const line of lines) {
71
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
72
+ if (match) {
73
+ values[match[1]] = match[2];
74
+ }
75
+ }
76
+ return { lines, values };
77
+ }
78
+ function resolveSetupMode(existing) {
79
+ const configured = process.env.CHAPTERHOUSE_MODE?.trim() || existing.CHAPTERHOUSE_MODE?.trim() || "personal";
80
+ return configured === "team" ? "team" : "personal";
81
+ }
82
+ function upsertEnvLines(lines, updates) {
83
+ const pending = new Map(Object.entries(updates)
84
+ .filter(([, value]) => value.trim().length > 0));
85
+ const nextLines = lines.map((line) => {
86
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
87
+ if (!match) {
88
+ return line;
89
+ }
90
+ const key = match[1];
91
+ const replacement = pending.get(key);
92
+ if (replacement === undefined) {
93
+ return line;
94
+ }
95
+ pending.delete(key);
96
+ return `${key}=${replacement}`;
97
+ });
98
+ for (const [key, value] of pending) {
99
+ nextLines.push(`${key}=${value}`);
100
+ }
101
+ return nextLines;
102
+ }
103
+ function hasTokenEnv() {
104
+ return Boolean(process.env.GITHUB_TOKEN?.trim() || process.env.COPILOT_TOKEN?.trim());
105
+ }
106
+ function hasGhAuth() {
107
+ try {
108
+ execFileSync("gh", ["auth", "status"], { stdio: "ignore" });
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ async function showWikiLocation(rl) {
116
+ console.log(`${BOLD}Personal wiki location${RESET}`);
117
+ console.log(` ${CYAN}${WIKI_DIR}${RESET}`);
118
+ console.log(`${DIM} Chapterhouse stores your local wiki here.${RESET}\n`);
119
+ await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
120
+ console.log();
121
+ }
122
+ export async function runSetup() {
61
123
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
62
- console.log(`
124
+ try {
125
+ console.log(`
63
126
  ${BOLD}╔══════════════════════════════════════════╗
64
127
  ║ 🤖 Chapterhouse Setup ║
65
128
  ╚══════════════════════════════════════════╝${RESET}
66
129
  `);
67
- console.log(`${DIM}Config directory: ${CHAPTERHOUSE_HOME}${RESET}\n`);
68
- ensureChapterhouseHome();
69
- // Load existing values if any
70
- const existing = {};
71
- if (existsSync(ENV_PATH)) {
72
- for (const line of readFileSync(ENV_PATH, "utf-8").split("\n")) {
73
- const match = line.match(/^([A-Z_]+)=(.*)$/);
74
- if (match)
75
- existing[match[1]] = match[2];
130
+ console.log(`${DIM}Config directory: ${CHAPTERHOUSE_HOME}${RESET}\n`);
131
+ ensureChapterhouseHome();
132
+ const existing = readExistingEnv();
133
+ const mode = resolveSetupMode(existing.values);
134
+ console.log(`${BOLD}${mode === "personal" ? "Personal setup" : "Team setup"}${RESET}`);
135
+ if (mode === "personal") {
136
+ console.log("We'll get your GitHub/Copilot auth, preferred model, and personal wiki location dialed in.");
137
+ console.log(`${DIM}Team-only integrations stay out of the way in personal mode.${RESET}`);
76
138
  }
77
- }
78
- // ── What is Chapterhouse ──────────────────────────────────────────
79
- console.log(`${BOLD}Meet Chapterhouse${RESET}`);
80
- console.log(`Chapterhouse is your team-level AI assistant — an always-on daemon that runs on`);
81
- console.log(`your machine. Open the web UI in your browser and talk in plain English;`);
82
- console.log(`Chapterhouse handles the rest.`);
83
- console.log();
84
- console.log(`${CYAN}What Chapterhouse can do out of the box:${RESET}`);
85
- console.log(` • Have conversations and answer questions`);
86
- console.log(` Spin up Copilot CLI sessions to code, debug, and run commands`);
87
- console.log(` • Manage multiple background tasks simultaneously`);
88
- console.log(` • Maintain a shared engineering wiki of everything it learns about your team`);
89
- console.log();
90
- console.log(`${CYAN}Skills — teach Chapterhouse anything:${RESET}`);
91
- console.log(` Chapterhouse has a skill system that lets it learn new capabilities. There's`);
92
- console.log(` an open source library of community skills it can install, or it can`);
93
- console.log(` write its own from scratch.`);
94
- console.log();
95
- await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
96
- console.log();
97
- // ── Model picker ─────────────────────────────────────────
98
- console.log(`\n${BOLD}━━━ Default Model ━━━${RESET}\n`);
99
- console.log(`${DIM}Fetching available models from Copilot...${RESET}`);
100
- let models = await fetchModels();
101
- if (models.length === 0) {
102
- console.log(`${YELLOW} Could not fetch models (Copilot CLI may not be authenticated yet).${RESET}`);
103
- console.log(`${DIM} Showing a curated list — you can switch anytime after setup.${RESET}\n`);
104
- models = FALLBACK_MODELS;
105
- }
106
- else {
107
- console.log(`${GREEN} ✓ Found ${models.length} models${RESET}\n`);
108
- }
109
- console.log(`${DIM}You can switch models anytime in the web UI's Settings page.${RESET}\n`);
110
- const currentModel = existing.COPILOT_MODEL || "claude-sonnet-4.6";
111
- const model = await askPicker(rl, "Choose a default model:", models, currentModel);
112
- const modelLabel = models.find((m) => m.id === model)?.label || model;
113
- console.log(`\n${GREEN} ✓ Using ${modelLabel}${RESET}\n`);
114
- // ── Write config ─────────────────────────────────────────
115
- const apiPort = existing.API_PORT || "7788";
116
- const lines = [];
117
- lines.push(`API_PORT=${apiPort}`);
118
- lines.push(`COPILOT_MODEL=${model}`);
119
- writeFileSync(ENV_PATH, lines.join("\n") + "\n");
120
- // ── Done ─────────────────────────────────────────────────
121
- console.log(`
139
+ else {
140
+ console.log("We'll keep the shared team configuration intact and confirm your local defaults.");
141
+ }
142
+ console.log();
143
+ await ask(rl, `${DIM}Press Enter to continue...${RESET}`);
144
+ console.log();
145
+ if (mode === "personal") {
146
+ if (!hasTokenEnv() && !hasGhAuth()) {
147
+ console.log(`${YELLOW}GitHub authentication is required before setup can continue.${RESET}`);
148
+ console.log("Run `gh auth login` to authenticate with GitHub, then re-run setup.");
149
+ console.log(`${DIM}If you already manage credentials via environment, set GITHUB_TOKEN or COPILOT_TOKEN before running setup.${RESET}`);
150
+ return;
151
+ }
152
+ console.log();
153
+ await showWikiLocation(rl);
154
+ }
155
+ console.log(`\n${BOLD}━━━ Default Model ━━━${RESET}\n`);
156
+ console.log(`${DIM}Fetching available models from Copilot...${RESET}`);
157
+ let models = await fetchModels();
158
+ if (models.length === 0) {
159
+ console.log(`${YELLOW} Could not fetch models (Copilot CLI may not be authenticated yet).${RESET}`);
160
+ console.log(`${DIM} Showing a curated list — you can switch anytime after setup.${RESET}\n`);
161
+ models = FALLBACK_MODELS;
162
+ }
163
+ else {
164
+ console.log(`${GREEN} Found ${models.length} models${RESET}\n`);
165
+ }
166
+ console.log(`${DIM}You can switch models anytime in the web UI's Settings page.${RESET}\n`);
167
+ const currentModel = existing.values.COPILOT_MODEL || "claude-sonnet-4.6";
168
+ const model = await askPicker(rl, "Choose a default model:", models, currentModel);
169
+ const modelLabel = models.find((entry) => entry.id === model)?.label || model;
170
+ console.log(`\n${GREEN} ✓ Using ${modelLabel}${RESET}\n`);
171
+ const apiPort = existing.values.API_PORT || "7788";
172
+ const nextEnv = upsertEnvLines(existing.lines, {
173
+ API_PORT: apiPort,
174
+ CHAPTERHOUSE_MODE: mode,
175
+ COPILOT_MODEL: model,
176
+ });
177
+ writeFileSync(ENV_PATH, `${nextEnv.join("\n")}\n`);
178
+ console.log(`
122
179
  ${GREEN}${BOLD}✅ Chapterhouse is ready!${RESET}
123
180
  ${DIM}Config saved to ${ENV_PATH}${RESET}
124
-
125
- ${BOLD}Get started:${RESET}
126
-
127
- ${CYAN}1.${RESET} Make sure Copilot CLI is authenticated:
128
- ${BOLD}copilot login${RESET}
129
-
130
- ${CYAN}2.${RESET} Start Chapterhouse:
131
- ${BOLD}chapterhouse start${RESET}
132
-
133
- ${CYAN}3.${RESET} Open the web UI:
134
- ${BOLD}http://localhost:${apiPort}${RESET}
135
-
136
- ${BOLD}Things to try:${RESET}
137
-
138
- ${DIM}"Start working on the auth bug in ${getExampleProjectPath()}"${RESET}
139
- ${DIM}"What sessions are running?"${RESET}
140
- ${DIM}"Find me a skill for checking Gmail"${RESET}
141
- ${DIM}"Switch to gpt-4.1"${RESET}
142
181
  `);
143
- rl.close();
182
+ if (mode === "personal") {
183
+ console.log(`${BOLD}Your personal wiki lives at:${RESET}`);
184
+ console.log(` ${CYAN}${WIKI_DIR}${RESET}\n`);
185
+ }
186
+ console.log(`${BOLD}Get started:${RESET}
187
+ `);
188
+ console.log(` ${CYAN}1.${RESET} If needed, authenticate GitHub CLI:`);
189
+ console.log(` ${BOLD}gh auth login${RESET}\n`);
190
+ console.log(` ${CYAN}2.${RESET} Make sure Copilot CLI is authenticated:`);
191
+ console.log(` ${BOLD}copilot login${RESET}\n`);
192
+ console.log(` ${CYAN}3.${RESET} Start Chapterhouse:`);
193
+ console.log(` ${BOLD}chapterhouse start${RESET}\n`);
194
+ console.log(` ${CYAN}4.${RESET} Open the web UI:`);
195
+ console.log(` ${BOLD}http://localhost:${apiPort}${RESET}\n`);
196
+ console.log(`${BOLD}Things to try:${RESET}\n`);
197
+ console.log(` ${DIM}"Start working on the auth bug in ${getExampleProjectPath()}"${RESET}`);
198
+ console.log(` ${DIM}"What sessions are running?"${RESET}`);
199
+ console.log(` ${DIM}"Find me a skill for checking Gmail"${RESET}`);
200
+ console.log(` ${DIM}"Switch to gpt-4.1"${RESET}`);
201
+ }
202
+ finally {
203
+ rl.close();
204
+ }
144
205
  }
145
- main().catch((err) => {
146
- console.error("Setup failed:", err);
147
- process.exit(1);
148
- });
149
206
  //# sourceMappingURL=setup.js.map
@@ -0,0 +1,122 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ async function runSetupScript(t, options = {}) {
4
+ const output = [];
5
+ const prompts = [];
6
+ let writtenConfig = "";
7
+ const answers = [...(options.answers ?? [])];
8
+ t.mock.module("node:readline", {
9
+ namedExports: {
10
+ createInterface: () => ({
11
+ question: (prompt, callback) => {
12
+ prompts.push(prompt);
13
+ callback(answers.shift() ?? "");
14
+ },
15
+ close: () => { },
16
+ }),
17
+ },
18
+ });
19
+ t.mock.module("fs", {
20
+ namedExports: {
21
+ existsSync: () => options.existingEnv !== undefined,
22
+ readFileSync: () => options.existingEnv ?? "",
23
+ writeFileSync: (_path, content) => {
24
+ writtenConfig = content;
25
+ },
26
+ },
27
+ });
28
+ t.mock.module("./paths.js", {
29
+ namedExports: {
30
+ ensureChapterhouseHome: () => { },
31
+ ENV_PATH: "/workspace/.chapterhouse/.env",
32
+ CHAPTERHOUSE_HOME: "/workspace/.chapterhouse",
33
+ WIKI_DIR: "/workspace/.chapterhouse/wiki",
34
+ },
35
+ });
36
+ t.mock.module("./home-path.js", {
37
+ namedExports: {
38
+ getExampleProjectPath: () => "/workspace/example-project",
39
+ },
40
+ });
41
+ t.mock.module("node:child_process", {
42
+ namedExports: {
43
+ execFileSync: () => {
44
+ if (options.ghAuthStatus === "unauthenticated") {
45
+ throw new Error("gh auth status failed");
46
+ }
47
+ return Buffer.from("github.com\n ✓ Logged in");
48
+ },
49
+ },
50
+ });
51
+ t.mock.module("@github/copilot-sdk", {
52
+ namedExports: {
53
+ CopilotClient: class {
54
+ async start() { }
55
+ async stop() { }
56
+ async listModels() {
57
+ return (options.modelIds ?? ["claude-sonnet-4.6"]).map((id) => ({
58
+ id,
59
+ name: id,
60
+ policy: { state: "enabled" },
61
+ }));
62
+ }
63
+ },
64
+ },
65
+ });
66
+ t.mock.method(console, "log", (...args) => {
67
+ output.push(args.join(" "));
68
+ });
69
+ t.mock.method(console, "error", (...args) => {
70
+ output.push(args.join(" "));
71
+ });
72
+ const priorEnv = process.env;
73
+ process.env = {
74
+ ...priorEnv,
75
+ ...options.env,
76
+ };
77
+ try {
78
+ const setupModule = await import(new URL(`./setup.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
79
+ const { runSetup } = setupModule;
80
+ assert.equal(typeof runSetup, "function", "setup.ts should export runSetup");
81
+ await runSetup?.();
82
+ }
83
+ finally {
84
+ process.env = priorEnv;
85
+ }
86
+ return { output, prompts, writtenConfig };
87
+ }
88
+ test("personal setup uses existing gh auth and never prompts for tokens", async (t) => {
89
+ const result = await runSetupScript(t, {
90
+ env: { CHAPTERHOUSE_MODE: "personal" },
91
+ answers: ["", "", "1"],
92
+ ghAuthStatus: "authenticated",
93
+ });
94
+ assert.doesNotMatch(result.prompts.join("\n"), /token/i);
95
+ assert.match(result.output.join("\n"), /wiki location/i);
96
+ assert.doesNotMatch(result.output.join("\n"), /ADO|Teams webhook|Entra/i);
97
+ assert.match(result.writtenConfig, /^CHAPTERHOUSE_MODE=personal$/m);
98
+ assert.match(result.writtenConfig, /^COPILOT_MODEL=claude-sonnet-4\.6$/m);
99
+ assert.doesNotMatch(result.writtenConfig, /^GITHUB_TOKEN=/m);
100
+ });
101
+ test("personal setup accepts env var tokens silently without prompting", async (t) => {
102
+ const result = await runSetupScript(t, {
103
+ env: { CHAPTERHOUSE_MODE: "personal", GITHUB_TOKEN: "ghp_env_token" },
104
+ answers: ["", "", "1"],
105
+ ghAuthStatus: "unauthenticated",
106
+ });
107
+ assert.doesNotMatch(result.prompts.join("\n"), /token/i);
108
+ assert.match(result.writtenConfig, /^CHAPTERHOUSE_MODE=personal$/m);
109
+ assert.match(result.writtenConfig, /^COPILOT_MODEL=claude-sonnet-4\.6$/m);
110
+ assert.doesNotMatch(result.writtenConfig, /^GITHUB_TOKEN=/m);
111
+ });
112
+ test("personal setup instructs users to run gh auth login when not authenticated", async (t) => {
113
+ const result = await runSetupScript(t, {
114
+ env: { CHAPTERHOUSE_MODE: "personal" },
115
+ answers: [""],
116
+ ghAuthStatus: "unauthenticated",
117
+ });
118
+ assert.doesNotMatch(result.prompts.join("\n"), /token/i);
119
+ assert.match(result.output.join("\n"), /Run `gh auth login` to authenticate with GitHub, then re-run setup/i);
120
+ assert.equal(result.writtenConfig, "");
121
+ });
122
+ //# sourceMappingURL=setup.test.js.map
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { config } from "../config.js";
4
+ import { ModeContext } from "../mode-context.js";
4
5
  import { WIKI_DIR } from "../paths.js";
5
6
  import { assertPagePath, readPage, writePage, writeFileAtomic } from "./fs.js";
6
7
  import { addToIndex, buildIndexEntryForPage } from "./index-manager.js";
@@ -18,6 +19,7 @@ export class TeamWikiSync {
18
19
  fetchImpl;
19
20
  warn;
20
21
  now;
22
+ modeContext;
21
23
  constructor(options = {}) {
22
24
  this.teamChapterhouseUrl = (options.teamChapterhouseUrl ?? config.teamChapterhouseUrl).trim().replace(/\/+$/, "");
23
25
  this.teamChapterhouseToken = (options.teamChapterhouseToken ?? config.teamChapterhouseToken).trim();
@@ -32,9 +34,14 @@ export class TeamWikiSync {
32
34
  this.fetchImpl = options.fetchImpl ?? fetch;
33
35
  this.warn = options.warn ?? ((message) => log.warn(message));
34
36
  this.now = options.now ?? (() => new Date());
37
+ this.modeContext = new ModeContext({
38
+ ...config,
39
+ teamChapterhouseUrl: this.teamChapterhouseUrl,
40
+ standaloneMode: this.standaloneMode,
41
+ });
35
42
  }
36
43
  isEnabled() {
37
- return !this.standaloneMode && this.teamChapterhouseUrl.length > 0;
44
+ return this.modeContext.canSyncTeamWiki();
38
45
  }
39
46
  isTeamPath(path) {
40
47
  return this.teamWikiPaths.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"