@handsupmin/gc-tree 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.es.md +166 -0
  3. package/README.ja.md +166 -0
  4. package/README.ko.md +166 -0
  5. package/README.md +166 -0
  6. package/README.zh.md +166 -0
  7. package/dist/src/cli.js +360 -0
  8. package/dist/src/markdown.js +81 -0
  9. package/dist/src/onboard.js +36 -0
  10. package/dist/src/onboarding-protocol.js +38 -0
  11. package/dist/src/paths.js +28 -0
  12. package/dist/src/proposals.js +73 -0
  13. package/dist/src/provider.js +121 -0
  14. package/dist/src/repo-map.js +149 -0
  15. package/dist/src/resolve.js +38 -0
  16. package/dist/src/scaffold.js +199 -0
  17. package/dist/src/settings.js +30 -0
  18. package/dist/src/store.js +149 -0
  19. package/dist/src/types.js +1 -0
  20. package/dist/src/update.js +26 -0
  21. package/docs/concept.es.md +81 -0
  22. package/docs/concept.ja.md +81 -0
  23. package/docs/concept.ko.md +81 -0
  24. package/docs/concept.md +81 -0
  25. package/docs/concept.zh.md +81 -0
  26. package/docs/local-development.es.md +83 -0
  27. package/docs/local-development.ja.md +83 -0
  28. package/docs/local-development.ko.md +83 -0
  29. package/docs/local-development.md +83 -0
  30. package/docs/local-development.zh.md +83 -0
  31. package/docs/principles.es.md +49 -0
  32. package/docs/principles.ja.md +49 -0
  33. package/docs/principles.ko.md +49 -0
  34. package/docs/principles.md +50 -0
  35. package/docs/principles.zh.md +49 -0
  36. package/docs/usage.es.md +119 -0
  37. package/docs/usage.ja.md +119 -0
  38. package/docs/usage.ko.md +119 -0
  39. package/docs/usage.md +121 -0
  40. package/docs/usage.zh.md +119 -0
  41. package/package.json +32 -0
  42. package/skills/checkout/SKILL.md +17 -0
  43. package/skills/onboard/SKILL.md +74 -0
  44. package/skills/reset-gc-branch/SKILL.md +14 -0
  45. package/skills/resolve-context/SKILL.md +21 -0
  46. package/skills/update-global-context/SKILL.md +21 -0
@@ -0,0 +1,28 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ export const DEFAULT_BRANCH = 'main';
4
+ export const INDEX_WARNING_CHARS = 2000;
5
+ export function resolveHome(explicitHome) {
6
+ return explicitHome || process.env.GCTREE_HOME || join(homedir(), '.gctree');
7
+ }
8
+ export function headPath(home) {
9
+ return join(home, 'HEAD');
10
+ }
11
+ export function settingsPath(home) {
12
+ return join(home, 'settings.json');
13
+ }
14
+ export function branchesRoot(home) {
15
+ return join(home, 'branches');
16
+ }
17
+ export function branchDir(home, branch) {
18
+ return join(branchesRoot(home), branch);
19
+ }
20
+ export function branchMetaPath(home, branch) {
21
+ return join(branchDir(home, branch), 'branch.json');
22
+ }
23
+ export function branchIndexPath(home, branch) {
24
+ return join(branchDir(home, branch), 'index.md');
25
+ }
26
+ export function branchDocsDir(home, branch) {
27
+ return join(branchDir(home, branch), 'docs');
28
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { renderDocMarkdown, slugify } from './markdown.js';
4
+ import { branchDir, branchDocsDir, branchProposalsDir, DEFAULT_BRANCH } from './paths.js';
5
+ import { ensureBranchExists, updateBranchMeta, writeIndexFromDocs } from './store.js';
6
+ export async function proposeUpdate({ home, input, branch, }) {
7
+ const targetBranch = branch || input.branch || DEFAULT_BRANCH;
8
+ await ensureBranchExists(home, targetBranch);
9
+ await mkdir(branchProposalsDir(home, targetBranch), { recursive: true });
10
+ const now = new Date().toISOString();
11
+ const id = `${now.replace(/[:.]/g, '-')}-${slugify(input.title)}`;
12
+ const proposal = {
13
+ version: 1,
14
+ id,
15
+ status: 'proposed',
16
+ branch: targetBranch,
17
+ title: input.title.trim(),
18
+ summary: input.summary.trim(),
19
+ created_at: now,
20
+ changes: input.docs.map((doc) => ({
21
+ path: `docs/${slugify(doc.slug || doc.title)}.md`,
22
+ title: doc.title.trim(),
23
+ summary: doc.summary.trim(),
24
+ body: doc.body.trim(),
25
+ ...(doc.tags?.length ? { tags: doc.tags } : {}),
26
+ })),
27
+ };
28
+ const proposalPath = join(branchProposalsDir(home, targetBranch), `${id}.json`);
29
+ await writeFile(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8');
30
+ return { proposal_path: proposalPath, proposal };
31
+ }
32
+ export async function applyProposal({ home, proposalPath, }) {
33
+ const raw = await readFile(proposalPath, 'utf8');
34
+ const proposal = JSON.parse(raw);
35
+ await ensureBranchExists(home, proposal.branch);
36
+ await mkdir(branchDocsDir(home, proposal.branch), { recursive: true });
37
+ const updatedDocs = [];
38
+ for (const change of proposal.changes) {
39
+ const fullPath = join(branchDir(home, proposal.branch), change.path);
40
+ await writeFile(fullPath, renderDocMarkdown({
41
+ title: change.title,
42
+ summary: change.summary,
43
+ body: change.body,
44
+ tags: change.tags,
45
+ }), 'utf8');
46
+ updatedDocs.push(fullPath);
47
+ }
48
+ proposal.status = 'applied';
49
+ proposal.applied_at = new Date().toISOString();
50
+ await writeFile(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8');
51
+ await writeIndexFromDocs(home, proposal.branch);
52
+ await updateBranchMeta(home, proposal.branch, {
53
+ summary: proposal.summary || (await readBranchSummary(home, proposal.branch)),
54
+ });
55
+ return {
56
+ branch: proposal.branch,
57
+ updated_docs: updatedDocs,
58
+ proposal_path: proposalPath,
59
+ };
60
+ }
61
+ async function readBranchSummary(home, branch) {
62
+ const branchJson = await readFile(join(branchDir(home, branch), 'branch.json'), 'utf8');
63
+ return JSON.parse(branchJson).summary || '';
64
+ }
65
+ export async function listProposals(home, branch) {
66
+ return (await readdir(branchProposalsDir(home, branch)).catch(() => []))
67
+ .filter((file) => file.endsWith('.json'))
68
+ .sort()
69
+ .map((file) => join(branchProposalsDir(home, branch), file));
70
+ }
71
+ export function proposalBasename(path) {
72
+ return basename(path);
73
+ }
@@ -0,0 +1,121 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stderr, stdin } from "node:process";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { onboardingCompletionLines, onboardingProtocolLines, } from "./onboarding-protocol.js";
5
+ export const LANGUAGE_SELECTION_PROMPT = "Choose language:\n1. English (default)\n2. Korean\n3. Other language\n> ";
6
+ export function visibleProviderCommand(provider, command) {
7
+ const prefix = provider === "codex" ? "$" : "/";
8
+ return `${prefix}${command}`;
9
+ }
10
+ export async function promptProviderSelection() {
11
+ const rl = createInterface({ input: stdin, output: stderr });
12
+ try {
13
+ const answer = (await rl.question("Choose your LLM CLI provider:\n1. Claude Code\n2. Codex\n3. Both\n> ")).trim();
14
+ if (answer === "2")
15
+ return "codex";
16
+ if (answer === "3")
17
+ return "both";
18
+ return "claude-code";
19
+ }
20
+ finally {
21
+ rl.close();
22
+ }
23
+ }
24
+ export async function promptLaunchProviderSelection() {
25
+ const rl = createInterface({ input: stdin, output: stderr });
26
+ try {
27
+ const answer = (await rl.question("Both providers are enabled. Which one should start onboarding now?\n1. Claude Code\n2. Codex\n> ")).trim();
28
+ return answer === "2" ? "codex" : "claude-code";
29
+ }
30
+ finally {
31
+ rl.close();
32
+ }
33
+ }
34
+ export async function promptLanguageSelection() {
35
+ const rl = createInterface({ input: stdin, output: stderr });
36
+ try {
37
+ const parsed = parseLanguageSelectionInput((await rl.question(LANGUAGE_SELECTION_PROMPT)).trim());
38
+ if (parsed.language)
39
+ return parsed.language;
40
+ if (parsed.needsFollowUp) {
41
+ const typed = (await rl.question("Type your language: ")).trim();
42
+ return typed || "English";
43
+ }
44
+ return "English";
45
+ }
46
+ finally {
47
+ rl.close();
48
+ }
49
+ }
50
+ export function parseLanguageSelectionInput(answer) {
51
+ const normalized = answer.trim();
52
+ if (!normalized)
53
+ return { language: "English", needsFollowUp: false };
54
+ if (/^1(?:[\s.)].*)?$/i.test(normalized) || /^english$/i.test(normalized)) {
55
+ return { language: "English", needsFollowUp: false };
56
+ }
57
+ if (/^2(?:[\s.)].*)?$/i.test(normalized) || /^korean$/i.test(normalized)) {
58
+ return { language: "Korean", needsFollowUp: false };
59
+ }
60
+ const customMatch = normalized.match(/^3(?:[\s.)\:,-]+(.+))?$/i);
61
+ if (customMatch) {
62
+ const inlineLanguage = customMatch[1]?.trim();
63
+ return inlineLanguage
64
+ ? { language: inlineLanguage, needsFollowUp: false }
65
+ : { language: null, needsFollowUp: true };
66
+ }
67
+ return { language: normalized, needsFollowUp: false };
68
+ }
69
+ export function buildProviderLaunchPlan({ provider, providerMode, preferredLanguage, targetDir, gcBranch, command, }) {
70
+ const providerCommand = visibleProviderCommand(provider, command);
71
+ const binary = provider === "codex" ? "codex" : "claude";
72
+ const promptLines = [
73
+ providerCommand,
74
+ "",
75
+ `IMPORTANT LANGUAGE RULE: Use ${preferredLanguage} for every message in this workflow.`,
76
+ `Do not switch to English or another language unless the user explicitly asks you to.`,
77
+ `At the start, explicitly say that the active gc-branch is "${gcBranch}" and that the required workflow language is "${preferredLanguage}".`,
78
+ ];
79
+ if (command === "gc-onboard") {
80
+ promptLines.push("IMPORTANT ONBOARDING RULES:");
81
+ promptLines.push(...onboardingProtocolLines().map((line) => `- ${line}`));
82
+ promptLines.push(...onboardingCompletionLines().map((line) => `- ${line}`));
83
+ }
84
+ const prompt = promptLines.join("\n");
85
+ return {
86
+ provider,
87
+ provider_mode: providerMode,
88
+ preferred_language: preferredLanguage,
89
+ binary,
90
+ args: [prompt],
91
+ target_dir: targetDir,
92
+ gc_branch: gcBranch,
93
+ provider_command: providerCommand,
94
+ launched: false,
95
+ };
96
+ }
97
+ export async function maybeLaunchProvider(plan) {
98
+ if (process.env.GCTREE_DISABLE_PROVIDER_LAUNCH === "1" ||
99
+ !stdin.isTTY ||
100
+ !process.stdout.isTTY) {
101
+ return plan;
102
+ }
103
+ await new Promise((resolve, reject) => {
104
+ const child = spawn(plan.binary, plan.args, {
105
+ cwd: plan.target_dir,
106
+ stdio: "inherit",
107
+ });
108
+ child.on("error", reject);
109
+ child.on("exit", (code) => {
110
+ if (code && code !== 0) {
111
+ reject(new Error(`${plan.binary} exited with code ${code}`));
112
+ return;
113
+ }
114
+ resolve();
115
+ });
116
+ });
117
+ return {
118
+ ...plan,
119
+ launched: true,
120
+ };
121
+ }
@@ -0,0 +1,149 @@
1
+ import { access, readFile, writeFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { stdin, stderr } from 'node:process';
6
+ export function branchRepoMapPath(home) {
7
+ return join(home, 'branch-repo-map.json');
8
+ }
9
+ function unique(values) {
10
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
11
+ }
12
+ function normalizeRule(rule) {
13
+ return {
14
+ ...(rule?.include?.length ? { include: unique(rule.include) } : {}),
15
+ ...(rule?.exclude?.length ? { exclude: unique(rule.exclude) } : {}),
16
+ };
17
+ }
18
+ export async function readBranchRepoMap(home) {
19
+ try {
20
+ const raw = await readFile(branchRepoMapPath(home), 'utf8');
21
+ const parsed = JSON.parse(raw);
22
+ return Object.fromEntries(Object.entries(parsed).map(([branch, rule]) => [branch, normalizeRule(rule)]));
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ }
28
+ export async function writeBranchRepoMap(home, mapping) {
29
+ const normalized = Object.fromEntries(Object.entries(mapping).map(([branch, rule]) => [branch, normalizeRule(rule)]));
30
+ await writeFile(branchRepoMapPath(home), `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
31
+ return normalized;
32
+ }
33
+ export async function setRepoScopeForBranch({ home, branch, repo, mode, }) {
34
+ const mapping = await readBranchRepoMap(home);
35
+ const current = normalizeRule(mapping[branch]);
36
+ const include = new Set(current.include || []);
37
+ const exclude = new Set(current.exclude || []);
38
+ if (mode === 'include') {
39
+ include.add(repo);
40
+ exclude.delete(repo);
41
+ }
42
+ else {
43
+ exclude.add(repo);
44
+ include.delete(repo);
45
+ }
46
+ mapping[branch] = normalizeRule({
47
+ include: [...include],
48
+ exclude: [...exclude],
49
+ });
50
+ return writeBranchRepoMap(home, mapping);
51
+ }
52
+ async function pathExists(path) {
53
+ try {
54
+ await access(path, constants.F_OK);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ export async function detectRepoRoot(startDir = process.cwd()) {
62
+ let current = resolve(startDir);
63
+ while (true) {
64
+ if (await pathExists(join(current, '.git')))
65
+ return current;
66
+ const parent = dirname(current);
67
+ if (parent === current)
68
+ return null;
69
+ current = parent;
70
+ }
71
+ }
72
+ export async function detectCurrentRepoId(startDir = process.cwd()) {
73
+ const root = await detectRepoRoot(startDir);
74
+ return root ? basename(root) : null;
75
+ }
76
+ export function branchScopeStatus(mapping, branch, repo) {
77
+ if (!repo)
78
+ return 'unscoped';
79
+ const rule = normalizeRule(mapping[branch]);
80
+ if (rule.exclude?.includes(repo))
81
+ return 'excluded';
82
+ if (rule.include?.includes(repo))
83
+ return 'included';
84
+ if ((rule.include?.length || 0) > 0 || (rule.exclude?.length || 0) > 0)
85
+ return 'unmapped';
86
+ return 'unscoped';
87
+ }
88
+ export async function resolveBranchForRepo({ home, head, explicitBranch, cwd = process.cwd(), }) {
89
+ const currentRepo = await detectCurrentRepoId(cwd);
90
+ const mapping = await readBranchRepoMap(home);
91
+ if (explicitBranch) {
92
+ return {
93
+ gc_branch: explicitBranch,
94
+ current_repo: currentRepo,
95
+ source: 'explicit',
96
+ scope_status: branchScopeStatus(mapping, explicitBranch, currentRepo),
97
+ };
98
+ }
99
+ if (currentRepo) {
100
+ const includedBranches = Object.entries(mapping)
101
+ .filter(([, rule]) => !rule.exclude?.includes(currentRepo) && rule.include?.includes(currentRepo))
102
+ .map(([branch]) => branch)
103
+ .sort();
104
+ if (includedBranches.length === 1) {
105
+ const chosen = includedBranches[0];
106
+ return {
107
+ gc_branch: chosen,
108
+ current_repo: currentRepo,
109
+ source: 'repo-map',
110
+ scope_status: 'included',
111
+ };
112
+ }
113
+ if (includedBranches.length > 1) {
114
+ const chosen = includedBranches.includes(head) ? head : includedBranches[0];
115
+ return {
116
+ gc_branch: chosen,
117
+ current_repo: currentRepo,
118
+ source: 'repo-map',
119
+ scope_status: 'included',
120
+ };
121
+ }
122
+ }
123
+ return {
124
+ gc_branch: head,
125
+ current_repo: currentRepo,
126
+ source: 'head',
127
+ scope_status: branchScopeStatus(mapping, head, currentRepo),
128
+ };
129
+ }
130
+ export async function promptResolveScopeDecision(branch, repo, preferredLanguage = 'English') {
131
+ if (!stdin.isTTY && process.env.GCTREE_ALLOW_STDIN_PROMPT !== '1') {
132
+ throw new Error(`current repo \"${repo}\" is not mapped to gc-branch \"${branch}\". Run interactively to choose whether to continue, include this repo, or ignore this gc-branch here.`);
133
+ }
134
+ const rl = createInterface({ input: stdin, output: stderr });
135
+ try {
136
+ const isKorean = preferredLanguage.trim().toLowerCase() === 'korean';
137
+ const answer = (await rl.question(isKorean
138
+ ? `현재 레포 \"${repo}\"는 gc-branch \"${branch}\"에 아직 매핑되어 있지 않습니다.\n1. 이번만 진행\n2. 앞으로 이 레포에서도 이 gc-branch 사용\n3. 이 레포에서는 이 gc-branch 무시\n> `
139
+ : `Repo \"${repo}\" is not mapped to gc-branch \"${branch}\".\n1. Continue once\n2. Always use this gc-branch for this repo\n3. Ignore this gc-branch for this repo\n> `)).trim();
140
+ if (answer === '2')
141
+ return 'always-use';
142
+ if (answer === '3')
143
+ return 'ignore';
144
+ return 'continue-once';
145
+ }
146
+ finally {
147
+ rl.close();
148
+ }
149
+ }
@@ -0,0 +1,38 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { extractExcerpt, extractSummary, parseIndexEntries } from './markdown.js';
4
+ import { branchDir, branchIndexPath } from './paths.js';
5
+ function scoreText(text, query) {
6
+ const tokens = String(query || '')
7
+ .toLowerCase()
8
+ .split(/[^a-z0-9]+/)
9
+ .filter((token) => token.length >= 2);
10
+ const haystack = String(text || '').toLowerCase();
11
+ return tokens.reduce((sum, token) => sum + (haystack.includes(token) ? 1 : 0), 0);
12
+ }
13
+ export async function resolveContext({ home, branch, query, }) {
14
+ const indexRaw = await readFile(branchIndexPath(home, branch), 'utf8');
15
+ const entries = parseIndexEntries(indexRaw);
16
+ const matches = [];
17
+ for (const entry of entries) {
18
+ const fullPath = join(branchDir(home, branch), entry.path);
19
+ const raw = await readFile(fullPath, 'utf8');
20
+ const summary = extractSummary(raw);
21
+ const combined = `${entry.title}\n${summary}\n${raw}`;
22
+ const score = scoreText(combined, query);
23
+ if (score <= 0)
24
+ continue;
25
+ matches.push({
26
+ title: entry.title,
27
+ path: entry.path,
28
+ score,
29
+ summary,
30
+ excerpt: extractExcerpt(raw, query),
31
+ });
32
+ }
33
+ return {
34
+ branch,
35
+ query,
36
+ matches: matches.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)),
37
+ };
38
+ }
@@ -0,0 +1,199 @@
1
+ import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { onboardingCompletionLines, onboardingProtocolLines } from './onboarding-protocol.js';
4
+ function renderCodexAgentsSnippet() {
5
+ return [
6
+ '# gctree Codex integration snippet',
7
+ '',
8
+ '- Treat the active gctree branch as a **gc-branch** when you describe it to users.',
9
+ '- Before planning or implementation, run `gctree status` to confirm the active gc-branch if it is unclear.',
10
+ '- Use `gctree resolve --query "<task>"` when reusable global context may matter.',
11
+ '- Use `$gc-onboard` only for an empty gc-branch.',
12
+ '- Use `$gc-update-global-context` when durable context in the active gc-branch should change.',
13
+ '',
14
+ ].join('\n');
15
+ }
16
+ function renderCodexBootstrapPrompt() {
17
+ return [
18
+ '# gctree Bootstrap',
19
+ '',
20
+ '- Keep the active gc-branch explicit whenever global context matters.',
21
+ '- Resolve reusable global context before planning or implementation when it may change the answer.',
22
+ '- Read summaries first and only open full docs when needed.',
23
+ '- Treat gctree docs as explicit source-of-truth markdown, not hidden memory.',
24
+ '',
25
+ ].join('\n');
26
+ }
27
+ function renderCodexResolveSkill() {
28
+ return [
29
+ '---',
30
+ 'description: Resolve reusable global context from the active gc-branch.',
31
+ 'argument_hint: "<query>"',
32
+ '---',
33
+ '',
34
+ 'Treat everything after this command as the query.',
35
+ '',
36
+ '1. Run `gctree status` if the active gc-branch is unclear.',
37
+ '2. Explicitly mention which gc-branch is active before using the result.',
38
+ '3. Run `gctree resolve --query "<query>"`.',
39
+ '4. If the current repo is outside the mapped scope, choose whether to continue once, always use this gc-branch for this repo, or ignore this gc-branch here.',
40
+ '5. Read summaries first and only open full docs if needed.',
41
+ '',
42
+ ].join('\n');
43
+ }
44
+ function renderCodexOnboardSkill() {
45
+ const protocol = onboardingProtocolLines();
46
+ const completion = onboardingCompletionLines();
47
+ return [
48
+ '---',
49
+ 'description: Guided onboarding for the active gc-branch in gctree.',
50
+ '---',
51
+ '',
52
+ 'Use this only when the active gc-branch is empty.',
53
+ '',
54
+ '1. Run `gctree status` and explicitly state the active gc-branch to the user.',
55
+ ...protocol.map((line, index) => `${index + 2}. ${line}`),
56
+ `${protocol.length + 2}. Then create a temporary JSON file with \`branchSummary\` and \`docs[]\` (\`title\`, \`summary\`, \`body\`).`,
57
+ `${protocol.length + 3}. Run \`gctree __apply-onboarding --input <temp-file>\`.`,
58
+ ...completion.map((line, index) => `${protocol.length + index + 4}. ${line}`),
59
+ `${protocol.length + completion.length + 4}. If the gc-branch is not empty, stop and tell the user to run \`gctree reset-gc-branch --branch <current-gc-branch> --yes\` or \`gctree update-global-context\` instead.`,
60
+ '',
61
+ ].join('\n');
62
+ }
63
+ function renderCodexUpdateSkill() {
64
+ return [
65
+ '---',
66
+ 'description: Guided durable update for the active gc-branch in gctree.',
67
+ '---',
68
+ '',
69
+ '1. Run `gctree status` and explicitly state the active gc-branch to the user.',
70
+ '2. Ask what durable context should change, one question at a time.',
71
+ '3. If this repo clearly belongs to the current gc-branch but is not mapped yet, ask the user whether it should be added to the branch-repo map and run `gctree set-repo-scope --branch <current-gc-branch> --include` when they approve.',
72
+ '4. Create a temporary JSON file containing the updated `docs[]` and optional `branchSummary`.',
73
+ '5. Run `gctree __apply-update --input <temp-file>`.',
74
+ '6. Show the updated docs back to the user.',
75
+ '',
76
+ ].join('\n');
77
+ }
78
+ function renderClaudeSnippet() {
79
+ return [
80
+ '# gctree Claude Code integration snippet',
81
+ '',
82
+ '- Treat the active gctree branch as a **gc-branch** in user-facing language.',
83
+ '- Run `gctree status` before relying on global context if the active gc-branch is unclear.',
84
+ '- Use `gctree resolve --query "<task>"` when reusable global context may matter.',
85
+ '- Use `/gc-onboard` only for an empty gc-branch.',
86
+ '- Use `/gc-update-global-context` when durable context in the active gc-branch should change.',
87
+ '',
88
+ ].join('\n');
89
+ }
90
+ function renderClaudeSessionStartHook() {
91
+ return [
92
+ '# gctree Claude Code SessionStart note',
93
+ '',
94
+ '- At session start, confirm the active gc-branch with `gctree status` when reusable global context may matter.',
95
+ '- Refer to gctree branches as **gc-branches** in user-facing language.',
96
+ '- Resolve summaries before planning or implementation when branch-level context may change the answer.',
97
+ '',
98
+ ].join('\n');
99
+ }
100
+ function renderClaudeResolveCommand() {
101
+ return [
102
+ '---',
103
+ 'description: Resolve reusable global context from the active gc-branch.',
104
+ 'argument-hint: "<query>"',
105
+ '---',
106
+ '',
107
+ '1. Run `gctree status` if the active gc-branch is unclear.',
108
+ '2. Explicitly mention the active gc-branch before using the result.',
109
+ '3. Run `gctree resolve --query "$ARGUMENTS"`.',
110
+ '4. If the current repo is outside the mapped scope, choose whether to continue once, always use this gc-branch for this repo, or ignore this gc-branch here.',
111
+ '5. Read summaries first and only open full docs if needed.',
112
+ '',
113
+ ].join('\n');
114
+ }
115
+ function renderClaudeOnboardCommand() {
116
+ const protocol = onboardingProtocolLines();
117
+ const completion = onboardingCompletionLines();
118
+ return [
119
+ '---',
120
+ 'description: Guided onboarding for the active gc-branch in gctree.',
121
+ '---',
122
+ '',
123
+ 'Use this only when the active gc-branch is empty.',
124
+ '',
125
+ '1. Run `gctree status` and explicitly state the active gc-branch to the user.',
126
+ ...protocol.map((line, index) => `${index + 2}. ${line}`),
127
+ `${protocol.length + 2}. Then create a temporary JSON file with \`branchSummary\` and \`docs[]\` (\`title\`, \`summary\`, \`body\`).`,
128
+ `${protocol.length + 3}. Run \`gctree __apply-onboarding --input <temp-file>\`.`,
129
+ ...completion.map((line, index) => `${protocol.length + index + 4}. ${line}`),
130
+ `${protocol.length + completion.length + 4}. If the gc-branch is not empty, stop and tell the user to run \`gctree reset-gc-branch --branch <current-gc-branch> --yes\` or \`gctree update-global-context\` instead.`,
131
+ '',
132
+ ].join('\n');
133
+ }
134
+ function renderClaudeUpdateCommand() {
135
+ return [
136
+ '---',
137
+ 'description: Guided durable update for the active gc-branch in gctree.',
138
+ '---',
139
+ '',
140
+ '1. Run `gctree status` and explicitly state the active gc-branch to the user.',
141
+ '2. Ask what durable context should change, one question at a time.',
142
+ '3. If this repo clearly belongs to the current gc-branch but is not mapped yet, ask the user whether it should be added to the branch-repo map and run `gctree set-repo-scope --branch <current-gc-branch> --include` when they approve.',
143
+ '4. Create a temporary JSON file containing the updated `docs[]` and optional `branchSummary`.',
144
+ '5. Run `gctree __apply-update --input <temp-file>`.',
145
+ '6. Show the updated docs back to the user.',
146
+ '',
147
+ ].join('\n');
148
+ }
149
+ function scaffoldFiles(host) {
150
+ if (host === 'codex') {
151
+ return [
152
+ { path: 'AGENTS.md', content: renderCodexAgentsSnippet() },
153
+ { path: '.codex/prompts/gctree-bootstrap.md', content: renderCodexBootstrapPrompt() },
154
+ { path: '.codex/skills/gc-resolve-context/SKILL.md', content: renderCodexResolveSkill() },
155
+ { path: '.codex/skills/gc-onboard/SKILL.md', content: renderCodexOnboardSkill() },
156
+ { path: '.codex/skills/gc-update-global-context/SKILL.md', content: renderCodexUpdateSkill() },
157
+ ];
158
+ }
159
+ return [
160
+ { path: 'CLAUDE.md', content: renderClaudeSnippet() },
161
+ { path: '.claude/hooks/gctree-session-start.md', content: renderClaudeSessionStartHook() },
162
+ { path: '.claude/commands/gc-resolve-context.md', content: renderClaudeResolveCommand() },
163
+ { path: '.claude/commands/gc-onboard.md', content: renderClaudeOnboardCommand() },
164
+ { path: '.claude/commands/gc-update-global-context.md', content: renderClaudeUpdateCommand() },
165
+ ];
166
+ }
167
+ export async function scaffoldHostIntegration({ host, targetDir, force = false, }) {
168
+ const files = scaffoldFiles(host);
169
+ const written = [];
170
+ const skippedExisting = [];
171
+ const targets = files.map((file) => ({
172
+ ...file,
173
+ fullPath: join(targetDir, file.path),
174
+ }));
175
+ for (const target of targets) {
176
+ let exists = false;
177
+ try {
178
+ await access(target.fullPath);
179
+ exists = true;
180
+ }
181
+ catch {
182
+ exists = false;
183
+ }
184
+ if (exists && !force) {
185
+ skippedExisting.push(target.fullPath);
186
+ continue;
187
+ }
188
+ await mkdir(dirname(target.fullPath), { recursive: true });
189
+ await writeFile(target.fullPath, `${target.content.trimEnd()}
190
+ `, 'utf8');
191
+ written.push(target.fullPath);
192
+ }
193
+ return {
194
+ host,
195
+ target_dir: targetDir,
196
+ written,
197
+ skipped_existing: skippedExisting,
198
+ };
199
+ }
@@ -0,0 +1,30 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { settingsPath } from './paths.js';
3
+ export async function readSettings(home) {
4
+ try {
5
+ const raw = await readFile(settingsPath(home), 'utf8');
6
+ return JSON.parse(raw);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ export async function writeSettings({ home, providerMode, preferredProvider, preferredLanguage, }) {
13
+ await mkdir(home, { recursive: true });
14
+ const settings = {
15
+ version: 1,
16
+ provider_mode: providerMode,
17
+ preferred_provider: preferredProvider,
18
+ preferred_language: preferredLanguage.trim() || 'English',
19
+ updated_at: new Date().toISOString(),
20
+ };
21
+ await writeFile(settingsPath(home), `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
22
+ return settings;
23
+ }
24
+ export async function requirePreferredProvider(home) {
25
+ const settings = await readSettings(home);
26
+ if (!settings?.preferred_provider) {
27
+ throw new Error('preferred provider is not configured. Run `gctree init` first.');
28
+ }
29
+ return settings.preferred_provider;
30
+ }