@first-tree-ai/context-tree 0.1.5 → 0.1.7-alpha.202609010710

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 (35) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +7 -3
  3. package/.codex-plugin/plugin.json +16 -9
  4. package/README.md +150 -117
  5. package/dist/cli/index.mjs +955 -573
  6. package/dist/index.d.mts +64 -15
  7. package/dist/index.mjs +833 -471
  8. package/dist/{schemas-C4bs-FkC.d.mts → schemas-C_7izpsa.d.mts} +130 -134
  9. package/dist/{schemas-BWM6Q6iz.mjs → schemas-DKHE1sWt.mjs} +62 -51
  10. package/dist/schemas.d.mts +2 -2
  11. package/dist/schemas.mjs +2 -2
  12. package/docs/specification.md +158 -144
  13. package/hooks/session-start.mjs +5 -14
  14. package/package.json +1 -1
  15. package/policy/context-tree-policy.md +10 -12
  16. package/skills/context-tree-connect/SKILL.md +34 -0
  17. package/skills/context-tree-connect/agents/openai.yaml +4 -0
  18. package/skills/context-tree-create/SKILL.md +32 -0
  19. package/skills/context-tree-create/agents/openai.yaml +4 -0
  20. package/skills/context-tree-publish/SKILL.md +26 -0
  21. package/skills/context-tree-publish/agents/openai.yaml +4 -0
  22. package/skills/context-tree-publish/scripts/context-tree.mjs +41 -0
  23. package/skills/context-tree-read/SKILL.md +20 -43
  24. package/skills/context-tree-read/agents/openai.yaml +2 -2
  25. package/skills/context-tree-setup/SKILL.md +33 -0
  26. package/skills/context-tree-setup/agents/openai.yaml +4 -0
  27. package/skills/context-tree-setup/scripts/context-tree.mjs +41 -0
  28. package/skills/context-tree-write/SKILL.md +29 -125
  29. package/skills/context-tree-write/agents/openai.yaml +2 -2
  30. package/skills/context-tree-init/SKILL.md +0 -51
  31. package/skills/context-tree-init/agents/openai.yaml +0 -4
  32. package/skills/context-tree-link/SKILL.md +0 -45
  33. package/skills/context-tree-link/agents/openai.yaml +0 -4
  34. /package/skills/{context-tree-init → context-tree-connect}/scripts/context-tree.mjs +0 -0
  35. /package/skills/{context-tree-link → context-tree-create}/scripts/context-tree.mjs +0 -0
package/dist/index.mjs CHANGED
@@ -1,57 +1,116 @@
1
- import { A as isRecord, C as githubRepositoryIdentitySchema, S as credentialFreeRepositoryUrlSchema, f as contextTreeLinkSchema, i as VALIDATION_CODES, k as parseMarkdownFrontmatter, t as CLI_ERROR_CODES, w as parseContextTreeRootNode } from "./schemas-BWM6Q6iz.mjs";
2
- import { spawnSync } from "node:child_process";
3
- import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { A as treeNameSchema, C as credentialFreeRepositoryUrlSchema, F as isRecord, O as parseContextTreeRootNode, P as parseMarkdownFrontmatter, T as githubRepositoryIdentitySchema, b as contextTreeStateSchema, f as contextTreeConnectionSchema, i as VALIDATION_CODES, t as CLI_ERROR_CODES } from "./schemas-DKHE1sWt.mjs";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
3
  import { homedir, tmpdir } from "node:os";
5
- import { basename, dirname, isAbsolute, join, parse, posix, relative, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path";
6
5
  import { z } from "zod";
6
+ import { spawnSync } from "node:child_process";
7
7
  import { fromMarkdown } from "mdast-util-from-markdown";
8
8
  import { fileURLToPath } from "node:url";
9
- //#region src/core/internal/filesystem.ts
10
- function readUtf8File(path) {
11
- return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
12
- }
9
+ //#region src/core/internal/errors.ts
10
+ /**
11
+ * A failure the CLI reports with a specific machine-readable code. Anything
12
+ * thrown as a plain Error is reported as CONTEXT_TREE_FAILED instead.
13
+ */
14
+ var ContextTreeError = class extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = "ContextTreeError";
19
+ this.code = code;
20
+ }
21
+ };
13
22
  //#endregion
14
- //#region src/core/internal/github-repository.ts
15
- function parseGitHubRepositoryIdentity(repository) {
16
- githubRepositoryIdentitySchema.parse(repository);
17
- return repository.split("/")[1] ?? "";
23
+ //#region src/core/internal/git.ts
24
+ function defaultRunner(command, args) {
25
+ const result = spawnSync(command, args, {
26
+ encoding: "utf8",
27
+ stdio: [
28
+ "ignore",
29
+ "pipe",
30
+ "pipe"
31
+ ]
32
+ });
33
+ return {
34
+ status: result.status,
35
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
36
+ stdout: typeof result.stdout === "string" ? result.stdout : ""
37
+ };
18
38
  }
19
- function repositoryIdentityFromGitHubUrl(repositoryUrl) {
20
- try {
21
- credentialFreeRepositoryUrlSchema.parse(repositoryUrl);
22
- } catch {
23
- throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
39
+ /** A failed Git or `gh` operation. Messages never include the argv. */
40
+ var CommandError = class extends Error {
41
+ command;
42
+ status;
43
+ stderr;
44
+ constructor(command, status, stderr, message) {
45
+ const detail = sanitizeCommandOutput(stderr).trim();
46
+ super(detail.length > 0 ? `${message}: ${detail}` : message);
47
+ this.name = "CommandError";
48
+ this.command = command;
49
+ this.status = status;
50
+ this.stderr = detail;
24
51
  }
25
- let host;
26
- let path;
27
- const scp = /^(?:git@)?([^:]+):(.+)$/u.exec(repositoryUrl);
28
- if (scp !== null && !repositoryUrl.includes("://")) {
29
- host = scp[1] ?? "";
30
- path = scp[2] ?? "";
31
- } else {
32
- let parsed;
33
- try {
34
- parsed = new URL(repositoryUrl);
35
- } catch {
36
- throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
37
- }
38
- if (parsed.password || (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username) throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
39
- host = parsed.hostname;
40
- path = parsed.pathname;
41
- }
42
- if (host.toLowerCase() !== "github.com") throw new Error("Context Tree origin must use github.com.");
43
- const identity = path.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "");
44
- try {
45
- parseGitHubRepositoryIdentity(identity);
46
- } catch {
47
- throw new Error("Context Tree origin must identify a safe GitHub OWNER/REPO repository.");
48
- }
49
- return identity;
52
+ };
53
+ /** Remove credentials and common access-token shapes before surfacing subprocess output. */
54
+ function sanitizeCommandOutput(value) {
55
+ return value.replace(/((?:https?|ssh):\/\/)[^\s/@]+@/giu, "$1<redacted>@").replace(/\b(?:gh[opsu]_[A-Za-z\d_]{20,}|github_pat_[A-Za-z\d_]{20,})\b/gu, "<redacted>").replace(/(authorization\s*:\s*(?:bearer|token)\s+)[^\s]+/giu, "$1<redacted>");
56
+ }
57
+ function trimOutput(value) {
58
+ return value.trim();
59
+ }
60
+ function execute(runner, command, args, message) {
61
+ const result = runner(command, args);
62
+ if (result.status !== 0) throw new CommandError(command, result.status, result.stderr, message);
63
+ return trimOutput(result.stdout);
50
64
  }
65
+ /** Run a Git command that is not scoped by `-C`, such as `git init <path>`. */
66
+ function gitCommand(args, options = {}) {
67
+ return execute(options.runner ?? defaultRunner, "git", args, options.message ?? "A Git operation failed.");
68
+ }
69
+ /** Run `git -C <root> <args>` and return trimmed stdout, throwing on failure. */
70
+ function git(root, args, options = {}) {
71
+ return execute(options.runner ?? defaultRunner, "git", [
72
+ "-C",
73
+ root,
74
+ ...args
75
+ ], options.message ?? "A Git operation failed.");
76
+ }
77
+ /** Run `git -C <root> <args>` and return trimmed stdout, or undefined on failure. */
78
+ function optionalGit(root, args, runner = defaultRunner) {
79
+ const result = runner("git", [
80
+ "-C",
81
+ root,
82
+ ...args
83
+ ]);
84
+ if (result.status !== 0) return void 0;
85
+ return trimOutput(result.stdout);
86
+ }
87
+ /** Run `gh <args>` and return trimmed stdout, throwing on failure. */
88
+ function gh(args, options = {}) {
89
+ return execute(options.runner ?? defaultRunner, "gh", args, options.message ?? "A GitHub CLI operation failed.");
90
+ }
91
+ //#endregion
92
+ //#region src/core/internal/github-repository.ts
93
+ const ORIGIN_MESSAGE = "Context Tree origin must identify a credential-free GitHub OWNER/REPO repository.";
51
94
  function canonicalGitHubRepositoryUrl(repository) {
52
- parseGitHubRepositoryIdentity(repository);
95
+ githubRepositoryIdentitySchema.parse(repository);
53
96
  return `https://github.com/${repository}.git`;
54
97
  }
98
+ /** Derive OWNER/REPO from a github.com origin, rejecting anything else. */
99
+ function gitHubRepositoryFromOriginUrl(origin) {
100
+ if (!credentialFreeRepositoryUrlSchema.safeParse(origin).success) throw new Error(ORIGIN_MESSAGE);
101
+ let owner;
102
+ let name;
103
+ const scp = /^(?:git@)?github\.com:([^/]+)\/(.+)$/iu.exec(origin);
104
+ if (scp !== null) [, owner, name] = scp;
105
+ else {
106
+ const url = URL.parse(origin);
107
+ if (url === null || url.hostname.toLowerCase() !== "github.com") throw new Error(ORIGIN_MESSAGE);
108
+ [owner, name] = url.pathname.replace(/^\/+|\/+$/gu, "").split("/");
109
+ }
110
+ const repository = `${owner ?? ""}/${(name ?? "").replace(/\.git$/iu, "")}`;
111
+ if (!githubRepositoryIdentitySchema.safeParse(repository).success) throw new Error(ORIGIN_MESSAGE);
112
+ return repository;
113
+ }
55
114
  //#endregion
56
115
  //#region src/core/path.ts
57
116
  function isPathInside(root, target) {
@@ -64,10 +123,42 @@ function resolveTreeRoot(path) {
64
123
  if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree root must be a real directory: ${path}`);
65
124
  return realpathSync(absolute);
66
125
  }
126
+ /** Resolve a directory while rejecting symlinks in user-controlled path components. */
127
+ function realDirectoryWithoutSymlinks(path, label) {
128
+ const absolute = resolve(path);
129
+ const parsed = parse(absolute);
130
+ const parts = absolute.slice(parsed.root.length).split(sep).filter(Boolean);
131
+ let current = parsed.root;
132
+ for (const [index, part] of parts.entries()) {
133
+ current = resolve(current, part);
134
+ if (lstatSync(current).isSymbolicLink()) if (index === 0) current = realpathSync(current);
135
+ else throw new Error(`${label} must contain no symlink component.`);
136
+ }
137
+ if (!lstatSync(current).isDirectory()) throw new Error(`${label} must be a directory.`);
138
+ return realpathSync(current);
139
+ }
67
140
  function toPosixPath(path) {
68
141
  return path.replace(/\\/gu, "/");
69
142
  }
70
143
  //#endregion
144
+ //#region src/core/internal/project.ts
145
+ /**
146
+ * Projects are identified solely by their canonical local root. A Git
147
+ * repository without an origin, a non-Git directory, a Git worktree, and a
148
+ * separate clone are all independent checkouts with their own canonical root.
149
+ */
150
+ function canonicalProjectRoot(path, runner) {
151
+ const directory = realDirectoryWithoutSymlinks(path, "Project path");
152
+ const toplevel = optionalGit(directory, ["rev-parse", "--show-toplevel"], runner);
153
+ if (toplevel === void 0 || toplevel.length === 0) return directory;
154
+ return realpathSync(toplevel);
155
+ }
156
+ //#endregion
157
+ //#region src/core/internal/filesystem.ts
158
+ function readUtf8File(path) {
159
+ return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
160
+ }
161
+ //#endregion
71
162
  //#region src/core/internal/content-class.ts
72
163
  const GENERATED_DIRECTORY_NAMES = new Set([
73
164
  "node_modules",
@@ -473,407 +564,326 @@ function verifyTree(treePath) {
473
564
  };
474
565
  }
475
566
  //#endregion
476
- //#region src/core/links.ts
477
- const contextTreeLinksFileSchema = z.object({
478
- links: z.array(contextTreeLinkSchema),
479
- schemaVersion: z.literal(1)
480
- }).strict();
481
- var LinkError = class extends Error {
482
- code;
483
- constructor(code, message) {
484
- super(message);
485
- this.name = "LinkError";
486
- this.code = code;
487
- }
488
- };
489
- function git$1(path, args) {
490
- const result = spawnSync("git", [
491
- "-C",
492
- path,
493
- ...args
567
+ //#region src/core/internal/tree-state.ts
568
+ /**
569
+ * The one tree-state resolver shared by create, connect, sync, publish, and
570
+ * writes. It validates a checkout exactly once and reports the discriminated
571
+ * tree state: a local-only tree, or a published tree with its GitHub
572
+ * OWNER/REPO identity. Resolution never backfills or mutates stored state.
573
+ */
574
+ /** Require a real directory with no symlink component that is an exact Git root. */
575
+ function exactGitRoot(treePath, runner) {
576
+ const root = realDirectoryWithoutSymlinks(treePath, "Context Tree path");
577
+ const toplevel = optionalGit(root, ["rev-parse", "--show-toplevel"], runner);
578
+ if (toplevel === void 0 || toplevel.length === 0) throw new Error("Context Tree path must be a Git repository.");
579
+ if (realpathSync(toplevel) !== root) throw new Error("Context Tree path must be the real Git root.");
580
+ return root;
581
+ }
582
+ /** Parse the root NODE.md, refusing symlinked or irregular files. */
583
+ function parseRootNode(root) {
584
+ const path = join(root, "NODE.md");
585
+ const entry = lstatSync(path);
586
+ if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Context Tree root NODE.md must be a regular file.");
587
+ return parseContextTreeRootNode(readUtf8File(path));
588
+ }
589
+ /**
590
+ * Validate a clean checkout without inferring state from mutable Git remotes.
591
+ * Uncommitted changes and invalid content each get their own code so callers
592
+ * can tell "commit your edits" apart from "this path is gone".
593
+ */
594
+ function validateTreeCheckout(treePath, runner) {
595
+ const root = exactGitRoot(treePath, runner);
596
+ if (git(root, [
597
+ "status",
598
+ "--porcelain",
599
+ "--untracked-files=all"
494
600
  ], {
495
- encoding: "utf8",
496
- stdio: [
497
- "ignore",
498
- "pipe",
499
- "ignore"
500
- ]
501
- });
502
- if (result.error !== void 0 || result.status !== 0) return void 0;
503
- return result.stdout.replace(/\r?\n$/u, "");
601
+ message: "Failed to inspect Context Tree cleanliness.",
602
+ runner
603
+ }).trim().length !== 0) throw new ContextTreeError(CLI_ERROR_CODES.dirtyTree, `The Context Tree at ${root} has uncommitted changes; commit or discard them.`);
604
+ if (!verifyTree(root).ok) throw new ContextTreeError(CLI_ERROR_CODES.invalidTree, `The Context Tree at ${root} is invalid; run context-tree verify --tree-path ${root}.`);
605
+ return root;
504
606
  }
505
- function requireGit$1(path, args, message, allowEmpty = false) {
506
- const value = git$1(path, args);
507
- if (value === void 0 || !allowEmpty && value.length === 0) throw new Error(message);
508
- return value;
607
+ /** Validate a stored state without reclassifying it from mutable Git remotes. */
608
+ function validateStoredTreeState(state, runner) {
609
+ const path = validateTreeCheckout(state.path, runner);
610
+ return state.kind === "local" ? {
611
+ kind: "local",
612
+ path
613
+ } : {
614
+ kind: "github",
615
+ path,
616
+ repository: state.repository
617
+ };
509
618
  }
510
- function normalizeRepositoryUrl(repositoryUrl) {
619
+ //#endregion
620
+ //#region src/core/connections.ts
621
+ const connectionsFileSchema = z.object({
622
+ connections: z.array(contextTreeConnectionSchema),
623
+ schemaVersion: z.literal(1)
624
+ }).strict();
625
+ const DUPLICATE_MESSAGE = "Duplicate Context Tree connection records exist for this project.";
626
+ const NO_CONNECTION_MESSAGE = "No Context Tree connection exists for this project; run context-tree create or connect.";
627
+ function realHome() {
511
628
  try {
512
- credentialFreeRepositoryUrlSchema.parse(repositoryUrl);
629
+ return realpathSync(homedir());
513
630
  } catch {
514
- throw new Error("Git origin must be a canonical credential-free repository URL.");
631
+ return homedir();
515
632
  }
516
- try {
517
- return canonicalGitHubRepositoryUrl(repositoryIdentityFromGitHubUrl(repositoryUrl).toLowerCase());
518
- } catch {}
519
- const scp = /^(?:([^@]+)@)?([^:]+):(.+)$/u.exec(repositoryUrl);
520
- if (scp !== null && !repositoryUrl.includes("://")) return `${scp[1] === void 0 ? "" : `${scp[1].toLowerCase()}@`}${(scp[2] ?? "").toLowerCase()}:${(scp[3] ?? "").replace(/\/+$/gu, "").replace(/\.git$/iu, "")}.git`;
521
- const parsed = new URL(repositoryUrl);
522
- parsed.hostname = parsed.hostname.toLowerCase();
523
- parsed.pathname = `${parsed.pathname.replace(/\/+$/gu, "").replace(/\.git$/iu, "")}.git`;
524
- return parsed.toString();
525
- }
526
- function realDirectory(path) {
527
- const absolute = resolve(path);
528
- if (!lstatSync(absolute).isDirectory()) throw new Error("Project path must be a directory.");
529
- return realpathSync(absolute);
530
633
  }
531
- function identifyProject(projectPath) {
532
- const path = realDirectory(projectPath);
533
- const gitRoot = git$1(path, ["rev-parse", "--show-toplevel"]);
534
- if (gitRoot === void 0) return {
535
- kind: "directory",
536
- path
537
- };
538
- return {
539
- kind: "git",
540
- origin: normalizeRepositoryUrl(requireGit$1(realpathSync(gitRoot), [
541
- "remote",
542
- "get-url",
543
- "origin"
544
- ], "Git project must have an origin remote."))
545
- };
634
+ /** Create a managed application directory below the home directory, failing closed on symlinks. */
635
+ function ensureManagedDirectory(...segments) {
636
+ let current = realHome();
637
+ for (const segment of segments) {
638
+ current = join(current, segment);
639
+ const entry = lstatSync(current, { throwIfNoEntry: false });
640
+ if (entry === void 0) {
641
+ mkdirSync(current, { mode: 448 });
642
+ continue;
643
+ }
644
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree managed directory must be a real directory: ${current}`);
645
+ }
646
+ return current;
546
647
  }
547
- function linksPath() {
548
- return join(homedir(), ".context-tree", "connections.json");
648
+ /** The managed namespace without creating it; listing must not create an absent directory. */
649
+ function managedTreesPath() {
650
+ return join(realHome(), ".context-tree", "trees");
549
651
  }
550
- function emptyLinks() {
551
- return {
552
- links: [],
553
- schemaVersion: 1
554
- };
652
+ function connectionsPath() {
653
+ return join(realHome(), ".context-tree", "connections.json");
555
654
  }
556
- function loadLinks(required) {
557
- const path = linksPath();
655
+ function managedTreesRoot() {
656
+ return ensureManagedDirectory(".context-tree", "trees");
657
+ }
658
+ function loadConnections(required) {
659
+ const path = connectionsPath();
558
660
  if (!existsSync(path)) {
559
- if (required) throw new LinkError(CLI_ERROR_CODES.noLink, "No Context Tree link exists for this project.");
560
- return emptyLinks();
661
+ if (required) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
662
+ return {
663
+ connections: [],
664
+ schemaVersion: 1
665
+ };
561
666
  }
562
667
  try {
563
668
  const entry = lstatSync(path);
564
669
  if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("not a regular file");
565
- return contextTreeLinksFileSchema.parse(JSON.parse(readFileSync(path, "utf8")));
670
+ return connectionsFileSchema.parse(JSON.parse(readFileSync(path, "utf8")));
566
671
  } catch {
567
- throw new LinkError(CLI_ERROR_CODES.corruptLink, "Context Tree links are corrupt; run link after repairing or removing the internal links file.");
672
+ throw new ContextTreeError(CLI_ERROR_CODES.corruptConnection, "Context Tree connections are corrupt; remove connections.json and run context-tree connect again.");
568
673
  }
569
674
  }
570
- function saveLinks(value) {
571
- const path = linksPath();
572
- const directory = dirname(path);
573
- mkdirSync(directory, {
574
- recursive: true,
575
- mode: 448
576
- });
577
- const directoryEntry = lstatSync(directory);
578
- if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) throw new Error("Context Tree links directory must be a real directory.");
579
- const temporary = join(directory, `.links-${process.pid}-${Date.now()}.tmp`);
580
- writeFileSync(temporary, `${JSON.stringify(contextTreeLinksFileSchema.parse(value), null, 2)}\n`, {
675
+ function saveConnections(value) {
676
+ const directory = ensureManagedDirectory(".context-tree");
677
+ const path = join(directory, "connections.json");
678
+ const temporary = join(directory, `.connections-${process.pid}-${Date.now()}.tmp`);
679
+ writeFileSync(temporary, `${JSON.stringify(connectionsFileSchema.parse(value), null, 2)}\n`, {
581
680
  encoding: "utf8",
582
681
  flag: "wx",
583
682
  mode: 384
584
683
  });
585
- renameSync(temporary, path);
586
- chmodSync(path, 384);
587
- }
588
- function exactCheckoutRoot(treePath) {
589
- const absolute = resolve(treePath);
590
- const entry = lstatSync(absolute);
591
- const root = realpathSync(absolute);
592
- if (!entry.isDirectory() || entry.isSymbolicLink() || absolute !== root) throw new Error("Context Tree checkout path must be a real directory with no symlink component.");
593
- if (realpathSync(requireGit$1(root, ["rev-parse", "--show-toplevel"], "Context Tree checkout must be a Git repository.")) !== root) throw new Error("Context Tree checkout must be the real Git root.");
594
- return root;
595
- }
596
- function checkoutRepository(root) {
597
- return repositoryIdentityFromGitHubUrl(requireGit$1(root, [
598
- "remote",
599
- "get-url",
600
- "origin"
601
- ], "Context Tree checkout must have an origin remote."));
602
- }
603
- function requireCheckoutClean(root, mode) {
604
- const status = requireGit$1(root, [
605
- "status",
606
- "--porcelain",
607
- "--untracked-files=all"
608
- ], "Failed to inspect Context Tree cleanliness.", true);
609
- if (status.length === 0) return;
610
- if (mode === "scaffold") {
611
- const lines = status.split("\n").sort();
612
- if (git$1(root, [
613
- "rev-parse",
614
- "--verify",
615
- "HEAD"
616
- ]) === void 0 && JSON.stringify(lines) === JSON.stringify([
617
- "?? .github/workflows/validate-context-tree.yml",
618
- "?? AGENTS.md",
619
- "?? CLAUDE.md",
620
- "?? NODE.md"
621
- ])) return;
684
+ try {
685
+ renameSync(temporary, path);
686
+ chmodSync(path, 384);
687
+ } finally {
688
+ rmSync(temporary, { force: true });
622
689
  }
623
- throw new Error("Context Tree checkout must be clean.");
624
690
  }
625
- function parseRootNode(root) {
626
- const path = join(root, "NODE.md");
627
- const entry = lstatSync(path);
628
- if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Context Tree root NODE.md must be a regular file.");
629
- return parseContextTreeRootNode(readUtf8File(path));
691
+ /** Exactly one record may exist per project; more than one is corruption. */
692
+ function singleConnection(stored, canonical) {
693
+ const matches = stored.connections.filter((connection) => connection.projectPath === canonical);
694
+ if (matches.length > 1) throw new ContextTreeError(CLI_ERROR_CODES.corruptConnection, DUPLICATE_MESSAGE);
695
+ return matches[0];
630
696
  }
631
- function verifyCheckout(treePath, mode) {
632
- const root = exactCheckoutRoot(treePath);
633
- requireCheckoutClean(root, mode);
634
- const repository = checkoutRepository(root);
635
- if (!verifyTree(root).ok) throw new Error("Context Tree checkout is invalid; run context-tree verify.");
636
- return {
637
- path: root,
638
- repository
639
- };
697
+ function isManagedName(value) {
698
+ return treeNameSchema.safeParse(value).success && value === value.toLowerCase();
640
699
  }
641
- function sameProject(left, right) {
642
- return left.kind === "git" && right.kind === "git" ? left.origin === right.origin : left.kind === "directory" && right.kind === "directory" && left.path === right.path;
643
- }
644
- function projectMatches(candidate, current) {
645
- if (candidate.kind === "git" && current.kind === "git") return candidate.origin === current.origin;
646
- if (candidate.kind === "directory" && current.kind === "directory") return isPathInside(candidate.path, current.path);
647
- return false;
648
- }
649
- function liveStoredCheckout(link) {
650
- try {
651
- const path = exactCheckoutRoot(link.tree.path);
652
- return {
653
- path,
654
- repository: checkoutRepository(path)
655
- };
656
- } catch {
657
- return;
658
- }
700
+ function managedName(value) {
701
+ if (!isManagedName(value)) throw new Error(`Managed Context Tree names must be safe lowercase path segments: ${value}`);
702
+ return value;
659
703
  }
660
- function linkWithMode(projectPath, treePath, mode) {
661
- const project = identifyProject(projectPath);
662
- const tree = verifyCheckout(treePath, mode);
663
- const stored = loadLinks(false);
664
- const existing = stored.links.filter((link) => sameProject(link.project, project));
665
- if (existing.length > 1) throw new LinkError(CLI_ERROR_CODES.ambiguousLink, "Multiple Context Tree links match this project.");
666
- const previous = existing[0];
667
- if (previous !== void 0) {
668
- if (previous.tree.repository.toLowerCase() !== tree.repository.toLowerCase()) throw new Error("A project cannot link to a different Context Tree repository.");
669
- if (previous.tree.path !== tree.path) {
670
- const live = liveStoredCheckout(previous);
671
- if (live !== void 0 && live.repository.toLowerCase() === previous.tree.repository.toLowerCase()) throw new Error("The existing Context Tree checkout is still live; replacement is allowed only when it is stale.");
672
- }
673
- }
674
- const link = {
675
- project,
676
- tree
704
+ /** List valid, clean managed trees, excluding unsafe or invalid candidates without failing the listing. */
705
+ function listManagedTrees(runner) {
706
+ const root = managedTreesPath();
707
+ if (!existsSync(root)) return {
708
+ schemaVersion: 1,
709
+ trees: []
677
710
  };
678
- saveLinks({
679
- links: [...stored.links.filter((candidate) => !sameProject(candidate.project, project)), link],
680
- schemaVersion: 1
681
- });
711
+ const rootEntry = lstatSync(root);
712
+ if (rootEntry.isSymbolicLink() || !rootEntry.isDirectory()) throw new Error("Context Tree managed directory must be a real directory.");
713
+ const trees = [];
714
+ for (const candidate of readdirSync(root, { withFileTypes: true })) {
715
+ if (!candidate.isDirectory() || !isManagedName(candidate.name)) continue;
716
+ try {
717
+ trees.push({
718
+ name: candidate.name,
719
+ tree: classifyCheckout(join(root, candidate.name), runner)
720
+ });
721
+ } catch {}
722
+ }
723
+ trees.sort((left, right) => left.name.localeCompare(right.name));
682
724
  return {
683
- link,
684
- schemaVersion: 1
725
+ schemaVersion: 1,
726
+ trees
685
727
  };
686
728
  }
687
- function linkProject(projectPath, treePath) {
688
- return linkWithMode(projectPath, treePath, "link");
729
+ function findConnectionRecord(projectPath, runner) {
730
+ return singleConnection(loadConnections(false), canonicalProjectRoot(projectPath, runner));
689
731
  }
690
- function resolveLink(projectPath) {
691
- const project = identifyProject(projectPath);
692
- const matches = loadLinks(true).links.filter((link) => projectMatches(link.project, project));
693
- if (matches.length === 0) throw new LinkError(CLI_ERROR_CODES.noLink, "No Context Tree link exists for this project.");
694
- if (matches.length > 1) throw new LinkError(CLI_ERROR_CODES.ambiguousLink, "Multiple Context Tree links match this project.");
695
- const link = matches[0];
696
- if (link === void 0) throw new Error("Link lookup failed.");
732
+ function validateManagedTreeState(tree, runner) {
733
+ const validated = validateStoredTreeState(tree, runner);
734
+ if (dirname(validated.path) === managedTreesPath() && !isManagedName(basename(validated.path))) throw new Error(`Managed Context Tree names must be safe lowercase path segments: ${basename(validated.path)}`);
735
+ return validated;
736
+ }
737
+ function resolveConnectionRecord(projectPath, runner) {
738
+ const canonical = canonicalProjectRoot(projectPath, runner);
739
+ const connection = singleConnection(loadConnections(true), canonical);
740
+ if (connection === void 0) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
697
741
  try {
698
- const root = exactCheckoutRoot(link.tree.path);
699
- requireCheckoutClean(root, "link");
700
- const repository = checkoutRepository(root);
701
- if (repository.toLowerCase() !== link.tree.repository.toLowerCase()) throw new Error("The linked path now contains a different Context Tree repository.");
702
- parseRootNode(root);
703
742
  return {
704
- link: {
705
- project: link.project,
706
- tree: {
707
- path: root,
708
- repository
709
- }
710
- },
711
- schemaVersion: 1
743
+ projectPath: connection.projectPath,
744
+ tree: validateManagedTreeState(connection.tree, runner)
712
745
  };
713
746
  } catch (error) {
714
- const message = error instanceof Error && error.message === "The linked path now contains a different Context Tree repository." ? error.message : "The linked Context Tree checkout is no longer a valid clean candidate; run link when its stored path is stale.";
715
- throw new LinkError(CLI_ERROR_CODES.staleLink, message);
747
+ if (error instanceof ContextTreeError) throw error;
748
+ const detail = error instanceof Error ? error.message : "unknown failure";
749
+ throw new ContextTreeError(CLI_ERROR_CODES.staleConnection, `The connected Context Tree is no longer usable at ${connection.tree.path}; run context-tree connect to point this project at its current location. ${detail}`);
716
750
  }
717
751
  }
718
- //#endregion
719
- //#region src/core/live.ts
720
- function git(root, args) {
721
- const result = spawnSync("git", [
722
- "-C",
723
- root,
724
- ...args
725
- ], {
726
- encoding: "utf8",
727
- stdio: [
728
- "ignore",
729
- "pipe",
730
- "ignore"
731
- ]
732
- });
733
- if (result.error !== void 0 || result.status !== 0) throw new Error("A Git operation failed while preparing the Context Tree.");
734
- return typeof result.stdout === "string" ? result.stdout : "";
735
- }
736
- function requireGit(root, args, allowEmpty = false) {
737
- const output = git(root, args).trim();
738
- if (output.length === 0 && !allowEmpty) throw new Error("Unexpected empty Git output.");
739
- return output;
740
- }
741
- function discoverDefaultBranch(root) {
742
- const refs = git(root, [
743
- "ls-remote",
744
- "--symref",
745
- "origin",
746
- "HEAD"
747
- ]).split("\n").map((line) => /^ref: refs\/heads\/([^\s\t]+)\tHEAD$/u.exec(line)?.[1]).filter((value) => value !== void 0 && value.length > 0);
748
- if (refs.length !== 1) throw new Error("The Context Tree origin must report exactly one live default branch.");
749
- return refs[0] ?? "";
750
- }
751
- function refreshProject(projectPath) {
752
- const result = resolveLink(projectPath);
753
- const root = result.link.tree.path;
754
- const defaultBranch = discoverDefaultBranch(root);
755
- if (requireGit(root, [
756
- "symbolic-ref",
757
- "--short",
758
- "HEAD"
759
- ]) !== defaultBranch) throw new Error(`The Context Tree checkout must be on the live default branch "${defaultBranch}".`);
760
- const before = requireGit(root, ["rev-parse", "HEAD"]);
761
- requireGit(root, [
762
- "pull",
763
- "--ff-only",
764
- "origin",
765
- defaultBranch
766
- ]);
767
- const after = requireGit(root, ["rev-parse", "HEAD"]);
752
+ function resolveConnection(projectPath, runner) {
768
753
  return {
769
- link: {
770
- ...result.link,
771
- tree: {
772
- ...result.link.tree,
773
- path: realpathSync(root)
774
- }
775
- },
776
- defaultBranch,
777
- refreshed: before !== after,
778
754
  schemaVersion: 1,
779
- sha: after
755
+ tree: resolveConnectionRecord(projectPath, runner).tree
780
756
  };
781
757
  }
782
- function createIsolatedWorktree(treePath, baseSha) {
783
- const destination = mkdtempSync(join(tmpdir(), "context-tree-stage-"));
784
- const taskBranch = `context-tree/write/${basename(destination)}`;
785
- git(treePath, [
786
- "worktree",
787
- "add",
788
- "-b",
789
- taskBranch,
790
- destination,
791
- baseSha
792
- ]);
758
+ function upsertConnection(connection, runner) {
759
+ const canonical = canonicalProjectRoot(connection.projectPath, runner);
760
+ const record = {
761
+ projectPath: canonical,
762
+ tree: validateManagedTreeState(contextTreeStateSchema.parse(connection.tree), runner)
763
+ };
764
+ const stored = loadConnections(false);
765
+ const previous = singleConnection(stored, canonical);
766
+ if (previous !== void 0 && JSON.stringify(previous.tree) === JSON.stringify(record.tree)) return {
767
+ schemaVersion: 1,
768
+ tree: record.tree
769
+ };
770
+ saveConnections({
771
+ connections: [...stored.connections.filter((candidate) => candidate.projectPath !== canonical), record],
772
+ schemaVersion: 1
773
+ });
793
774
  return {
794
- taskBranch,
795
- worktreePath: realpathSync(destination)
775
+ schemaVersion: 1,
776
+ tree: record.tree
796
777
  };
797
778
  }
798
- function stageContextWrite(projectPath) {
799
- const result = resolveLink(projectPath);
800
- const treePath = result.link.tree.path;
801
- const ownerRepository = result.link.tree.repository;
802
- const defaultBranch = discoverDefaultBranch(treePath);
803
- if (requireGit(treePath, [
804
- "symbolic-ref",
805
- "--short",
806
- "HEAD"
807
- ]) !== defaultBranch) throw new Error(`The Context Tree checkout must be on the live default branch "${defaultBranch}".`);
808
- requireGit(treePath, [
809
- "fetch",
810
- "origin",
811
- defaultBranch
812
- ], true);
813
- const baseSha = requireGit(treePath, ["rev-parse", `origin/${defaultBranch}`]);
814
- const { taskBranch, worktreePath } = createIsolatedWorktree(treePath, baseSha);
779
+ function updateConnectionTree(projectPath, tree, runner) {
780
+ const canonical = canonicalProjectRoot(projectPath, runner);
781
+ const stored = loadConnections(true);
782
+ if (singleConnection(stored, canonical) === void 0) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
783
+ const validatedTree = validateManagedTreeState(contextTreeStateSchema.parse(tree), runner);
784
+ saveConnections({
785
+ connections: stored.connections.map((connection) => connection.projectPath === canonical ? {
786
+ ...connection,
787
+ tree: validatedTree
788
+ } : connection),
789
+ schemaVersion: 1
790
+ });
791
+ }
792
+ /**
793
+ * Validate an exact checkout and classify it from a safe origin: no origin
794
+ * is local state, a credential-free GitHub origin is GitHub state, and any
795
+ * other origin is rejected as unsafe or unsupported.
796
+ */
797
+ function classifyCheckout(path, runner) {
798
+ const root = validateTreeCheckout(path, runner);
799
+ const origin = optionalGit(root, [
800
+ "remote",
801
+ "get-url",
802
+ "origin"
803
+ ], runner);
804
+ if (origin === void 0) return {
805
+ kind: "local",
806
+ path: root
807
+ };
815
808
  return {
816
- baseSha,
817
- link: {
818
- project: result.link.project,
819
- tree: {
820
- path: treePath,
821
- repository: ownerRepository
822
- }
823
- },
824
- defaultBranch,
825
- schemaVersion: 1,
826
- taskBranch,
827
- worktreePath
809
+ kind: "github",
810
+ path: root,
811
+ repository: gitHubRepositoryFromOriginUrl(origin)
828
812
  };
829
813
  }
830
- const DIFF_STATUS = {
831
- A: "added",
832
- D: "deleted",
833
- M: "modified",
834
- R: "renamed"
835
- };
836
- function changedFiles(root, base) {
837
- const output = git(root, [
838
- "diff",
839
- "--name-status",
840
- base
841
- ]);
842
- const files = [];
843
- for (const line of output.split("\n")) {
844
- const match = /^([ADMR])\s+(.+)$/u.exec(line);
845
- if (match === null) continue;
846
- const status = DIFF_STATUS[match[1]];
847
- if (status === void 0) continue;
848
- files.push({
849
- path: match[2] ?? "",
850
- status
851
- });
814
+ function sameRepository(left, right) {
815
+ return left.toLowerCase() === right.toLowerCase();
816
+ }
817
+ /** An existing managed directory that must be a real directory, not a symlinked alias. */
818
+ function realManagedDirectory(name, destination) {
819
+ const entry = lstatSync(destination);
820
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Managed Context Tree name ${name} is occupied by an unsafe destination.`);
821
+ }
822
+ /**
823
+ * Connect by exact managed name or GitHub OWNER/REPO, or attach an exact,
824
+ * clean, fully valid Git checkout at an explicit disk path in place.
825
+ */
826
+ function connectProject(options, runner) {
827
+ if ("treePath" in options) {
828
+ const tree = classifyCheckout(options.treePath, runner);
829
+ return upsertConnection({
830
+ projectPath: options.projectPath,
831
+ tree
832
+ }, runner);
852
833
  }
853
- const porcelain = git(root, [
854
- "status",
855
- "--porcelain",
856
- "--untracked-files=all"
857
- ]);
858
- for (const line of porcelain.split("\n")) {
859
- const path = /^\?\?\s+(.+)$/u.exec(line)?.[1];
860
- if (path !== void 0 && path.length > 0) files.push({
861
- path,
862
- status: "added"
834
+ const treesRoot = managedTreesRoot();
835
+ if (!options.target.includes("/")) {
836
+ const name = managedName(options.target);
837
+ const destination = join(treesRoot, name);
838
+ if (!existsSync(destination)) throw new Error(`No managed Context Tree named ${name} exists.`);
839
+ realManagedDirectory(name, destination);
840
+ const tree = classifyCheckout(destination, runner);
841
+ return upsertConnection({
842
+ projectPath: options.projectPath,
843
+ tree
844
+ }, runner);
845
+ }
846
+ const repository = githubRepositoryIdentitySchema.parse(options.target);
847
+ const repositoryName = repository.split("/")[1];
848
+ if (repositoryName === void 0) throw new Error("Repository must be OWNER/REPO.");
849
+ const name = managedName(repositoryName.toLowerCase());
850
+ const destination = join(treesRoot, name);
851
+ if (existsSync(destination)) {
852
+ realManagedDirectory(name, destination);
853
+ const tree = classifyCheckout(destination, runner);
854
+ if (tree.kind !== "github" || !sameRepository(tree.repository, repository)) throw new Error(`Managed Context Tree name ${name} is already used by a different tree.`);
855
+ return upsertConnection({
856
+ projectPath: options.projectPath,
857
+ tree
858
+ }, runner);
859
+ }
860
+ mkdirSync(destination, { mode: 448 });
861
+ try {
862
+ git(treesRoot, [
863
+ "clone",
864
+ "--quiet",
865
+ "--origin",
866
+ "origin",
867
+ "--",
868
+ canonicalGitHubRepositoryUrl(repository),
869
+ destination
870
+ ], {
871
+ message: "Cloning the Context Tree repository failed.",
872
+ runner
873
+ });
874
+ const tree = classifyCheckout(destination, runner);
875
+ if (tree.kind !== "github" || !sameRepository(tree.repository, repository)) throw new Error("The cloned Context Tree origin does not match the requested repository.");
876
+ return upsertConnection({
877
+ projectPath: options.projectPath,
878
+ tree
879
+ }, runner);
880
+ } catch (error) {
881
+ rmSync(destination, {
882
+ force: true,
883
+ recursive: true
863
884
  });
885
+ throw error;
864
886
  }
865
- return files;
866
- }
867
- function inspectContextTreeDiff(treePath, base) {
868
- const root = realpathSync(treePath);
869
- const reference = base ?? "HEAD";
870
- return {
871
- base: reference,
872
- files: changedFiles(root, reference),
873
- patch: git(root, ["diff", reference]),
874
- schemaVersion: 1,
875
- treePath: root
876
- };
877
887
  }
878
888
  //#endregion
879
889
  //#region src/core/internal/packaged-resource.ts
@@ -913,6 +923,173 @@ function readPackageVersion() {
913
923
  return manifest.version;
914
924
  }
915
925
  //#endregion
926
+ //#region src/core/scaffold.ts
927
+ function template(name, values) {
928
+ let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
929
+ for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
930
+ return result;
931
+ }
932
+ /** Templated regular files, written before the CLAUDE.md -> AGENTS.md symlink. */
933
+ const TEMPLATED_FILES = [
934
+ ["NODE.md", "root-node.md"],
935
+ ["AGENTS.md", "AGENTS.md"],
936
+ [".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
937
+ ];
938
+ const SCAFFOLD_FILES = [
939
+ "NODE.md",
940
+ "AGENTS.md",
941
+ "CLAUDE.md",
942
+ ".github/workflows/validate-context-tree.yml"
943
+ ];
944
+ function initializeGitRepository(root, runner) {
945
+ gitCommand([
946
+ "init",
947
+ "--quiet",
948
+ "--",
949
+ root
950
+ ], {
951
+ message: "Failed to initialize Git repository.",
952
+ runner
953
+ });
954
+ return git(root, [
955
+ "symbolic-ref",
956
+ "--short",
957
+ "HEAD"
958
+ ], {
959
+ message: "Failed to resolve the initial Git branch during repository initialization.",
960
+ runner
961
+ });
962
+ }
963
+ function commitScaffold(root, runner) {
964
+ for (const file of SCAFFOLD_FILES) git(root, [
965
+ "add",
966
+ "--",
967
+ file
968
+ ], {
969
+ message: "Failed to stage the scaffold files.",
970
+ runner
971
+ });
972
+ git(root, [
973
+ "-c",
974
+ "user.name=Context Tree",
975
+ "-c",
976
+ "user.email=context-tree@localhost",
977
+ "-c",
978
+ "commit.gpgsign=false",
979
+ "commit",
980
+ "--quiet",
981
+ "-m",
982
+ "Initialize Context Tree"
983
+ ], {
984
+ message: "Failed to commit the scaffold.",
985
+ runner
986
+ });
987
+ return git(root, ["rev-parse", "HEAD"], {
988
+ message: "Failed to resolve the scaffold commit.",
989
+ runner
990
+ });
991
+ }
992
+ function scaffoldTree(options) {
993
+ const name = treeNameSchema.parse(options.name);
994
+ const root = resolve(options.path);
995
+ const destination = lstatSync(root, { throwIfNoEntry: false });
996
+ if (destination !== void 0) {
997
+ if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
998
+ if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
999
+ }
1000
+ const initialBranch = initializeGitRepository(root, options.runner);
1001
+ const values = {
1002
+ branchJson: JSON.stringify(initialBranch),
1003
+ packageVersion: readPackageVersion(),
1004
+ title: name,
1005
+ titleJson: JSON.stringify(name)
1006
+ };
1007
+ for (const [relativePath, source] of TEMPLATED_FILES) {
1008
+ const path = join(root, relativePath);
1009
+ mkdirSync(dirname(path), { recursive: true });
1010
+ writeFileSync(path, template(source, values), {
1011
+ encoding: "utf8",
1012
+ flag: "wx",
1013
+ mode: 420
1014
+ });
1015
+ }
1016
+ symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
1017
+ if (!verifyTree(root).ok) throw new Error("Refusing to commit an invalid Context Tree scaffold.");
1018
+ return {
1019
+ branch: initialBranch,
1020
+ commit: commitScaffold(root, options.runner),
1021
+ root
1022
+ };
1023
+ }
1024
+ //#endregion
1025
+ //#region src/core/create.ts
1026
+ function projectName(canonicalRoot) {
1027
+ const normalized = basename(canonicalRoot).toLowerCase().replace(/[^a-z\d._-]+/gu, "-").replace(/-{2,}/gu, "-").replace(/^[-.]+/u, "").replace(/[-.]+$/u, "").slice(0, 40);
1028
+ return /^[a-z\d]/u.test(normalized) ? normalized : "project";
1029
+ }
1030
+ function existingCreateResult(destination, runner) {
1031
+ return {
1032
+ branch: git(destination, [
1033
+ "symbolic-ref",
1034
+ "--short",
1035
+ "HEAD"
1036
+ ], {
1037
+ message: "Failed to resolve the managed tree branch.",
1038
+ runner
1039
+ }),
1040
+ commitSha: git(destination, ["rev-parse", "HEAD"], {
1041
+ message: "Failed to resolve the managed tree commit.",
1042
+ runner
1043
+ }),
1044
+ created: false,
1045
+ schemaVersion: 1,
1046
+ title: parseRootNode(destination).frontmatter.title,
1047
+ treePath: destination
1048
+ };
1049
+ }
1050
+ /** Create and connect the project's uniquely named managed local Context Tree. */
1051
+ function createProject(projectPath, runner) {
1052
+ const canonical = canonicalProjectRoot(projectPath, runner);
1053
+ const name = treeNameSchema.parse(`${projectName(canonical)}-context-tree`);
1054
+ const destination = join(managedTreesRoot(), name);
1055
+ const current = findConnectionRecord(canonical, runner);
1056
+ if (current !== void 0 && current.tree.path !== destination) throw new Error(`This project is already connected to a Context Tree at ${current.tree.path}; run context-tree connect ${name} to switch.`);
1057
+ if (existsSync(destination)) {
1058
+ const entry = lstatSync(destination);
1059
+ if (entry.isSymbolicLink() || !entry.isDirectory() || current === void 0) throw new Error(`Managed Context Tree name ${name} is occupied; run context-tree connect ${name}.`);
1060
+ return existingCreateResult(destination, runner);
1061
+ }
1062
+ mkdirSync(destination, { mode: 448 });
1063
+ try {
1064
+ const scaffold = scaffoldTree({
1065
+ name,
1066
+ path: destination,
1067
+ runner
1068
+ });
1069
+ upsertConnection({
1070
+ projectPath: canonical,
1071
+ tree: {
1072
+ kind: "local",
1073
+ path: scaffold.root
1074
+ }
1075
+ }, runner);
1076
+ return {
1077
+ branch: scaffold.branch,
1078
+ commitSha: scaffold.commit,
1079
+ created: true,
1080
+ schemaVersion: 1,
1081
+ title: name,
1082
+ treePath: scaffold.root
1083
+ };
1084
+ } catch (error) {
1085
+ rmSync(destination, {
1086
+ force: true,
1087
+ recursive: true
1088
+ });
1089
+ throw error;
1090
+ }
1091
+ }
1092
+ //#endregion
916
1093
  //#region src/core/policy.ts
917
1094
  function readContextTreePolicy() {
918
1095
  return {
@@ -921,6 +1098,97 @@ function readContextTreePolicy() {
921
1098
  };
922
1099
  }
923
1100
  //#endregion
1101
+ //#region src/core/publish.ts
1102
+ function authenticatedAccount(runner) {
1103
+ let login;
1104
+ try {
1105
+ login = gh([
1106
+ "api",
1107
+ "user",
1108
+ "--jq",
1109
+ ".login"
1110
+ ], {
1111
+ message: "GitHub account lookup failed.",
1112
+ runner
1113
+ });
1114
+ } catch (error) {
1115
+ if (error instanceof CommandError && /gh auth login|not logged|authentication failed|http 401|bad credentials/iu.test(error.stderr)) throw new ContextTreeError(CLI_ERROR_CODES.githubAuth, "GitHub authentication failed; run gh auth login before publishing.");
1116
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub account lookup failed; publication did not start and must not be retried automatically.");
1117
+ }
1118
+ if (login.trim().length === 0) throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub account lookup returned no repository owner.");
1119
+ return login.trim();
1120
+ }
1121
+ function classifyCreationFailure(stderr) {
1122
+ if (/already exists/iu.test(stderr)) return new ContextTreeError(CLI_ERROR_CODES.repositoryExists, "A GitHub repository with this name already exists; choose an explicit OWNER/REPO override.");
1123
+ return new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub repository creation has an uncertain or partial result; do not retry automatically.");
1124
+ }
1125
+ /**
1126
+ * Publish a clean, valid local tree as a private GitHub repository. The
1127
+ * default repository name derives from the authenticated account and managed
1128
+ * tree name; OWNER/REPO is accepted only as an explicit override. The initial
1129
+ * publication is one gh repo create operation, and the stored connection is
1130
+ * updated atomically to the published tree state.
1131
+ */
1132
+ function publishProject(projectPath, options = {}, runner) {
1133
+ const connection = resolveConnectionRecord(projectPath, runner);
1134
+ const root = connection.tree.path;
1135
+ if (connection.tree.kind === "github") throw new ContextTreeError(CLI_ERROR_CODES.failed, `The Context Tree is already published as ${connection.tree.repository}; writes publish new commits automatically.`);
1136
+ if (optionalGit(root, [
1137
+ "remote",
1138
+ "get-url",
1139
+ "origin"
1140
+ ], runner) !== void 0) throw new ContextTreeError(CLI_ERROR_CODES.failed, "A local Context Tree must not already have an origin before publication.");
1141
+ const branch = git(root, [
1142
+ "symbolic-ref",
1143
+ "--short",
1144
+ "HEAD"
1145
+ ], {
1146
+ message: "Failed to resolve the checked-out branch.",
1147
+ runner
1148
+ });
1149
+ const sha = git(root, ["rev-parse", "HEAD"], {
1150
+ message: "Failed to resolve the Context Tree commit.",
1151
+ runner
1152
+ });
1153
+ const repository = options.repository === void 0 ? `${authenticatedAccount(runner)}/${basename(root)}` : githubRepositoryIdentitySchema.parse(options.repository);
1154
+ const url = canonicalGitHubRepositoryUrl(repository);
1155
+ try {
1156
+ gh([
1157
+ "repo",
1158
+ "create",
1159
+ repository,
1160
+ "--private",
1161
+ "--source",
1162
+ root,
1163
+ "--remote",
1164
+ "origin",
1165
+ "--push"
1166
+ ], {
1167
+ message: "GitHub repository creation failed.",
1168
+ runner
1169
+ });
1170
+ } catch (error) {
1171
+ if (error instanceof CommandError) throw classifyCreationFailure(error.stderr);
1172
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub publication ended with an uncertain result.");
1173
+ }
1174
+ try {
1175
+ updateConnectionTree(connection.projectPath, {
1176
+ kind: "github",
1177
+ path: root,
1178
+ repository
1179
+ }, runner);
1180
+ } catch {
1181
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "The private repository was created, but updating the local connection failed.");
1182
+ }
1183
+ return {
1184
+ branch,
1185
+ repository,
1186
+ schemaVersion: 1,
1187
+ sha,
1188
+ url
1189
+ };
1190
+ }
1191
+ //#endregion
924
1192
  //#region src/core/read.ts
925
1193
  function normalizeTreeTarget(value) {
926
1194
  if (!value || value === ".") return "";
@@ -991,97 +1259,191 @@ function readTree(treePath, path) {
991
1259
  };
992
1260
  }
993
1261
  //#endregion
994
- //#region src/core/scaffold.ts
995
- function template(name, values) {
996
- let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
997
- for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
998
- return result;
999
- }
1000
- function initializeGitRepository(root, repository) {
1001
- const initialized = spawnSync("git", [
1002
- "init",
1003
- "--quiet",
1004
- root
1005
- ], { stdio: "ignore" });
1006
- if (initialized.error !== void 0 || initialized.status !== 0) throw new Error("Failed to initialize Git repository.");
1007
- const branch = spawnSync("git", [
1008
- "-C",
1009
- root,
1262
+ //#region src/core/sync.ts
1263
+ /**
1264
+ * Local trees report their checked-out state without network access. GitHub
1265
+ * trees fast-forward the exact checked-out branch once, then revalidate.
1266
+ */
1267
+ function syncProject(projectPath, runner) {
1268
+ const connection = resolveConnectionRecord(projectPath, runner);
1269
+ const root = connection.tree.path;
1270
+ const branch = git(root, [
1010
1271
  "symbolic-ref",
1011
1272
  "--short",
1012
1273
  "HEAD"
1013
1274
  ], {
1014
- encoding: "utf8",
1015
- stdio: [
1016
- "ignore",
1017
- "pipe",
1018
- "ignore"
1019
- ]
1275
+ message: "Failed to resolve the checked-out branch.",
1276
+ runner
1020
1277
  });
1021
- const name = branch.stdout.replace(/\r?\n$/u, "");
1022
- if (branch.error !== void 0 || branch.status !== 0 || name.length === 0) throw new Error("Failed to resolve the initial Git branch during repository initialization.");
1023
- const remote = spawnSync("git", [
1024
- "-C",
1025
- root,
1026
- "remote",
1027
- "add",
1028
- "origin",
1029
- canonicalGitHubRepositoryUrl(repository)
1030
- ], { stdio: "ignore" });
1031
- if (remote.error !== void 0 || remote.status !== 0) throw new Error("Failed to configure the credential-free Context Tree origin.");
1032
- return name;
1033
- }
1034
- function scaffoldTree(options) {
1035
- const title = parseGitHubRepositoryIdentity(options.repository);
1036
- const root = resolve(options.path);
1037
- const destination = lstatSync(root, { throwIfNoEntry: false });
1038
- if (destination !== void 0) {
1039
- if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
1040
- if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
1278
+ if (connection.tree.kind === "github") {
1279
+ git(root, [
1280
+ "pull",
1281
+ "--ff-only",
1282
+ "origin",
1283
+ branch
1284
+ ], {
1285
+ message: "Fast-forwarding the Context Tree failed.",
1286
+ runner
1287
+ });
1288
+ validateStoredTreeState(connection.tree, runner);
1041
1289
  }
1042
- const initialBranch = initializeGitRepository(root, options.repository);
1043
- const values = {
1044
- branchJson: JSON.stringify(initialBranch),
1045
- packageVersion: readPackageVersion(),
1046
- title,
1047
- titleJson: JSON.stringify(title)
1290
+ return {
1291
+ branch,
1292
+ schemaVersion: 1,
1293
+ sha: git(root, ["rev-parse", "HEAD"], {
1294
+ message: "Failed to resolve the Context Tree commit.",
1295
+ runner
1296
+ }),
1297
+ tree: connection.tree
1048
1298
  };
1049
- const regularFiles = [
1050
- ["NODE.md", "root-node.md"],
1051
- ["AGENTS.md", "AGENTS.md"],
1052
- [".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
1053
- ];
1054
- const files = [
1055
- "NODE.md",
1056
- "AGENTS.md",
1057
- "CLAUDE.md",
1058
- ".github/workflows/validate-context-tree.yml"
1059
- ];
1060
- for (const [relativePath, source] of regularFiles.slice(0, 2)) {
1061
- const path = join(root, relativePath);
1062
- mkdirSync(dirname(path), { recursive: true });
1063
- writeFileSync(path, template(source, values), {
1064
- encoding: "utf8",
1065
- flag: "wx",
1066
- mode: 420
1299
+ }
1300
+ //#endregion
1301
+ //#region src/core/write.ts
1302
+ const TASK_BRANCH_PREFIX = "context-tree/write/";
1303
+ /** Synchronize first, then create an isolated task worktree at the exact HEAD. */
1304
+ function prepareContextWrite(projectPath, runner) {
1305
+ const synchronized = syncProject(projectPath, runner);
1306
+ const root = synchronized.tree.path;
1307
+ const destination = mkdtempSync(join(tmpdir(), "context-tree-write-"));
1308
+ const taskBranch = `${TASK_BRANCH_PREFIX}${basename(destination)}`;
1309
+ try {
1310
+ git(root, [
1311
+ "worktree",
1312
+ "add",
1313
+ "--quiet",
1314
+ "-b",
1315
+ taskBranch,
1316
+ destination,
1317
+ synchronized.sha
1318
+ ], {
1319
+ message: "Creating the isolated write worktree failed.",
1320
+ runner
1067
1321
  });
1322
+ return {
1323
+ schemaVersion: 1,
1324
+ worktreePath: realDirectoryWithoutSymlinks(destination, "Write worktree")
1325
+ };
1326
+ } catch (error) {
1327
+ rmSync(destination, {
1328
+ force: true,
1329
+ recursive: true
1330
+ });
1331
+ throw error;
1068
1332
  }
1069
- symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
1070
- for (const [relativePath, source] of regularFiles.slice(2)) {
1071
- const path = join(root, relativePath);
1072
- mkdirSync(dirname(path), { recursive: true });
1073
- writeFileSync(path, template(source, values), {
1074
- encoding: "utf8",
1075
- flag: "wx",
1076
- mode: 420
1333
+ }
1334
+ /** Commit every pending change, then fast-forward locally or push once. */
1335
+ function finishContextWrite(options, runner) {
1336
+ const connection = resolveConnectionRecord(options.projectPath, runner);
1337
+ const root = connection.tree.path;
1338
+ const { taskBranch, worktreePath } = validatePreparedWorktree(root, options.worktreePath, runner);
1339
+ const branch = git(root, [
1340
+ "symbolic-ref",
1341
+ "--short",
1342
+ "HEAD"
1343
+ ], {
1344
+ message: "Failed to resolve the connected checkout branch.",
1345
+ runner
1346
+ });
1347
+ if (git(worktreePath, [
1348
+ "status",
1349
+ "--porcelain",
1350
+ "--untracked-files=all"
1351
+ ], {
1352
+ message: "Failed to inspect the prepared worktree.",
1353
+ runner
1354
+ }).length === 0) throw new Error("The prepared worktree has no pending changes.");
1355
+ if (!verifyTree(worktreePath).ok) throw new ContextTreeError(CLI_ERROR_CODES.invalidTree, `Refusing to commit an invalid Context Tree; run context-tree verify --tree-path ${worktreePath}.`);
1356
+ git(worktreePath, ["add", "--all"], {
1357
+ message: "Staging the Context Tree changes failed.",
1358
+ runner
1359
+ });
1360
+ git(worktreePath, [
1361
+ "-c",
1362
+ "commit.gpgsign=false",
1363
+ "commit",
1364
+ "--quiet",
1365
+ "-m",
1366
+ options.message
1367
+ ], {
1368
+ message: "Committing the Context Tree changes failed.",
1369
+ runner
1370
+ });
1371
+ const sha = git(worktreePath, ["rev-parse", "HEAD"], {
1372
+ message: "Failed to resolve the write commit.",
1373
+ runner
1374
+ });
1375
+ try {
1376
+ if (connection.tree.kind === "local") git(root, [
1377
+ "merge",
1378
+ "--ff-only",
1379
+ taskBranch
1380
+ ], {
1381
+ message: "Fast-forwarding the local Context Tree failed.",
1382
+ runner
1077
1383
  });
1384
+ else git(worktreePath, [
1385
+ "push",
1386
+ "origin",
1387
+ `HEAD:refs/heads/${branch}`
1388
+ ], {
1389
+ message: "Publishing the Context Tree write failed.",
1390
+ runner
1391
+ });
1392
+ } catch (error) {
1393
+ if (isNonFastForward(error)) throw new ContextTreeError(CLI_ERROR_CODES.writeOutdated, `The Context Tree advanced; the prepared worktree is preserved at ${worktreePath}.`);
1394
+ throw error;
1078
1395
  }
1396
+ removeWorktree(root, worktreePath, taskBranch, runner);
1079
1397
  return {
1080
- files,
1081
- root,
1398
+ branch,
1082
1399
  schemaVersion: 1,
1083
- verification: verifyTree(root)
1400
+ sha
1084
1401
  };
1085
1402
  }
1403
+ function gitCommonDirectory(root, runner) {
1404
+ const value = git(root, ["rev-parse", "--git-common-dir"], {
1405
+ message: "Failed to resolve the Git common directory.",
1406
+ runner
1407
+ });
1408
+ return realDirectoryWithoutSymlinks(isAbsolute(value) ? value : resolve(root, value), "Git common directory");
1409
+ }
1410
+ function validatePreparedWorktree(root, suppliedPath, runner) {
1411
+ const worktreePath = realDirectoryWithoutSymlinks(suppliedPath, "Prepared worktree");
1412
+ if (gitCommonDirectory(worktreePath, runner) !== gitCommonDirectory(root, runner)) throw new Error("The prepared worktree does not belong to the connected Context Tree.");
1413
+ const taskBranch = git(worktreePath, [
1414
+ "symbolic-ref",
1415
+ "--short",
1416
+ "HEAD"
1417
+ ], {
1418
+ message: "Failed to resolve the worktree branch.",
1419
+ runner
1420
+ });
1421
+ if (!taskBranch.startsWith(TASK_BRANCH_PREFIX)) throw new Error("The prepared worktree is not on a reserved Context Tree write branch.");
1422
+ return {
1423
+ taskBranch,
1424
+ worktreePath
1425
+ };
1426
+ }
1427
+ function isNonFastForward(error) {
1428
+ return error instanceof CommandError && /non-fast-forward|fetch first|tip of your current branch is behind|not possible to fast-forward|diverg/i.test(error.stderr);
1429
+ }
1430
+ function removeWorktree(root, worktreePath, taskBranch, runner) {
1431
+ git(root, [
1432
+ "worktree",
1433
+ "remove",
1434
+ worktreePath
1435
+ ], {
1436
+ message: "Removing the write worktree failed.",
1437
+ runner
1438
+ });
1439
+ git(root, [
1440
+ "branch",
1441
+ "-D",
1442
+ taskBranch
1443
+ ], {
1444
+ message: "Deleting the write branch failed.",
1445
+ runner
1446
+ });
1447
+ }
1086
1448
  //#endregion
1087
- export { inspectContextTreeDiff, linkProject, readContextTreePolicy, readTree, refreshProject, resolveLink, scaffoldTree, stageContextWrite, verifyTree };
1449
+ export { connectProject, createProject, finishContextWrite, listManagedTrees, prepareContextWrite, publishProject, readContextTreePolicy, readTree, resolveConnection, syncProject, verifyTree };