@first-tree-ai/context-tree 0.1.6-alpha.202608310559 → 0.1.7

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 (45) hide show
  1. package/README.md +158 -128
  2. package/dist/cli/index.mjs +1404 -746
  3. package/package.json +7 -28
  4. package/scripts/postinstall.mjs +53 -0
  5. package/skills/context-tree-connect/SKILL.md +36 -0
  6. package/skills/context-tree-connect/agents/openai.yaml +4 -0
  7. package/skills/context-tree-create/SKILL.md +32 -0
  8. package/skills/context-tree-create/agents/openai.yaml +4 -0
  9. package/skills/context-tree-publish/SKILL.md +22 -0
  10. package/skills/context-tree-publish/agents/openai.yaml +4 -0
  11. package/skills/context-tree-read/SKILL.md +34 -37
  12. package/skills/context-tree-read/agents/openai.yaml +2 -2
  13. package/skills/context-tree-setup/SKILL.md +28 -0
  14. package/skills/context-tree-setup/agents/openai.yaml +4 -0
  15. package/skills/context-tree-write/SKILL.md +208 -125
  16. package/skills/context-tree-write/agents/openai.yaml +2 -2
  17. package/.agents/plugins/marketplace.json +0 -19
  18. package/.claude-plugin/marketplace.json +0 -21
  19. package/.claude-plugin/plugin.json +0 -16
  20. package/.codex-plugin/plugin.json +0 -36
  21. package/dist/cli/index.d.mts +0 -1
  22. package/dist/index.d.mts +0 -28
  23. package/dist/index.mjs +0 -1087
  24. package/dist/schemas-BWM6Q6iz.mjs +0 -278
  25. package/dist/schemas-C4bs-FkC.d.mts +0 -394
  26. package/dist/schemas.d.mts +0 -2
  27. package/dist/schemas.mjs +0 -2
  28. package/docs/specification.md +0 -146
  29. package/examples/basic/NODE.md +0 -15
  30. package/examples/basic/members/NODE.md +0 -8
  31. package/examples/basic/members/example-agent/NODE.md +0 -7
  32. package/examples/basic/members/example-agent/memory.md +0 -9
  33. package/examples/basic/systems/NODE.md +0 -10
  34. package/examples/basic/systems/runtime.md +0 -15
  35. package/hooks/hooks.json +0 -26
  36. package/hooks/session-start.mjs +0 -64
  37. package/policy/context-tree-policy.md +0 -158
  38. package/skills/context-tree-init/SKILL.md +0 -51
  39. package/skills/context-tree-init/agents/openai.yaml +0 -4
  40. package/skills/context-tree-init/scripts/context-tree.mjs +0 -41
  41. package/skills/context-tree-link/SKILL.md +0 -45
  42. package/skills/context-tree-link/agents/openai.yaml +0 -4
  43. package/skills/context-tree-link/scripts/context-tree.mjs +0 -41
  44. package/skills/context-tree-read/scripts/context-tree.mjs +0 -41
  45. package/skills/context-tree-write/scripts/context-tree.mjs +0 -41
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import path, { basename, dirname, isAbsolute, join, parse, posix, relative, resolve } from "node:path";
3
+ import path, { basename, dirname, isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path";
4
4
  import { EventEmitter } from "node:events";
5
5
  import childProcess, { spawnSync } from "node:child_process";
6
- import fs, { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import fs, { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
7
7
  import process$1 from "node:process";
8
8
  import { stripVTControlCharacters } from "node:util";
9
- import { fileURLToPath } from "node:url";
10
9
  import { homedir, tmpdir } from "node:os";
10
+ import { fileURLToPath } from "node:url";
11
11
  //#region \0rolldown/runtime.js
12
12
  var __defProp = Object.defineProperty;
13
13
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
@@ -7240,6 +7240,448 @@ function refine(fn, _params = {}) {
7240
7240
  function superRefine(fn, params) {
7241
7241
  return /* @__PURE__ */ _superRefine(fn, params);
7242
7242
  }
7243
+ const CONTEXT_TREE_ROOT_NODE_MAX_BYTES = 16 * 1024;
7244
+ const VALIDATION_CODES = {
7245
+ rootMissing: "TREE_ROOT_MISSING",
7246
+ rootNodeInvalid: "TREE_ROOT_NODE_INVALID",
7247
+ rootOnlyFields: "TREE_ROOT_ONLY_FIELDS",
7248
+ directoryNodeMissing: "TREE_DIRECTORY_NODE_MISSING",
7249
+ frontmatterMissing: "TREE_FRONTMATTER_MISSING",
7250
+ frontmatterParse: "TREE_FRONTMATTER_PARSE",
7251
+ titleMissing: "TREE_TITLE_MISSING",
7252
+ titleInvalid: "TREE_TITLE_INVALID",
7253
+ descriptionInvalid: "TREE_DESCRIPTION_INVALID",
7254
+ softLinksInvalid: "TREE_SOFT_LINKS_INVALID",
7255
+ softLinkBroken: "TREE_SOFT_LINK_BROKEN",
7256
+ softLinkPathEscape: "TREE_SOFT_LINK_PATH_ESCAPE",
7257
+ markdownPathEscape: "TREE_MARKDOWN_LINK_PATH_ESCAPE",
7258
+ markdownFileSymlinkBroken: "TREE_MARKDOWN_FILE_SYMLINK_BROKEN",
7259
+ markdownFileSymlinkUnsupported: "TREE_MARKDOWN_FILE_SYMLINK_UNSUPPORTED",
7260
+ markdownFilePathEscape: "TREE_MARKDOWN_FILE_PATH_ESCAPE",
7261
+ markdownFileContentClassMismatch: "TREE_MARKDOWN_FILE_CONTENT_CLASS_MISMATCH",
7262
+ directorySymlinkUnsupported: "TREE_DIRECTORY_SYMLINK_UNSUPPORTED",
7263
+ directorySymlinkPathEscape: "TREE_DIRECTORY_SYMLINK_PATH_ESCAPE"
7264
+ };
7265
+ const CLI_ERROR_CODES = {
7266
+ corruptConnection: "CORRUPT_CONNECTION",
7267
+ dirtyTree: "DIRTY_TREE",
7268
+ failed: "CONTEXT_TREE_FAILED",
7269
+ githubAuth: "GITHUB_AUTH",
7270
+ invalidTree: "INVALID_TREE",
7271
+ noConnection: "NO_CONNECTION",
7272
+ publishIncomplete: "PUBLISH_INCOMPLETE",
7273
+ repositoryExists: "REPOSITORY_EXISTS",
7274
+ staleConnection: "STALE_CONNECTION",
7275
+ writeOutdated: "WRITE_OUTDATED"
7276
+ };
7277
+ function hasUnsafeCharacter(value) {
7278
+ return [...value].some((character) => {
7279
+ const code = character.codePointAt(0);
7280
+ return code !== void 0 && (code <= 31 || code === 127 || code === 8232 || code === 8233);
7281
+ });
7282
+ }
7283
+ const absoluteSingleLinePathSchema = string$2().superRefine((value, context) => {
7284
+ if (!isAbsolute(value) || value.trim() !== value || hasUnsafeCharacter(value)) context.addIssue({
7285
+ code: "custom",
7286
+ message: "Paths must be absolute single-line values."
7287
+ });
7288
+ });
7289
+ const credentialFreeRepositoryUrlSchema = string$2().superRefine((value, context) => {
7290
+ if (value.trim() !== value || hasUnsafeCharacter(value) || value.includes("\\")) {
7291
+ context.addIssue({
7292
+ code: "custom",
7293
+ message: "Repository URLs must be canonical single-line values."
7294
+ });
7295
+ return;
7296
+ }
7297
+ if (value.includes("?") || value.includes("#")) {
7298
+ context.addIssue({
7299
+ code: "custom",
7300
+ message: "Repository URLs must not contain a query or fragment."
7301
+ });
7302
+ return;
7303
+ }
7304
+ if (/^(?:[a-z\d._-]+@)?[a-z\d.-]+:[^\s/][^\s]*$/iu.test(value)) return;
7305
+ try {
7306
+ const parsed = new URL(value);
7307
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" && parsed.protocol !== "ssh:" || !parsed.hostname || parsed.pathname.split("/").every((part) => part.length === 0) || parsed.password || (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username) throw new Error("invalid transport");
7308
+ } catch {
7309
+ context.addIssue({
7310
+ code: "custom",
7311
+ message: "Repository URLs must use credential-free HTTP(S), ssh://, or scp-like SSH syntax."
7312
+ });
7313
+ }
7314
+ });
7315
+ const treeNameSchema = string$2().superRefine((value, context) => {
7316
+ if (!/^[A-Za-z\d][A-Za-z\d._-]{0,99}$/u.test(value) || /\.git$/iu.test(value)) context.addIssue({
7317
+ code: "custom",
7318
+ message: "Tree name must be a safe single path segment."
7319
+ });
7320
+ });
7321
+ const githubRepositoryIdentitySchema = string$2().superRefine((value, context) => {
7322
+ const parts = value.split("/");
7323
+ const [owner, name] = parts;
7324
+ if (parts.length !== 2 || owner === void 0 || name === void 0 || !/^[A-Za-z\d](?:[A-Za-z\d-]{0,37}[A-Za-z\d])?$/u.test(owner) || !treeNameSchema.safeParse(name).success) context.addIssue({
7325
+ code: "custom",
7326
+ message: "Repository must be an explicit GitHub OWNER/REPO identity."
7327
+ });
7328
+ });
7329
+ const contextTreeRootNodeSchema = object({
7330
+ frontmatter: object({
7331
+ schemaVersion: literal(1),
7332
+ title: string$2().trim().min(1),
7333
+ description: string$2().trim().min(1).optional(),
7334
+ soft_links: array(string$2().trim().min(1)).min(1).optional()
7335
+ }).loose(),
7336
+ body: string$2().trim().min(1, "Root NODE.md must contain repository-wide context.")
7337
+ });
7338
+ const contextContentClassSchema = _enum([
7339
+ "normal",
7340
+ "member",
7341
+ "repo-infra"
7342
+ ]);
7343
+ const treeValidationFindingSchema = object({
7344
+ code: _enum(VALIDATION_CODES),
7345
+ message: string$2(),
7346
+ path: string$2(),
7347
+ target: string$2().optional()
7348
+ }).strict();
7349
+ const contextContentClassCountsSchema = object({
7350
+ normal: number().int().nonnegative(),
7351
+ member: number().int().nonnegative(),
7352
+ "repo-infra": number().int().nonnegative()
7353
+ }).strict();
7354
+ const SKILL_HOSTS = ["claude", "codex"];
7355
+ const skillHostSchema = _enum(SKILL_HOSTS);
7356
+ const skillInstallationSchema = object({
7357
+ host: skillHostSchema,
7358
+ path: absoluteSingleLinePathSchema,
7359
+ skills: array(string$2().trim().min(1))
7360
+ }).strict();
7361
+ const skillInstallSkipSchema = object({
7362
+ host: skillHostSchema,
7363
+ reason: string$2().trim().min(1)
7364
+ }).strict();
7365
+ object({
7366
+ installed: array(skillInstallationSchema),
7367
+ schemaVersion: literal(1),
7368
+ skipped: array(skillInstallSkipSchema),
7369
+ version: string$2().trim().min(1)
7370
+ }).strict();
7371
+ const contextTreeReadCommonFields = {
7372
+ contentClass: contextContentClassSchema,
7373
+ kind: _enum(["directory", "file"]),
7374
+ path: string$2()
7375
+ };
7376
+ const contextTreeReadNodeSchema = object({
7377
+ body: string$2(),
7378
+ frontmatter: record(string$2(), unknown()),
7379
+ ...contextTreeReadCommonFields
7380
+ }).strict();
7381
+ object({
7382
+ children: array(object({
7383
+ description: string$2().optional(),
7384
+ title: string$2(),
7385
+ ...contextTreeReadCommonFields
7386
+ }).strict()),
7387
+ node: contextTreeReadNodeSchema,
7388
+ root: string$2(),
7389
+ schemaVersion: literal(1),
7390
+ target: string$2()
7391
+ }).strict();
7392
+ object({
7393
+ findings: array(treeValidationFindingSchema),
7394
+ ok: boolean(),
7395
+ root: string$2(),
7396
+ scannedByContentClass: contextContentClassCountsSchema,
7397
+ schemaVersion: literal(1)
7398
+ }).strict();
7399
+ const contextTreeStateSchema = discriminatedUnion("kind", [object({
7400
+ kind: literal("local"),
7401
+ path: absoluteSingleLinePathSchema
7402
+ }).strict(), object({
7403
+ kind: literal("github"),
7404
+ path: absoluteSingleLinePathSchema,
7405
+ repository: githubRepositoryIdentitySchema
7406
+ }).strict()]);
7407
+ const contextTreeConnectionSchema = object({
7408
+ projectPath: absoluteSingleLinePathSchema,
7409
+ tree: contextTreeStateSchema
7410
+ }).strict();
7411
+ object({
7412
+ schemaVersion: literal(1),
7413
+ tree: contextTreeStateSchema
7414
+ }).strict();
7415
+ /** Whether the project's AGENTS.md pointer was created, rewritten, or left alone. */
7416
+ const projectPointerOutcomeSchema = _enum([
7417
+ "written",
7418
+ "updated",
7419
+ "skipped"
7420
+ ]);
7421
+ object({
7422
+ branch: string$2().trim().min(1),
7423
+ commitSha: string$2(),
7424
+ created: boolean(),
7425
+ pointer: projectPointerOutcomeSchema,
7426
+ schemaVersion: literal(1),
7427
+ title: string$2().trim().min(1),
7428
+ treePath: absoluteSingleLinePathSchema
7429
+ }).strict();
7430
+ object({
7431
+ pointer: projectPointerOutcomeSchema,
7432
+ schemaVersion: literal(1),
7433
+ tree: contextTreeStateSchema
7434
+ }).strict();
7435
+ const managedTreeListingEntrySchema = object({
7436
+ name: treeNameSchema,
7437
+ tree: contextTreeStateSchema
7438
+ }).strict();
7439
+ object({
7440
+ schemaVersion: literal(1),
7441
+ trees: array(managedTreeListingEntrySchema)
7442
+ }).strict();
7443
+ object({
7444
+ branch: string$2().trim().min(1),
7445
+ schemaVersion: literal(1),
7446
+ sha: string$2(),
7447
+ tree: contextTreeStateSchema
7448
+ }).strict();
7449
+ object({
7450
+ schemaVersion: literal(1),
7451
+ worktreePath: absoluteSingleLinePathSchema
7452
+ }).strict();
7453
+ object({
7454
+ branch: string$2().trim().min(1),
7455
+ schemaVersion: literal(1),
7456
+ sha: string$2()
7457
+ }).strict();
7458
+ object({
7459
+ branch: string$2().trim().min(1),
7460
+ repository: githubRepositoryIdentitySchema,
7461
+ schemaVersion: literal(1),
7462
+ sha: string$2(),
7463
+ url: credentialFreeRepositoryUrlSchema
7464
+ }).strict();
7465
+ object({
7466
+ error: object({
7467
+ code: _enum(CLI_ERROR_CODES),
7468
+ message: string$2()
7469
+ }).strict(),
7470
+ ok: literal(false),
7471
+ schemaVersion: literal(1)
7472
+ }).strict();
7473
+ //#endregion
7474
+ //#region src/core/internal/errors.ts
7475
+ /**
7476
+ * A failure the CLI reports with a specific machine-readable code. Anything
7477
+ * thrown as a plain Error is reported as CONTEXT_TREE_FAILED instead.
7478
+ */
7479
+ var ContextTreeError = class extends Error {
7480
+ code;
7481
+ constructor(code, message) {
7482
+ super(message);
7483
+ this.name = "ContextTreeError";
7484
+ this.code = code;
7485
+ }
7486
+ };
7487
+ //#endregion
7488
+ //#region src/core/internal/git.ts
7489
+ function defaultRunner(command, args) {
7490
+ const result = spawnSync(command, args, {
7491
+ encoding: "utf8",
7492
+ stdio: [
7493
+ "ignore",
7494
+ "pipe",
7495
+ "pipe"
7496
+ ]
7497
+ });
7498
+ return {
7499
+ status: result.status,
7500
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
7501
+ stdout: typeof result.stdout === "string" ? result.stdout : ""
7502
+ };
7503
+ }
7504
+ /** A failed Git or `gh` operation. Messages never include the argv. */
7505
+ var CommandError = class extends Error {
7506
+ command;
7507
+ status;
7508
+ stderr;
7509
+ constructor(command, status, stderr, message) {
7510
+ const detail = sanitizeCommandOutput(stderr).trim();
7511
+ super(detail.length > 0 ? `${message}: ${detail}` : message);
7512
+ this.name = "CommandError";
7513
+ this.command = command;
7514
+ this.status = status;
7515
+ this.stderr = detail;
7516
+ }
7517
+ };
7518
+ /** Remove credentials and common access-token shapes before surfacing subprocess output. */
7519
+ function sanitizeCommandOutput(value) {
7520
+ 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>");
7521
+ }
7522
+ function trimOutput(value) {
7523
+ return value.trim();
7524
+ }
7525
+ function execute(runner, command, args, message) {
7526
+ const result = runner(command, args);
7527
+ if (result.status !== 0) throw new CommandError(command, result.status, result.stderr, message);
7528
+ return trimOutput(result.stdout);
7529
+ }
7530
+ /** Run a Git command that is not scoped by `-C`, such as `git init <path>`. */
7531
+ function gitCommand(args, options = {}) {
7532
+ return execute(options.runner ?? defaultRunner, "git", args, options.message ?? "A Git operation failed.");
7533
+ }
7534
+ /** Run `git -C <root> <args>` and return trimmed stdout, throwing on failure. */
7535
+ function git(root, args, options = {}) {
7536
+ return execute(options.runner ?? defaultRunner, "git", [
7537
+ "-C",
7538
+ root,
7539
+ ...args
7540
+ ], options.message ?? "A Git operation failed.");
7541
+ }
7542
+ /** Run `git -C <root> <args>` and return trimmed stdout, or undefined on failure. */
7543
+ function optionalGit(root, args, runner = defaultRunner) {
7544
+ const result = runner("git", [
7545
+ "-C",
7546
+ root,
7547
+ ...args
7548
+ ]);
7549
+ if (result.status !== 0) return void 0;
7550
+ return trimOutput(result.stdout);
7551
+ }
7552
+ /** Run `gh <args>` and return trimmed stdout, throwing on failure. */
7553
+ function gh(args, options = {}) {
7554
+ return execute(options.runner ?? defaultRunner, "gh", args, options.message ?? "A GitHub CLI operation failed.");
7555
+ }
7556
+ //#endregion
7557
+ //#region src/core/internal/github-repository.ts
7558
+ const ORIGIN_MESSAGE = "Context Tree origin must identify a credential-free GitHub OWNER/REPO repository.";
7559
+ function canonicalGitHubRepositoryUrl(repository) {
7560
+ githubRepositoryIdentitySchema.parse(repository);
7561
+ return `https://github.com/${repository}.git`;
7562
+ }
7563
+ /** Derive OWNER/REPO from a github.com origin, rejecting anything else. */
7564
+ function gitHubRepositoryFromOriginUrl(origin) {
7565
+ if (!credentialFreeRepositoryUrlSchema.safeParse(origin).success) throw new Error(ORIGIN_MESSAGE);
7566
+ let owner;
7567
+ let name;
7568
+ const scp = /^(?:git@)?github\.com:([^/]+)\/(.+)$/iu.exec(origin);
7569
+ if (scp !== null) [, owner, name] = scp;
7570
+ else {
7571
+ const url = URL.parse(origin);
7572
+ if (url === null || url.hostname.toLowerCase() !== "github.com") throw new Error(ORIGIN_MESSAGE);
7573
+ [owner, name] = url.pathname.replace(/^\/+|\/+$/gu, "").split("/");
7574
+ }
7575
+ const repository = `${owner ?? ""}/${(name ?? "").replace(/\.git$/iu, "")}`;
7576
+ if (!githubRepositoryIdentitySchema.safeParse(repository).success) throw new Error(ORIGIN_MESSAGE);
7577
+ return repository;
7578
+ }
7579
+ //#endregion
7580
+ //#region src/core/path.ts
7581
+ function isPathInside(root, target) {
7582
+ const path = relative(root, target);
7583
+ return path === "" || !path.startsWith("..") && !isAbsolute(path);
7584
+ }
7585
+ function resolveTreeRoot(path) {
7586
+ const absolute = resolve(path);
7587
+ const entry = lstatSync(absolute);
7588
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree root must be a real directory: ${path}`);
7589
+ return realpathSync(absolute);
7590
+ }
7591
+ /** Resolve a directory while rejecting symlinks in user-controlled path components. */
7592
+ function realDirectoryWithoutSymlinks(path, label) {
7593
+ const absolute = resolve(path);
7594
+ const parsed = parse(absolute);
7595
+ const parts = absolute.slice(parsed.root.length).split(sep).filter(Boolean);
7596
+ let current = parsed.root;
7597
+ for (const [index, part] of parts.entries()) {
7598
+ current = resolve(current, part);
7599
+ if (lstatSync(current).isSymbolicLink()) if (index === 0) current = realpathSync(current);
7600
+ else throw new Error(`${label} must contain no symlink component.`);
7601
+ }
7602
+ if (!lstatSync(current).isDirectory()) throw new Error(`${label} must be a directory.`);
7603
+ return realpathSync(current);
7604
+ }
7605
+ function toPosixPath(path) {
7606
+ return path.replace(/\\/gu, "/");
7607
+ }
7608
+ //#endregion
7609
+ //#region src/core/internal/project.ts
7610
+ /**
7611
+ * Projects are identified solely by their canonical local root. A Git
7612
+ * repository without an origin, a non-Git directory, a Git worktree, and a
7613
+ * separate clone are all independent checkouts with their own canonical root.
7614
+ */
7615
+ function canonicalProjectRoot(path, runner) {
7616
+ const directory = realDirectoryWithoutSymlinks(path, "Project path");
7617
+ const toplevel = optionalGit(directory, ["rev-parse", "--show-toplevel"], runner);
7618
+ if (toplevel === void 0 || toplevel.length === 0) return directory;
7619
+ return realpathSync(toplevel);
7620
+ }
7621
+ //#endregion
7622
+ //#region src/core/internal/project-pointer.ts
7623
+ const BEGIN = "<!-- context-tree:begin -->";
7624
+ const END = "<!-- context-tree:end -->";
7625
+ function pointerBlock(treePath) {
7626
+ return [
7627
+ BEGIN,
7628
+ "## Context Tree",
7629
+ "",
7630
+ `This project is connected to a Context Tree at \`${treePath}\`.`,
7631
+ "",
7632
+ "Read the decisions and constraints that bear on a task before planning or",
7633
+ "changing code, and record durable decisions there. Use the `context-tree-read`",
7634
+ "and `context-tree-write` skills rather than editing the tree by hand.",
7635
+ END
7636
+ ].join("\n");
7637
+ }
7638
+ /** A real regular file, a real absent path, or a refusal. */
7639
+ function regularFileContent(path) {
7640
+ const entry = lstatSync(path, { throwIfNoEntry: false });
7641
+ if (entry === void 0) return void 0;
7642
+ if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`Refusing to write the Context Tree pointer through a symlink or non-file: ${path}`);
7643
+ return readFileSync(path, "utf8");
7644
+ }
7645
+ /**
7646
+ * Record the connected tree in the project's own `AGENTS.md`, so any agent that reads
7647
+ * instruction files knows the tree exists without a host-specific session hook.
7648
+ *
7649
+ * Idempotent: a delimited block is rewritten in place, never appended twice, so
7650
+ * switching the connected tree updates the existing pointer. All other content is
7651
+ * preserved, and an existing regular `CLAUDE.md` is left alone.
7652
+ */
7653
+ function writeProjectPointer(projectPath, treePath) {
7654
+ const agentsPath = join(projectPath, "AGENTS.md");
7655
+ const block = pointerBlock(treePath);
7656
+ const existing = regularFileContent(agentsPath);
7657
+ if (existing === void 0) {
7658
+ writeFileSync(agentsPath, `# AGENTS.md\n\n${block}\n`, {
7659
+ encoding: "utf8",
7660
+ mode: 420
7661
+ });
7662
+ linkClaudeMarkdown(projectPath);
7663
+ return "written";
7664
+ }
7665
+ const start = existing.indexOf(BEGIN);
7666
+ const end = existing.indexOf(END);
7667
+ if (start !== -1 && end > start) {
7668
+ const replaced = `${existing.slice(0, start)}${block}${existing.slice(end + 25)}`;
7669
+ if (replaced === existing) return "skipped";
7670
+ writeFileSync(agentsPath, replaced, { encoding: "utf8" });
7671
+ return "updated";
7672
+ }
7673
+ writeFileSync(agentsPath, `${existing}${existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n"}${block}\n`, { encoding: "utf8" });
7674
+ linkClaudeMarkdown(projectPath);
7675
+ return "written";
7676
+ }
7677
+ /** Point CLAUDE.md at AGENTS.md only when the project has no CLAUDE.md of its own. */
7678
+ function linkClaudeMarkdown(projectPath) {
7679
+ const claudePath = join(projectPath, "CLAUDE.md");
7680
+ if (lstatSync(claudePath, { throwIfNoEntry: false }) !== void 0) return;
7681
+ try {
7682
+ symlinkSync("AGENTS.md", claudePath, "file");
7683
+ } catch {}
7684
+ }
7243
7685
  //#endregion
7244
7686
  //#region node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js
7245
7687
  var require_identity = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -13912,90 +14354,20 @@ function parseMarkdownFrontmatter(source) {
13912
14354
  };
13913
14355
  }
13914
14356
  }
13915
- const CONTEXT_TREE_ROOT_NODE_MAX_BYTES = 16 * 1024;
13916
- const VALIDATION_CODES = {
13917
- rootMissing: "TREE_ROOT_MISSING",
13918
- rootNodeInvalid: "TREE_ROOT_NODE_INVALID",
13919
- rootOnlyFields: "TREE_ROOT_ONLY_FIELDS",
13920
- directoryNodeMissing: "TREE_DIRECTORY_NODE_MISSING",
13921
- frontmatterMissing: "TREE_FRONTMATTER_MISSING",
13922
- frontmatterParse: "TREE_FRONTMATTER_PARSE",
13923
- titleMissing: "TREE_TITLE_MISSING",
13924
- titleInvalid: "TREE_TITLE_INVALID",
13925
- descriptionInvalid: "TREE_DESCRIPTION_INVALID",
13926
- softLinksInvalid: "TREE_SOFT_LINKS_INVALID",
13927
- softLinkBroken: "TREE_SOFT_LINK_BROKEN",
13928
- softLinkPathEscape: "TREE_SOFT_LINK_PATH_ESCAPE",
13929
- markdownPathEscape: "TREE_MARKDOWN_LINK_PATH_ESCAPE",
13930
- markdownFileSymlinkBroken: "TREE_MARKDOWN_FILE_SYMLINK_BROKEN",
13931
- markdownFileSymlinkUnsupported: "TREE_MARKDOWN_FILE_SYMLINK_UNSUPPORTED",
13932
- markdownFilePathEscape: "TREE_MARKDOWN_FILE_PATH_ESCAPE",
13933
- markdownFileContentClassMismatch: "TREE_MARKDOWN_FILE_CONTENT_CLASS_MISMATCH",
13934
- directorySymlinkUnsupported: "TREE_DIRECTORY_SYMLINK_UNSUPPORTED",
13935
- directorySymlinkPathEscape: "TREE_DIRECTORY_SYMLINK_PATH_ESCAPE"
13936
- };
13937
- const CLI_ERROR_CODES = {
13938
- ambiguousLink: "AMBIGUOUS_LINK",
13939
- corruptLink: "CORRUPT_LINK",
13940
- failed: "CONTEXT_TREE_FAILED",
13941
- noLink: "NO_LINK",
13942
- staleLink: "STALE_LINK"
13943
- };
13944
- function hasUnsafeCharacter(value) {
13945
- return [...value].some((character) => {
13946
- const code = character.codePointAt(0);
13947
- return code !== void 0 && (code <= 31 || code === 127 || code === 8232 || code === 8233);
13948
- });
14357
+ //#endregion
14358
+ //#region src/core/internal/filesystem.ts
14359
+ function readUtf8File(path) {
14360
+ return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
13949
14361
  }
13950
- const absoluteSingleLinePathSchema = string$2().superRefine((value, context) => {
13951
- if (!isAbsolute(value) || value.trim() !== value || hasUnsafeCharacter(value)) context.addIssue({
13952
- code: "custom",
13953
- message: "Paths must be absolute single-line values."
13954
- });
13955
- });
13956
- const credentialFreeRepositoryUrlSchema = string$2().superRefine((value, context) => {
13957
- if (value.trim() !== value || hasUnsafeCharacter(value) || value.includes("\\")) {
13958
- context.addIssue({
13959
- code: "custom",
13960
- message: "Repository URLs must be canonical single-line values."
13961
- });
13962
- return;
13963
- }
13964
- if (value.includes("?") || value.includes("#")) {
13965
- context.addIssue({
13966
- code: "custom",
13967
- message: "Repository URLs must not contain a query or fragment."
13968
- });
13969
- return;
13970
- }
13971
- if (/^(?:[a-z\d._-]+@)?[a-z\d.-]+:[^\s/][^\s]*$/iu.test(value)) return;
13972
- try {
13973
- const parsed = new URL(value);
13974
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:" && parsed.protocol !== "ssh:" || !parsed.hostname || parsed.pathname.split("/").every((part) => part.length === 0) || parsed.password || (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username) throw new Error("invalid transport");
13975
- } catch {
13976
- context.addIssue({
13977
- code: "custom",
13978
- message: "Repository URLs must use credential-free HTTP(S), ssh://, or scp-like SSH syntax."
13979
- });
13980
- }
13981
- });
13982
- const githubRepositoryIdentitySchema = string$2().superRefine((value, context) => {
13983
- const parts = value.split("/");
13984
- const [owner, name] = parts;
13985
- if (parts.length !== 2 || owner === void 0 || name === void 0 || !/^[A-Za-z\d](?:[A-Za-z\d-]{0,37}[A-Za-z\d])?$/u.test(owner) || !/^[A-Za-z\d._-]{1,100}$/u.test(name) || name === "." || name === ".." || /\.git$/iu.test(name)) context.addIssue({
13986
- code: "custom",
13987
- message: "Repository must be an explicit GitHub OWNER/REPO identity."
13988
- });
13989
- });
13990
- const contextTreeRootNodeSchema = object({
13991
- frontmatter: object({
13992
- schemaVersion: literal(1),
13993
- title: string$2().trim().min(1),
13994
- description: string$2().trim().min(1).optional(),
13995
- soft_links: array(string$2().trim().min(1)).min(1).optional()
13996
- }).loose(),
13997
- body: string$2().trim().min(1, "Root NODE.md must contain repository-wide context.")
13998
- });
14362
+ //#endregion
14363
+ //#region src/core/internal/root-node.ts
14364
+ /**
14365
+ * The one root NODE.md reader. Verification and tree-state resolution both need
14366
+ * it, and they must agree on the fail-closed file guard, so it lives here rather
14367
+ * than in either caller: tree-state already imports verify, so a shared helper
14368
+ * in verify would close a cycle.
14369
+ */
14370
+ /** Parse root NODE.md content, bounding size before any YAML or Markdown work. */
13999
14371
  function parseContextTreeRootNode(markdown) {
14000
14372
  if (Buffer.byteLength(markdown, "utf8") > 16384) throw new Error(`Root NODE.md exceeds the ${CONTEXT_TREE_ROOT_NODE_MAX_BYTES}-byte limit.`);
14001
14373
  const document = parseMarkdownFrontmatter(markdown);
@@ -14006,214 +14378,12 @@ function parseContextTreeRootNode(markdown) {
14006
14378
  body: document.body
14007
14379
  });
14008
14380
  }
14009
- const contextContentClassSchema = _enum([
14010
- "normal",
14011
- "member",
14012
- "repo-infra"
14013
- ]);
14014
- const treeValidationFindingSchema = object({
14015
- code: _enum(VALIDATION_CODES),
14016
- message: string$2(),
14017
- path: string$2(),
14018
- target: string$2().optional()
14019
- }).strict();
14020
- const contextContentClassCountsSchema = object({
14021
- normal: number().int().nonnegative(),
14022
- member: number().int().nonnegative(),
14023
- "repo-infra": number().int().nonnegative()
14024
- }).strict();
14025
- object({
14026
- content: string$2(),
14027
- schemaVersion: literal(1)
14028
- }).strict();
14029
- const contextTreeReadCommonFields = {
14030
- contentClass: contextContentClassSchema,
14031
- kind: _enum(["directory", "file"]),
14032
- path: string$2()
14033
- };
14034
- const contextTreeReadNodeSchema = object({
14035
- body: string$2(),
14036
- frontmatter: record(string$2(), unknown()),
14037
- ...contextTreeReadCommonFields
14038
- }).strict();
14039
- object({
14040
- children: array(object({
14041
- description: string$2().optional(),
14042
- title: string$2(),
14043
- ...contextTreeReadCommonFields
14044
- }).strict()),
14045
- node: contextTreeReadNodeSchema,
14046
- root: string$2(),
14047
- schemaVersion: literal(1),
14048
- target: string$2()
14049
- }).strict();
14050
- const verifyTreeReportSchema = object({
14051
- findings: array(treeValidationFindingSchema),
14052
- ok: boolean(),
14053
- root: string$2(),
14054
- scannedByContentClass: contextContentClassCountsSchema,
14055
- schemaVersion: literal(1)
14056
- }).strict();
14057
- object({
14058
- files: array(string$2()),
14059
- root: string$2(),
14060
- schemaVersion: literal(1),
14061
- verification: verifyTreeReportSchema
14062
- }).strict();
14063
- const contextTreeLinkSchema = object({
14064
- project: discriminatedUnion("kind", [object({
14065
- kind: literal("git"),
14066
- origin: credentialFreeRepositoryUrlSchema
14067
- }).strict(), object({
14068
- kind: literal("directory"),
14069
- path: absoluteSingleLinePathSchema
14070
- }).strict()]),
14071
- tree: object({
14072
- path: absoluteSingleLinePathSchema,
14073
- repository: githubRepositoryIdentitySchema
14074
- }).strict()
14075
- }).strict();
14076
- object({
14077
- link: contextTreeLinkSchema,
14078
- schemaVersion: literal(1)
14079
- }).strict();
14080
- object({
14081
- link: contextTreeLinkSchema,
14082
- defaultBranch: string$2().trim().min(1),
14083
- refreshed: boolean(),
14084
- schemaVersion: literal(1),
14085
- sha: string$2()
14086
- }).strict();
14087
- object({
14088
- link: contextTreeLinkSchema,
14089
- defaultBranch: string$2().trim().min(1),
14090
- baseSha: string$2(),
14091
- schemaVersion: literal(1),
14092
- taskBranch: string$2().trim().min(1),
14093
- worktreePath: string$2()
14094
- }).strict();
14095
- object({
14096
- base: string$2(),
14097
- files: array(object({
14098
- path: string$2(),
14099
- status: _enum([
14100
- "added",
14101
- "deleted",
14102
- "modified",
14103
- "renamed",
14104
- "untracked"
14105
- ])
14106
- }).strict()).max(4096),
14107
- patch: string$2(),
14108
- schemaVersion: literal(1),
14109
- treePath: string$2()
14110
- }).strict();
14111
- object({
14112
- error: object({
14113
- code: _enum(CLI_ERROR_CODES),
14114
- message: string$2()
14115
- }).strict(),
14116
- ok: literal(false),
14117
- schemaVersion: literal(1)
14118
- }).strict();
14119
- //#endregion
14120
- //#region src/core/internal/github-repository.ts
14121
- function parseGitHubRepositoryIdentity(repository) {
14122
- githubRepositoryIdentitySchema.parse(repository);
14123
- return repository.split("/")[1] ?? "";
14124
- }
14125
- function repositoryIdentityFromGitHubUrl(repositoryUrl) {
14126
- try {
14127
- credentialFreeRepositoryUrlSchema.parse(repositoryUrl);
14128
- } catch {
14129
- throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
14130
- }
14131
- let host;
14132
- let path;
14133
- const scp = /^(?:git@)?([^:]+):(.+)$/u.exec(repositoryUrl);
14134
- if (scp !== null && !repositoryUrl.includes("://")) {
14135
- host = scp[1] ?? "";
14136
- path = scp[2] ?? "";
14137
- } else {
14138
- let parsed;
14139
- try {
14140
- parsed = new URL(repositoryUrl);
14141
- } catch {
14142
- throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
14143
- }
14144
- 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.");
14145
- host = parsed.hostname;
14146
- path = parsed.pathname;
14147
- }
14148
- if (host.toLowerCase() !== "github.com") throw new Error("Context Tree origin must use github.com.");
14149
- const identity = path.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "");
14150
- try {
14151
- parseGitHubRepositoryIdentity(identity);
14152
- } catch {
14153
- throw new Error("Context Tree origin must identify a safe GitHub OWNER/REPO repository.");
14154
- }
14155
- return identity;
14156
- }
14157
- function canonicalGitHubRepositoryUrl(repository) {
14158
- parseGitHubRepositoryIdentity(repository);
14159
- return `https://github.com/${repository}.git`;
14160
- }
14161
- //#endregion
14162
- //#region src/core/internal/packaged-resource.ts
14163
- const PACKAGE_NAME = "@first-tree-ai/context-tree";
14164
- function isPackageRoot(path) {
14165
- const manifestPath = join(path, "package.json");
14166
- if (!existsSync(manifestPath)) return false;
14167
- try {
14168
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
14169
- return isRecord(manifest) && manifest.name === PACKAGE_NAME;
14170
- } catch {
14171
- return false;
14172
- }
14173
- }
14174
- function resolvePackagedResource(...segments) {
14175
- let candidate = dirname(fileURLToPath(import.meta.url));
14176
- const filesystemRoot = parse(candidate).root;
14177
- while (true) {
14178
- if (isPackageRoot(candidate)) {
14179
- const resource = resolve(candidate, ...segments);
14180
- if (existsSync(resource)) return resource;
14181
- throw new Error(`Packaged resource is missing: ${segments.join("/")}`);
14182
- }
14183
- if (candidate === filesystemRoot) break;
14184
- candidate = dirname(candidate);
14185
- }
14186
- throw new Error(`Package root is missing while resolving: ${segments.join("/")}`);
14187
- }
14188
- function readPackageManifest() {
14189
- const parsed = JSON.parse(readFileSync(resolvePackagedResource("package.json"), "utf8"));
14190
- if (!isRecord(parsed)) throw new Error("Package metadata is invalid.");
14191
- return parsed;
14192
- }
14193
- function readPackageVersion() {
14194
- const manifest = readPackageManifest();
14195
- if (typeof manifest.version !== "string") throw new Error("Package version is missing or invalid.");
14196
- return manifest.version;
14197
- }
14198
- //#endregion
14199
- //#region src/core/internal/filesystem.ts
14200
- function readUtf8File(path) {
14201
- return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
14202
- }
14203
- //#endregion
14204
- //#region src/core/path.ts
14205
- function isPathInside(root, target) {
14206
- const path = relative(root, target);
14207
- return path === "" || !path.startsWith("..") && !isAbsolute(path);
14208
- }
14209
- function resolveTreeRoot(path) {
14210
- const absolute = resolve(path);
14211
- const entry = lstatSync(absolute);
14212
- if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree root must be a real directory: ${path}`);
14213
- return realpathSync(absolute);
14214
- }
14215
- function toPosixPath(path) {
14216
- return path.replace(/\\/gu, "/");
14381
+ /** Read and parse a tree's root NODE.md, refusing symlinked or irregular files. */
14382
+ function readRootNode(root) {
14383
+ const path = join(root, "NODE.md");
14384
+ const entry = lstatSync(path);
14385
+ if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Root NODE.md must be a regular file and must not be a symlink.");
14386
+ return parseContextTreeRootNode(readUtf8File(path));
14217
14387
  }
14218
14388
  //#endregion
14219
14389
  //#region src/core/internal/content-class.ts
@@ -25199,16 +25369,13 @@ function collectNodeValidationFindings(treeRoot) {
25199
25369
  //#endregion
25200
25370
  //#region src/core/verify.ts
25201
25371
  function rootNodeFindings(root) {
25202
- const path = join(root, "NODE.md");
25203
- if (!existsSync(path)) return [{
25372
+ if (!existsSync(join(root, "NODE.md"))) return [{
25204
25373
  code: VALIDATION_CODES.rootMissing,
25205
25374
  message: "root NODE.md is missing",
25206
25375
  path: "NODE.md"
25207
25376
  }];
25208
25377
  try {
25209
- const entry = lstatSync(path);
25210
- if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Root NODE.md must be a regular file and must not be a symlink.");
25211
- parseContextTreeRootNode(readUtf8File(path));
25378
+ readRootNode(root);
25212
25379
  return [];
25213
25380
  } catch (error) {
25214
25381
  return [{
@@ -25240,417 +25407,720 @@ function verifyTree(treePath) {
25240
25407
  };
25241
25408
  }
25242
25409
  //#endregion
25243
- //#region src/core/links.ts
25244
- const contextTreeLinksFileSchema = object({
25245
- links: array(contextTreeLinkSchema),
25410
+ //#region src/core/internal/tree-state.ts
25411
+ /**
25412
+ * The one tree-state resolver shared by create, connect, sync, publish, and
25413
+ * writes. It validates a checkout exactly once and reports the discriminated
25414
+ * tree state: a local-only tree, or a published tree with its GitHub
25415
+ * OWNER/REPO identity. Resolution never backfills or mutates stored state.
25416
+ */
25417
+ /** Require a real directory with no symlink component that is an exact Git root. */
25418
+ function exactGitRoot(treePath, runner) {
25419
+ const root = realDirectoryWithoutSymlinks(treePath, "Context Tree path");
25420
+ const toplevel = optionalGit(root, ["rev-parse", "--show-toplevel"], runner);
25421
+ if (toplevel === void 0 || toplevel.length === 0) throw new Error("Context Tree path must be a Git repository.");
25422
+ if (realpathSync(toplevel) !== root) throw new Error("Context Tree path must be the real Git root.");
25423
+ return root;
25424
+ }
25425
+ /**
25426
+ * Validate a clean checkout without inferring state from mutable Git remotes.
25427
+ * Uncommitted changes and invalid content each get their own code so callers
25428
+ * can tell "commit your edits" apart from "this path is gone".
25429
+ */
25430
+ function validateTreeCheckout(treePath, runner) {
25431
+ const root = exactGitRoot(treePath, runner);
25432
+ if (git(root, [
25433
+ "status",
25434
+ "--porcelain",
25435
+ "--untracked-files=all"
25436
+ ], {
25437
+ message: "Failed to inspect Context Tree cleanliness.",
25438
+ runner
25439
+ }).trim().length !== 0) throw new ContextTreeError(CLI_ERROR_CODES.dirtyTree, `The Context Tree at ${root} has uncommitted changes; commit or discard them.`);
25440
+ 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}.`);
25441
+ return root;
25442
+ }
25443
+ /** Validate a stored state without reclassifying it from mutable Git remotes. */
25444
+ function validateStoredTreeState(state, runner) {
25445
+ const path = validateTreeCheckout(state.path, runner);
25446
+ return state.kind === "local" ? {
25447
+ kind: "local",
25448
+ path
25449
+ } : {
25450
+ kind: "github",
25451
+ path,
25452
+ repository: state.repository
25453
+ };
25454
+ }
25455
+ //#endregion
25456
+ //#region src/core/connections.ts
25457
+ const connectionsFileSchema = object({
25458
+ connections: array(contextTreeConnectionSchema),
25246
25459
  schemaVersion: literal(1)
25247
25460
  }).strict();
25248
- var LinkError = class extends Error {
25249
- code;
25250
- constructor(code, message) {
25251
- super(message);
25252
- this.name = "LinkError";
25253
- this.code = code;
25461
+ const DUPLICATE_MESSAGE = "Duplicate Context Tree connection records exist for this project.";
25462
+ const NO_CONNECTION_MESSAGE = "No Context Tree connection exists for this project; run context-tree create or connect.";
25463
+ function realHome$1() {
25464
+ try {
25465
+ return realpathSync(homedir());
25466
+ } catch {
25467
+ return homedir();
25254
25468
  }
25255
- };
25256
- function git$1(path, args) {
25257
- const result = spawnSync("git", [
25258
- "-C",
25259
- path,
25260
- ...args
25261
- ], {
25262
- encoding: "utf8",
25263
- stdio: [
25264
- "ignore",
25265
- "pipe",
25266
- "ignore"
25267
- ]
25268
- });
25269
- if (result.error !== void 0 || result.status !== 0) return void 0;
25270
- return result.stdout.replace(/\r?\n$/u, "");
25271
25469
  }
25272
- function requireGit$1(path, args, message, allowEmpty = false) {
25273
- const value = git$1(path, args);
25274
- if (value === void 0 || !allowEmpty && value.length === 0) throw new Error(message);
25275
- return value;
25276
- }
25277
- function normalizeRepositoryUrl(repositoryUrl) {
25278
- try {
25279
- credentialFreeRepositoryUrlSchema.parse(repositoryUrl);
25280
- } catch {
25281
- throw new Error("Git origin must be a canonical credential-free repository URL.");
25470
+ /** Create a managed application directory below the home directory, failing closed on symlinks. */
25471
+ function ensureManagedDirectory(...segments) {
25472
+ let current = realHome$1();
25473
+ for (const segment of segments) {
25474
+ current = join(current, segment);
25475
+ const entry = lstatSync(current, { throwIfNoEntry: false });
25476
+ if (entry === void 0) {
25477
+ mkdirSync(current, { mode: 448 });
25478
+ continue;
25479
+ }
25480
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree managed directory must be a real directory: ${current}`);
25282
25481
  }
25283
- try {
25284
- return canonicalGitHubRepositoryUrl(repositoryIdentityFromGitHubUrl(repositoryUrl).toLowerCase());
25285
- } catch {}
25286
- const scp = /^(?:([^@]+)@)?([^:]+):(.+)$/u.exec(repositoryUrl);
25287
- if (scp !== null && !repositoryUrl.includes("://")) return `${scp[1] === void 0 ? "" : `${scp[1].toLowerCase()}@`}${(scp[2] ?? "").toLowerCase()}:${(scp[3] ?? "").replace(/\/+$/gu, "").replace(/\.git$/iu, "")}.git`;
25288
- const parsed = new URL(repositoryUrl);
25289
- parsed.hostname = parsed.hostname.toLowerCase();
25290
- parsed.pathname = `${parsed.pathname.replace(/\/+$/gu, "").replace(/\.git$/iu, "")}.git`;
25291
- return parsed.toString();
25292
- }
25293
- function realDirectory(path) {
25294
- const absolute = resolve(path);
25295
- if (!lstatSync(absolute).isDirectory()) throw new Error("Project path must be a directory.");
25296
- return realpathSync(absolute);
25482
+ return current;
25297
25483
  }
25298
- function identifyProject(projectPath) {
25299
- const path = realDirectory(projectPath);
25300
- const gitRoot = git$1(path, ["rev-parse", "--show-toplevel"]);
25301
- if (gitRoot === void 0) return {
25302
- kind: "directory",
25303
- path
25304
- };
25305
- return {
25306
- kind: "git",
25307
- origin: normalizeRepositoryUrl(requireGit$1(realpathSync(gitRoot), [
25308
- "remote",
25309
- "get-url",
25310
- "origin"
25311
- ], "Git project must have an origin remote."))
25312
- };
25484
+ /** The managed namespace without creating it; listing must not create an absent directory. */
25485
+ function managedTreesPath() {
25486
+ return join(realHome$1(), ".context-tree", "trees");
25313
25487
  }
25314
- function linksPath() {
25315
- return join(homedir(), ".context-tree", "connections.json");
25488
+ function connectionsPath() {
25489
+ return join(realHome$1(), ".context-tree", "connections.json");
25316
25490
  }
25317
- function emptyLinks() {
25318
- return {
25319
- links: [],
25320
- schemaVersion: 1
25321
- };
25491
+ function managedTreesRoot() {
25492
+ return ensureManagedDirectory(".context-tree", "trees");
25322
25493
  }
25323
- function loadLinks(required) {
25324
- const path = linksPath();
25494
+ function loadConnections(required) {
25495
+ const path = connectionsPath();
25325
25496
  if (!existsSync(path)) {
25326
- if (required) throw new LinkError(CLI_ERROR_CODES.noLink, "No Context Tree link exists for this project.");
25327
- return emptyLinks();
25497
+ if (required) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
25498
+ return {
25499
+ connections: [],
25500
+ schemaVersion: 1
25501
+ };
25328
25502
  }
25329
25503
  try {
25330
25504
  const entry = lstatSync(path);
25331
25505
  if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("not a regular file");
25332
- return contextTreeLinksFileSchema.parse(JSON.parse(readFileSync(path, "utf8")));
25506
+ return connectionsFileSchema.parse(JSON.parse(readFileSync(path, "utf8")));
25333
25507
  } catch {
25334
- throw new LinkError(CLI_ERROR_CODES.corruptLink, "Context Tree links are corrupt; run link after repairing or removing the internal links file.");
25508
+ throw new ContextTreeError(CLI_ERROR_CODES.corruptConnection, "Context Tree connections are corrupt; remove connections.json and run context-tree connect again.");
25335
25509
  }
25336
25510
  }
25337
- function saveLinks(value) {
25338
- const path = linksPath();
25339
- const directory = dirname(path);
25340
- mkdirSync(directory, {
25341
- recursive: true,
25342
- mode: 448
25343
- });
25344
- const directoryEntry = lstatSync(directory);
25345
- if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) throw new Error("Context Tree links directory must be a real directory.");
25346
- const temporary = join(directory, `.links-${process.pid}-${Date.now()}.tmp`);
25347
- writeFileSync(temporary, `${JSON.stringify(contextTreeLinksFileSchema.parse(value), null, 2)}\n`, {
25511
+ function saveConnections(value) {
25512
+ const directory = ensureManagedDirectory(".context-tree");
25513
+ const path = join(directory, "connections.json");
25514
+ const temporary = join(directory, `.connections-${process.pid}-${Date.now()}.tmp`);
25515
+ writeFileSync(temporary, `${JSON.stringify(connectionsFileSchema.parse(value), null, 2)}\n`, {
25348
25516
  encoding: "utf8",
25349
25517
  flag: "wx",
25350
25518
  mode: 384
25351
25519
  });
25352
- renameSync(temporary, path);
25353
- chmodSync(path, 384);
25354
- }
25355
- function exactCheckoutRoot(treePath) {
25356
- const absolute = resolve(treePath);
25357
- const entry = lstatSync(absolute);
25358
- const root = realpathSync(absolute);
25359
- if (!entry.isDirectory() || entry.isSymbolicLink() || absolute !== root) throw new Error("Context Tree checkout path must be a real directory with no symlink component.");
25360
- 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.");
25361
- return root;
25520
+ try {
25521
+ renameSync(temporary, path);
25522
+ chmodSync(path, 384);
25523
+ } finally {
25524
+ rmSync(temporary, { force: true });
25525
+ }
25362
25526
  }
25363
- function checkoutRepository(root) {
25364
- return repositoryIdentityFromGitHubUrl(requireGit$1(root, [
25365
- "remote",
25366
- "get-url",
25367
- "origin"
25368
- ], "Context Tree checkout must have an origin remote."));
25527
+ /** Exactly one record may exist per project; more than one is corruption. */
25528
+ function singleConnection(stored, canonical) {
25529
+ const matches = stored.connections.filter((connection) => connection.projectPath === canonical);
25530
+ if (matches.length > 1) throw new ContextTreeError(CLI_ERROR_CODES.corruptConnection, DUPLICATE_MESSAGE);
25531
+ return matches[0];
25369
25532
  }
25370
- function requireCheckoutClean(root, mode) {
25371
- const status = requireGit$1(root, [
25372
- "status",
25373
- "--porcelain",
25374
- "--untracked-files=all"
25375
- ], "Failed to inspect Context Tree cleanliness.", true);
25376
- if (status.length === 0) return;
25377
- if (mode === "scaffold") {
25378
- const lines = status.split("\n").sort();
25379
- if (git$1(root, [
25380
- "rev-parse",
25381
- "--verify",
25382
- "HEAD"
25383
- ]) === void 0 && JSON.stringify(lines) === JSON.stringify([
25384
- "?? .github/workflows/validate-context-tree.yml",
25385
- "?? AGENTS.md",
25386
- "?? CLAUDE.md",
25387
- "?? NODE.md"
25388
- ])) return;
25389
- }
25390
- throw new Error("Context Tree checkout must be clean.");
25533
+ function isManagedName(value) {
25534
+ return treeNameSchema.safeParse(value).success && value === value.toLowerCase();
25391
25535
  }
25392
- function parseRootNode(root) {
25393
- const path = join(root, "NODE.md");
25394
- const entry = lstatSync(path);
25395
- if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Context Tree root NODE.md must be a regular file.");
25396
- return parseContextTreeRootNode(readUtf8File(path));
25536
+ function managedName(value) {
25537
+ if (!isManagedName(value)) throw new Error(`Managed Context Tree names must be safe lowercase path segments: ${value}`);
25538
+ return value;
25397
25539
  }
25398
- function verifyCheckout(treePath, mode) {
25399
- const root = exactCheckoutRoot(treePath);
25400
- requireCheckoutClean(root, mode);
25401
- const repository = checkoutRepository(root);
25402
- if (!verifyTree(root).ok) throw new Error("Context Tree checkout is invalid; run context-tree verify.");
25540
+ /** List valid, clean managed trees, excluding unsafe or invalid candidates without failing the listing. */
25541
+ function listManagedTrees(runner) {
25542
+ const root = managedTreesPath();
25543
+ if (!existsSync(root)) return {
25544
+ schemaVersion: 1,
25545
+ trees: []
25546
+ };
25547
+ const rootEntry = lstatSync(root);
25548
+ if (rootEntry.isSymbolicLink() || !rootEntry.isDirectory()) throw new Error("Context Tree managed directory must be a real directory.");
25549
+ const trees = [];
25550
+ for (const candidate of readdirSync(root, { withFileTypes: true })) {
25551
+ if (!candidate.isDirectory() || !isManagedName(candidate.name)) continue;
25552
+ try {
25553
+ trees.push({
25554
+ name: candidate.name,
25555
+ tree: classifyCheckout(join(root, candidate.name), runner)
25556
+ });
25557
+ } catch {}
25558
+ }
25559
+ trees.sort((left, right) => left.name.localeCompare(right.name));
25403
25560
  return {
25404
- path: root,
25405
- repository
25561
+ schemaVersion: 1,
25562
+ trees
25406
25563
  };
25407
25564
  }
25408
- function sameProject(left, right) {
25409
- return left.kind === "git" && right.kind === "git" ? left.origin === right.origin : left.kind === "directory" && right.kind === "directory" && left.path === right.path;
25565
+ function findConnectionRecord(projectPath, runner) {
25566
+ return singleConnection(loadConnections(false), canonicalProjectRoot(projectPath, runner));
25410
25567
  }
25411
- function projectMatches(candidate, current) {
25412
- if (candidate.kind === "git" && current.kind === "git") return candidate.origin === current.origin;
25413
- if (candidate.kind === "directory" && current.kind === "directory") return isPathInside(candidate.path, current.path);
25414
- return false;
25568
+ function validateManagedTreeState(tree, runner) {
25569
+ const validated = validateStoredTreeState(tree, runner);
25570
+ 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)}`);
25571
+ return validated;
25415
25572
  }
25416
- function liveStoredCheckout(link) {
25573
+ function resolveConnectionRecord(projectPath, runner) {
25574
+ const canonical = canonicalProjectRoot(projectPath, runner);
25575
+ const connection = singleConnection(loadConnections(true), canonical);
25576
+ if (connection === void 0) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
25417
25577
  try {
25418
- const path = exactCheckoutRoot(link.tree.path);
25419
25578
  return {
25420
- path,
25421
- repository: checkoutRepository(path)
25579
+ projectPath: connection.projectPath,
25580
+ tree: validateManagedTreeState(connection.tree, runner)
25422
25581
  };
25423
- } catch {
25424
- return;
25582
+ } catch (error) {
25583
+ if (error instanceof ContextTreeError) throw error;
25584
+ const detail = error instanceof Error ? error.message : "unknown failure";
25585
+ 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}`);
25425
25586
  }
25426
25587
  }
25427
- function linkWithMode(projectPath, treePath, mode) {
25428
- const project = identifyProject(projectPath);
25429
- const tree = verifyCheckout(treePath, mode);
25430
- const stored = loadLinks(false);
25431
- const existing = stored.links.filter((link) => sameProject(link.project, project));
25432
- if (existing.length > 1) throw new LinkError(CLI_ERROR_CODES.ambiguousLink, "Multiple Context Tree links match this project.");
25433
- const previous = existing[0];
25434
- if (previous !== void 0) {
25435
- if (previous.tree.repository.toLowerCase() !== tree.repository.toLowerCase()) throw new Error("A project cannot link to a different Context Tree repository.");
25436
- if (previous.tree.path !== tree.path) {
25437
- const live = liveStoredCheckout(previous);
25438
- 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.");
25439
- }
25440
- }
25441
- const link = {
25442
- project,
25443
- tree
25588
+ function resolveConnection(projectPath, runner) {
25589
+ return {
25590
+ schemaVersion: 1,
25591
+ tree: resolveConnectionRecord(projectPath, runner).tree
25444
25592
  };
25445
- saveLinks({
25446
- links: [...stored.links.filter((candidate) => !sameProject(candidate.project, project)), link],
25593
+ }
25594
+ function upsertConnection(connection, runner) {
25595
+ const canonical = canonicalProjectRoot(connection.projectPath, runner);
25596
+ const record = {
25597
+ projectPath: canonical,
25598
+ tree: validateManagedTreeState(contextTreeStateSchema.parse(connection.tree), runner)
25599
+ };
25600
+ const stored = loadConnections(false);
25601
+ const previous = singleConnection(stored, canonical);
25602
+ if (previous !== void 0 && JSON.stringify(previous.tree) === JSON.stringify(record.tree)) return {
25603
+ schemaVersion: 1,
25604
+ tree: record.tree
25605
+ };
25606
+ saveConnections({
25607
+ connections: [...stored.connections.filter((candidate) => candidate.projectPath !== canonical), record],
25447
25608
  schemaVersion: 1
25448
25609
  });
25449
25610
  return {
25450
- link,
25611
+ schemaVersion: 1,
25612
+ tree: record.tree
25613
+ };
25614
+ }
25615
+ function updateConnectionTree(projectPath, tree, runner) {
25616
+ const canonical = canonicalProjectRoot(projectPath, runner);
25617
+ const stored = loadConnections(true);
25618
+ if (singleConnection(stored, canonical) === void 0) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
25619
+ const validatedTree = validateManagedTreeState(contextTreeStateSchema.parse(tree), runner);
25620
+ saveConnections({
25621
+ connections: stored.connections.map((connection) => connection.projectPath === canonical ? {
25622
+ ...connection,
25623
+ tree: validatedTree
25624
+ } : connection),
25451
25625
  schemaVersion: 1
25626
+ });
25627
+ }
25628
+ /**
25629
+ * Validate an exact checkout and classify it from a safe origin: no origin
25630
+ * is local state, a credential-free GitHub origin is GitHub state, and any
25631
+ * other origin is rejected as unsafe or unsupported.
25632
+ */
25633
+ function classifyCheckout(path, runner) {
25634
+ const root = validateTreeCheckout(path, runner);
25635
+ const origin = optionalGit(root, [
25636
+ "remote",
25637
+ "get-url",
25638
+ "origin"
25639
+ ], runner);
25640
+ if (origin === void 0) return {
25641
+ kind: "local",
25642
+ path: root
25643
+ };
25644
+ return {
25645
+ kind: "github",
25646
+ path: root,
25647
+ repository: gitHubRepositoryFromOriginUrl(origin)
25452
25648
  };
25453
25649
  }
25454
- function linkProject(projectPath, treePath) {
25455
- return linkWithMode(projectPath, treePath, "link");
25650
+ function sameRepository(left, right) {
25651
+ return left.toLowerCase() === right.toLowerCase();
25456
25652
  }
25457
- function linkScaffoldedProject(projectPath, treePath) {
25458
- return linkWithMode(projectPath, treePath, "scaffold");
25653
+ /** An existing managed directory that must be a real directory, not a symlinked alias. */
25654
+ function realManagedDirectory(name, destination) {
25655
+ const entry = lstatSync(destination);
25656
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Managed Context Tree name ${name} is occupied by an unsafe destination.`);
25459
25657
  }
25460
- function resolveLink(projectPath) {
25461
- const project = identifyProject(projectPath);
25462
- const matches = loadLinks(true).links.filter((link) => projectMatches(link.project, project));
25463
- if (matches.length === 0) throw new LinkError(CLI_ERROR_CODES.noLink, "No Context Tree link exists for this project.");
25464
- if (matches.length > 1) throw new LinkError(CLI_ERROR_CODES.ambiguousLink, "Multiple Context Tree links match this project.");
25465
- const link = matches[0];
25466
- if (link === void 0) throw new Error("Link lookup failed.");
25467
- try {
25468
- const root = exactCheckoutRoot(link.tree.path);
25469
- requireCheckoutClean(root, "link");
25470
- const repository = checkoutRepository(root);
25471
- if (repository.toLowerCase() !== link.tree.repository.toLowerCase()) throw new Error("The linked path now contains a different Context Tree repository.");
25472
- parseRootNode(root);
25658
+ /**
25659
+ * Connect by exact managed name or GitHub OWNER/REPO, or attach an exact,
25660
+ * clean, fully valid Git checkout at an explicit disk path in place.
25661
+ */
25662
+ function connectProject(options, runner) {
25663
+ /** Store the connection, then record it in the project so any agent can find it. */
25664
+ const connect = (tree) => {
25665
+ const result = upsertConnection({
25666
+ projectPath: options.projectPath,
25667
+ tree
25668
+ }, runner);
25473
25669
  return {
25474
- link: {
25475
- project: link.project,
25476
- tree: {
25477
- path: root,
25478
- repository
25479
- }
25480
- },
25481
- schemaVersion: 1
25670
+ pointer: writeProjectPointer(canonicalProjectRoot(options.projectPath, runner), result.tree.path),
25671
+ schemaVersion: 1,
25672
+ tree: result.tree
25482
25673
  };
25674
+ };
25675
+ if ("treePath" in options) return connect(classifyCheckout(options.treePath, runner));
25676
+ const treesRoot = managedTreesRoot();
25677
+ if (!options.target.includes("/")) {
25678
+ const name = managedName(options.target);
25679
+ const destination = join(treesRoot, name);
25680
+ if (!existsSync(destination)) throw new Error(`No managed Context Tree named ${name} exists.`);
25681
+ realManagedDirectory(name, destination);
25682
+ return connect(classifyCheckout(destination, runner));
25683
+ }
25684
+ const repository = githubRepositoryIdentitySchema.parse(options.target);
25685
+ const repositoryName = repository.split("/")[1];
25686
+ if (repositoryName === void 0) throw new Error("Repository must be OWNER/REPO.");
25687
+ const name = managedName(repositoryName.toLowerCase());
25688
+ const destination = join(treesRoot, name);
25689
+ if (existsSync(destination)) {
25690
+ realManagedDirectory(name, destination);
25691
+ const tree = classifyCheckout(destination, runner);
25692
+ if (tree.kind !== "github" || !sameRepository(tree.repository, repository)) throw new Error(`Managed Context Tree name ${name} is already used by a different tree.`);
25693
+ return connect(tree);
25694
+ }
25695
+ mkdirSync(destination, { mode: 448 });
25696
+ try {
25697
+ git(treesRoot, [
25698
+ "clone",
25699
+ "--quiet",
25700
+ "--origin",
25701
+ "origin",
25702
+ "--",
25703
+ canonicalGitHubRepositoryUrl(repository),
25704
+ destination
25705
+ ], {
25706
+ message: "Cloning the Context Tree repository failed.",
25707
+ runner
25708
+ });
25709
+ const tree = classifyCheckout(destination, runner);
25710
+ if (tree.kind !== "github" || !sameRepository(tree.repository, repository)) throw new Error("The cloned Context Tree origin does not match the requested repository.");
25711
+ return connect(tree);
25483
25712
  } catch (error) {
25484
- 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.";
25485
- throw new LinkError(CLI_ERROR_CODES.staleLink, message);
25713
+ rmSync(destination, {
25714
+ force: true,
25715
+ recursive: true
25716
+ });
25717
+ throw error;
25486
25718
  }
25487
25719
  }
25488
25720
  //#endregion
25489
- //#region src/core/live.ts
25490
- function git(root, args) {
25491
- const result = spawnSync("git", [
25492
- "-C",
25493
- root,
25494
- ...args
25721
+ //#region src/core/internal/packaged-resource.ts
25722
+ const PACKAGE_NAME = "@first-tree-ai/context-tree";
25723
+ function isPackageRoot(path) {
25724
+ const manifestPath = join(path, "package.json");
25725
+ if (!existsSync(manifestPath)) return false;
25726
+ try {
25727
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
25728
+ return isRecord(manifest) && manifest.name === PACKAGE_NAME;
25729
+ } catch {
25730
+ return false;
25731
+ }
25732
+ }
25733
+ function resolvePackagedResource(...segments) {
25734
+ let candidate = dirname(fileURLToPath(import.meta.url));
25735
+ const filesystemRoot = parse(candidate).root;
25736
+ while (true) {
25737
+ if (isPackageRoot(candidate)) {
25738
+ const resource = resolve(candidate, ...segments);
25739
+ if (existsSync(resource)) return resource;
25740
+ throw new Error(`Packaged resource is missing: ${segments.join("/")}`);
25741
+ }
25742
+ if (candidate === filesystemRoot) break;
25743
+ candidate = dirname(candidate);
25744
+ }
25745
+ throw new Error(`Package root is missing while resolving: ${segments.join("/")}`);
25746
+ }
25747
+ function readPackageManifest() {
25748
+ const parsed = JSON.parse(readFileSync(resolvePackagedResource("package.json"), "utf8"));
25749
+ if (!isRecord(parsed)) throw new Error("Package metadata is invalid.");
25750
+ return parsed;
25751
+ }
25752
+ function readPackageVersion() {
25753
+ const manifest = readPackageManifest();
25754
+ if (typeof manifest.version !== "string") throw new Error("Package version is missing or invalid.");
25755
+ return manifest.version;
25756
+ }
25757
+ //#endregion
25758
+ //#region src/core/scaffold.ts
25759
+ function template(name, values) {
25760
+ let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
25761
+ for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
25762
+ return result;
25763
+ }
25764
+ /** Templated regular files, written before the CLAUDE.md -> AGENTS.md symlink. */
25765
+ const TEMPLATED_FILES = [
25766
+ ["NODE.md", "root-node.md"],
25767
+ ["AGENTS.md", "AGENTS.md"],
25768
+ [".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
25769
+ ];
25770
+ const SCAFFOLD_FILES = [
25771
+ "NODE.md",
25772
+ "AGENTS.md",
25773
+ "CLAUDE.md",
25774
+ ".github/workflows/validate-context-tree.yml"
25775
+ ];
25776
+ function initializeGitRepository(root, runner) {
25777
+ gitCommand([
25778
+ "init",
25779
+ "--quiet",
25780
+ "--",
25781
+ root
25495
25782
  ], {
25496
- encoding: "utf8",
25497
- stdio: [
25498
- "ignore",
25499
- "pipe",
25500
- "ignore"
25501
- ]
25783
+ message: "Failed to initialize Git repository.",
25784
+ runner
25502
25785
  });
25503
- if (result.error !== void 0 || result.status !== 0) throw new Error("A Git operation failed while preparing the Context Tree.");
25504
- return typeof result.stdout === "string" ? result.stdout : "";
25505
- }
25506
- function requireGit(root, args, allowEmpty = false) {
25507
- const output = git(root, args).trim();
25508
- if (output.length === 0 && !allowEmpty) throw new Error("Unexpected empty Git output.");
25509
- return output;
25510
- }
25511
- function discoverDefaultBranch(root) {
25512
- const refs = git(root, [
25513
- "ls-remote",
25514
- "--symref",
25515
- "origin",
25516
- "HEAD"
25517
- ]).split("\n").map((line) => /^ref: refs\/heads\/([^\s\t]+)\tHEAD$/u.exec(line)?.[1]).filter((value) => value !== void 0 && value.length > 0);
25518
- if (refs.length !== 1) throw new Error("The Context Tree origin must report exactly one live default branch.");
25519
- return refs[0] ?? "";
25520
- }
25521
- function refreshProject(projectPath) {
25522
- const result = resolveLink(projectPath);
25523
- const root = result.link.tree.path;
25524
- const defaultBranch = discoverDefaultBranch(root);
25525
- if (requireGit(root, [
25786
+ return git(root, [
25526
25787
  "symbolic-ref",
25527
25788
  "--short",
25528
25789
  "HEAD"
25529
- ]) !== defaultBranch) throw new Error(`The Context Tree checkout must be on the live default branch "${defaultBranch}".`);
25530
- const before = requireGit(root, ["rev-parse", "HEAD"]);
25531
- requireGit(root, [
25532
- "pull",
25533
- "--ff-only",
25534
- "origin",
25535
- defaultBranch
25536
- ]);
25537
- const after = requireGit(root, ["rev-parse", "HEAD"]);
25538
- return {
25539
- link: {
25540
- ...result.link,
25541
- tree: {
25542
- ...result.link.tree,
25543
- path: realpathSync(root)
25544
- }
25545
- },
25546
- defaultBranch,
25547
- refreshed: before !== after,
25548
- schemaVersion: 1,
25549
- sha: after
25550
- };
25790
+ ], {
25791
+ message: "Failed to resolve the initial Git branch during repository initialization.",
25792
+ runner
25793
+ });
25551
25794
  }
25552
- function createIsolatedWorktree(treePath, baseSha) {
25553
- const destination = mkdtempSync(join(tmpdir(), "context-tree-stage-"));
25554
- const taskBranch = `context-tree/write/${basename(destination)}`;
25555
- git(treePath, [
25556
- "worktree",
25795
+ function commitScaffold(root, runner) {
25796
+ for (const file of SCAFFOLD_FILES) git(root, [
25557
25797
  "add",
25558
- "-b",
25559
- taskBranch,
25560
- destination,
25561
- baseSha
25562
- ]);
25798
+ "--",
25799
+ file
25800
+ ], {
25801
+ message: "Failed to stage the scaffold files.",
25802
+ runner
25803
+ });
25804
+ git(root, [
25805
+ "-c",
25806
+ "user.name=Context Tree",
25807
+ "-c",
25808
+ "user.email=context-tree@localhost",
25809
+ "-c",
25810
+ "commit.gpgsign=false",
25811
+ "commit",
25812
+ "--quiet",
25813
+ "-m",
25814
+ "Initialize Context Tree"
25815
+ ], {
25816
+ message: "Failed to commit the scaffold.",
25817
+ runner
25818
+ });
25819
+ return git(root, ["rev-parse", "HEAD"], {
25820
+ message: "Failed to resolve the scaffold commit.",
25821
+ runner
25822
+ });
25823
+ }
25824
+ function scaffoldTree(options) {
25825
+ const name = treeNameSchema.parse(options.name);
25826
+ const root = resolve(options.path);
25827
+ const destination = lstatSync(root, { throwIfNoEntry: false });
25828
+ if (destination !== void 0) {
25829
+ if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
25830
+ if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
25831
+ }
25832
+ const initialBranch = initializeGitRepository(root, options.runner);
25833
+ const values = {
25834
+ branchJson: JSON.stringify(initialBranch),
25835
+ packageVersion: readPackageVersion(),
25836
+ title: name,
25837
+ titleJson: JSON.stringify(name)
25838
+ };
25839
+ for (const [relativePath, source] of TEMPLATED_FILES) {
25840
+ const path = join(root, relativePath);
25841
+ mkdirSync(dirname(path), { recursive: true });
25842
+ writeFileSync(path, template(source, values), {
25843
+ encoding: "utf8",
25844
+ flag: "wx",
25845
+ mode: 420
25846
+ });
25847
+ }
25848
+ symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
25849
+ if (!verifyTree(root).ok) throw new Error("Refusing to commit an invalid Context Tree scaffold.");
25563
25850
  return {
25564
- taskBranch,
25565
- worktreePath: realpathSync(destination)
25851
+ branch: initialBranch,
25852
+ commit: commitScaffold(root, options.runner),
25853
+ root
25566
25854
  };
25567
25855
  }
25568
- function stageContextWrite(projectPath) {
25569
- const result = resolveLink(projectPath);
25570
- const treePath = result.link.tree.path;
25571
- const ownerRepository = result.link.tree.repository;
25572
- const defaultBranch = discoverDefaultBranch(treePath);
25573
- if (requireGit(treePath, [
25574
- "symbolic-ref",
25575
- "--short",
25576
- "HEAD"
25577
- ]) !== defaultBranch) throw new Error(`The Context Tree checkout must be on the live default branch "${defaultBranch}".`);
25578
- requireGit(treePath, [
25579
- "fetch",
25580
- "origin",
25581
- defaultBranch
25582
- ], true);
25583
- const baseSha = requireGit(treePath, ["rev-parse", `origin/${defaultBranch}`]);
25584
- const { taskBranch, worktreePath } = createIsolatedWorktree(treePath, baseSha);
25856
+ //#endregion
25857
+ //#region src/core/create.ts
25858
+ function projectName(canonicalRoot) {
25859
+ const normalized = basename(canonicalRoot).toLowerCase().replace(/[^a-z\d._-]+/gu, "-").replace(/-{2,}/gu, "-").replace(/^[-.]+/u, "").replace(/[-.]+$/u, "").slice(0, 40);
25860
+ return /^[a-z\d]/u.test(normalized) ? normalized : "project";
25861
+ }
25862
+ function existingCreateResult(canonical, destination, runner) {
25585
25863
  return {
25586
- baseSha,
25587
- link: {
25588
- project: result.link.project,
25589
- tree: {
25590
- path: treePath,
25591
- repository: ownerRepository
25592
- }
25593
- },
25594
- defaultBranch,
25864
+ branch: git(destination, [
25865
+ "symbolic-ref",
25866
+ "--short",
25867
+ "HEAD"
25868
+ ], {
25869
+ message: "Failed to resolve the managed tree branch.",
25870
+ runner
25871
+ }),
25872
+ commitSha: git(destination, ["rev-parse", "HEAD"], {
25873
+ message: "Failed to resolve the managed tree commit.",
25874
+ runner
25875
+ }),
25876
+ created: false,
25877
+ pointer: writeProjectPointer(canonical, destination),
25595
25878
  schemaVersion: 1,
25596
- taskBranch,
25597
- worktreePath
25879
+ title: readRootNode(destination).frontmatter.title,
25880
+ treePath: destination
25598
25881
  };
25599
25882
  }
25600
- const DIFF_STATUS = {
25601
- A: "added",
25602
- D: "deleted",
25603
- M: "modified",
25604
- R: "renamed"
25605
- };
25606
- function changedFiles(root, base) {
25607
- const output = git(root, [
25608
- "diff",
25609
- "--name-status",
25610
- base
25611
- ]);
25612
- const files = [];
25613
- for (const line of output.split("\n")) {
25614
- const match = /^([ADMR])\s+(.+)$/u.exec(line);
25615
- if (match === null) continue;
25616
- const status = DIFF_STATUS[match[1]];
25617
- if (status === void 0) continue;
25618
- files.push({
25619
- path: match[2] ?? "",
25620
- status
25883
+ /** Create and connect the project's uniquely named managed local Context Tree. */
25884
+ function createProject(projectPath, runner) {
25885
+ const canonical = canonicalProjectRoot(projectPath, runner);
25886
+ const name = treeNameSchema.parse(`${projectName(canonical)}-context-tree`);
25887
+ const destination = join(managedTreesRoot(), name);
25888
+ const current = findConnectionRecord(canonical, runner);
25889
+ 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.`);
25890
+ if (existsSync(destination)) {
25891
+ const entry = lstatSync(destination);
25892
+ if (entry.isSymbolicLink() || !entry.isDirectory() || current === void 0) throw new Error(`Managed Context Tree name ${name} is occupied; run context-tree connect ${name}.`);
25893
+ return existingCreateResult(canonical, destination, runner);
25894
+ }
25895
+ mkdirSync(destination, { mode: 448 });
25896
+ try {
25897
+ const scaffold = scaffoldTree({
25898
+ name,
25899
+ path: destination,
25900
+ runner
25621
25901
  });
25902
+ upsertConnection({
25903
+ projectPath: canonical,
25904
+ tree: {
25905
+ kind: "local",
25906
+ path: scaffold.root
25907
+ }
25908
+ }, runner);
25909
+ return {
25910
+ branch: scaffold.branch,
25911
+ commitSha: scaffold.commit,
25912
+ created: true,
25913
+ pointer: writeProjectPointer(canonical, scaffold.root),
25914
+ schemaVersion: 1,
25915
+ title: name,
25916
+ treePath: scaffold.root
25917
+ };
25918
+ } catch (error) {
25919
+ rmSync(destination, {
25920
+ force: true,
25921
+ recursive: true
25922
+ });
25923
+ throw error;
25622
25924
  }
25623
- const porcelain = git(root, [
25624
- "status",
25625
- "--porcelain",
25626
- "--untracked-files=all"
25627
- ]);
25628
- for (const line of porcelain.split("\n")) {
25629
- const path = /^\?\?\s+(.+)$/u.exec(line)?.[1];
25630
- if (path !== void 0 && path.length > 0) files.push({
25631
- path,
25632
- status: "added"
25925
+ }
25926
+ //#endregion
25927
+ //#region src/core/install.ts
25928
+ /** Per-host configuration directory, relative to the home directory or to a project root. */
25929
+ const HOST_CONFIG_DIRECTORY = {
25930
+ claude: ".claude",
25931
+ codex: ".codex"
25932
+ };
25933
+ /** Every supported host keeps user skills in the same subdirectory of its configuration directory. */
25934
+ const SKILLS_DIRECTORY = "skills";
25935
+ /** Only directories carrying this prefix are ever replaced or removed. */
25936
+ const OWNED_SKILL_PREFIX = "context-tree-";
25937
+ function realHome() {
25938
+ try {
25939
+ return realpathSync(homedir());
25940
+ } catch {
25941
+ return homedir();
25942
+ }
25943
+ }
25944
+ /** Create a directory below `root`, failing closed on symlinks and non-directories. */
25945
+ function ensureRealDirectory(root, segments) {
25946
+ let current = root;
25947
+ for (const segment of segments) {
25948
+ current = join(current, segment);
25949
+ const entry = lstatSync(current, { throwIfNoEntry: false });
25950
+ if (entry === void 0) {
25951
+ mkdirSync(current, { mode: 448 });
25952
+ continue;
25953
+ }
25954
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree skill directory must be a real directory: ${current}`);
25955
+ }
25956
+ return current;
25957
+ }
25958
+ /** Copy a packaged skill tree, refusing to read or write through symlinks. Skills are never executable. */
25959
+ function copyRealTree(source, destination) {
25960
+ const entry = lstatSync(source);
25961
+ if (entry.isSymbolicLink()) throw new Error(`Refusing to install a symlinked skill entry: ${source}`);
25962
+ if (entry.isDirectory()) {
25963
+ mkdirSync(destination, {
25964
+ mode: 448,
25965
+ recursive: true
25633
25966
  });
25967
+ for (const child of readdirSync(source)) copyRealTree(join(source, child), join(destination, child));
25968
+ return;
25969
+ }
25970
+ if (!entry.isFile()) throw new Error(`Refusing to install a non-regular skill entry: ${source}`);
25971
+ copyFileSync(source, destination);
25972
+ chmodSync(destination, 420);
25973
+ }
25974
+ /** Packaged skill directory names, e.g. `context-tree-read`. */
25975
+ function packagedSkillNames(skillsRoot) {
25976
+ return readdirSync(skillsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith(OWNED_SKILL_PREFIX)).map((entry) => entry.name).sort();
25977
+ }
25978
+ /** Resolve one host's destination, or the reason it was skipped. */
25979
+ function hostDestination(host, root, isProjectInstall) {
25980
+ const configDirectory = HOST_CONFIG_DIRECTORY[host];
25981
+ if (!isProjectInstall) {
25982
+ const hostRoot = join(root, configDirectory);
25983
+ const entry = lstatSync(hostRoot, { throwIfNoEntry: false });
25984
+ if (entry === void 0) return { reason: `${hostRoot} does not exist; install ${host} first, then run context-tree install.` };
25985
+ if (entry.isSymbolicLink() || !entry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` };
25634
25986
  }
25635
- return files;
25987
+ return { destination: ensureRealDirectory(root, [configDirectory, SKILLS_DIRECTORY]) };
25636
25988
  }
25637
- function inspectContextTreeDiff(treePath, base) {
25638
- const root = realpathSync(treePath);
25639
- const reference = base ?? "HEAD";
25989
+ /**
25990
+ * Copy the packaged skills into each requested host's skill directory.
25991
+ *
25992
+ * Existing `context-tree-*` directories are replaced; that is the upgrade path. Skill
25993
+ * directories the package does not own are never touched. A project install always
25994
+ * creates its target, because the caller named it.
25995
+ */
25996
+ function installSkills(options = {}) {
25997
+ const skillsRoot = resolvePackagedResource("skills");
25998
+ const skills = packagedSkillNames(skillsRoot);
25999
+ if (skills.length === 0) throw new Error("Packaged skills are missing; reinstall @first-tree-ai/context-tree.");
26000
+ const hosts = options.hosts === void 0 || options.hosts.length === 0 ? SKILL_HOSTS : options.hosts;
26001
+ const projectRoot = options.projectPath === void 0 ? void 0 : resolve(options.projectPath);
26002
+ const root = projectRoot ?? realHome();
26003
+ const installed = [];
26004
+ const skipped = [];
26005
+ for (const host of hosts) {
26006
+ const resolved = hostDestination(host, root, projectRoot !== void 0);
26007
+ if ("reason" in resolved) {
26008
+ skipped.push({
26009
+ host,
26010
+ reason: resolved.reason
26011
+ });
26012
+ continue;
26013
+ }
26014
+ for (const skill of skills) {
26015
+ const target = join(resolved.destination, skill);
26016
+ if (lstatSync(target, { throwIfNoEntry: false }) !== void 0) rmSync(target, {
26017
+ force: true,
26018
+ recursive: true
26019
+ });
26020
+ copyRealTree(join(skillsRoot, skill), target);
26021
+ }
26022
+ installed.push({
26023
+ host,
26024
+ path: resolved.destination,
26025
+ skills
26026
+ });
26027
+ }
25640
26028
  return {
25641
- base: reference,
25642
- files: changedFiles(root, reference),
25643
- patch: git(root, ["diff", reference]),
26029
+ installed,
25644
26030
  schemaVersion: 1,
25645
- treePath: root
26031
+ skipped,
26032
+ version: readPackageVersion()
25646
26033
  };
25647
26034
  }
25648
26035
  //#endregion
25649
- //#region src/core/policy.ts
25650
- function readContextTreePolicy() {
26036
+ //#region src/core/publish.ts
26037
+ function authenticatedAccount(runner) {
26038
+ let login;
26039
+ try {
26040
+ login = gh([
26041
+ "api",
26042
+ "user",
26043
+ "--jq",
26044
+ ".login"
26045
+ ], {
26046
+ message: "GitHub account lookup failed.",
26047
+ runner
26048
+ });
26049
+ } catch (error) {
26050
+ 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.");
26051
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub account lookup failed; publication did not start and must not be retried automatically.");
26052
+ }
26053
+ if (login.trim().length === 0) throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub account lookup returned no repository owner.");
26054
+ return login.trim();
26055
+ }
26056
+ function classifyCreationFailure(stderr) {
26057
+ 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.");
26058
+ return new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub repository creation has an uncertain or partial result; do not retry automatically.");
26059
+ }
26060
+ /**
26061
+ * Publish a clean, valid local tree as a private GitHub repository. The
26062
+ * default repository name derives from the authenticated account and managed
26063
+ * tree name; OWNER/REPO is accepted only as an explicit override. The initial
26064
+ * publication is one gh repo create operation, and the stored connection is
26065
+ * updated atomically to the published tree state.
26066
+ */
26067
+ function publishProject(projectPath, options = {}, runner) {
26068
+ const connection = resolveConnectionRecord(projectPath, runner);
26069
+ const root = connection.tree.path;
26070
+ 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.`);
26071
+ if (optionalGit(root, [
26072
+ "remote",
26073
+ "get-url",
26074
+ "origin"
26075
+ ], runner) !== void 0) throw new ContextTreeError(CLI_ERROR_CODES.failed, "A local Context Tree must not already have an origin before publication.");
26076
+ const branch = git(root, [
26077
+ "symbolic-ref",
26078
+ "--short",
26079
+ "HEAD"
26080
+ ], {
26081
+ message: "Failed to resolve the checked-out branch.",
26082
+ runner
26083
+ });
26084
+ const sha = git(root, ["rev-parse", "HEAD"], {
26085
+ message: "Failed to resolve the Context Tree commit.",
26086
+ runner
26087
+ });
26088
+ const repository = options.repository === void 0 ? `${authenticatedAccount(runner)}/${basename(root)}` : githubRepositoryIdentitySchema.parse(options.repository);
26089
+ const url = canonicalGitHubRepositoryUrl(repository);
26090
+ try {
26091
+ gh([
26092
+ "repo",
26093
+ "create",
26094
+ repository,
26095
+ "--private",
26096
+ "--source",
26097
+ root,
26098
+ "--remote",
26099
+ "origin",
26100
+ "--push"
26101
+ ], {
26102
+ message: "GitHub repository creation failed.",
26103
+ runner
26104
+ });
26105
+ } catch (error) {
26106
+ if (error instanceof CommandError) throw classifyCreationFailure(error.stderr);
26107
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub publication ended with an uncertain result.");
26108
+ }
26109
+ try {
26110
+ updateConnectionTree(connection.projectPath, {
26111
+ kind: "github",
26112
+ path: root,
26113
+ repository
26114
+ }, runner);
26115
+ } catch {
26116
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "The private repository was created, but updating the local connection failed.");
26117
+ }
25651
26118
  return {
25652
- content: readFileSync(resolvePackagedResource("policy", "context-tree-policy.md"), "utf8"),
25653
- schemaVersion: 1
26119
+ branch,
26120
+ repository,
26121
+ schemaVersion: 1,
26122
+ sha,
26123
+ url
25654
26124
  };
25655
26125
  }
25656
26126
  //#endregion
@@ -25724,98 +26194,271 @@ function readTree(treePath, path) {
25724
26194
  };
25725
26195
  }
25726
26196
  //#endregion
25727
- //#region src/core/scaffold.ts
25728
- function template(name, values) {
25729
- let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
25730
- for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
25731
- return result;
25732
- }
25733
- function initializeGitRepository(root, repository) {
25734
- const initialized = spawnSync("git", [
25735
- "init",
25736
- "--quiet",
25737
- root
25738
- ], { stdio: "ignore" });
25739
- if (initialized.error !== void 0 || initialized.status !== 0) throw new Error("Failed to initialize Git repository.");
25740
- const branch = spawnSync("git", [
25741
- "-C",
25742
- root,
26197
+ //#region src/core/sync.ts
26198
+ /**
26199
+ * Local trees report their checked-out state without network access. GitHub
26200
+ * trees fast-forward the exact checked-out branch once, then revalidate.
26201
+ */
26202
+ function syncProject(projectPath, runner) {
26203
+ const connection = resolveConnectionRecord(projectPath, runner);
26204
+ const root = connection.tree.path;
26205
+ const branch = git(root, [
25743
26206
  "symbolic-ref",
25744
26207
  "--short",
25745
26208
  "HEAD"
25746
26209
  ], {
25747
- encoding: "utf8",
25748
- stdio: [
25749
- "ignore",
25750
- "pipe",
25751
- "ignore"
25752
- ]
26210
+ message: "Failed to resolve the checked-out branch.",
26211
+ runner
25753
26212
  });
25754
- const name = branch.stdout.replace(/\r?\n$/u, "");
25755
- if (branch.error !== void 0 || branch.status !== 0 || name.length === 0) throw new Error("Failed to resolve the initial Git branch during repository initialization.");
25756
- const remote = spawnSync("git", [
25757
- "-C",
25758
- root,
25759
- "remote",
25760
- "add",
25761
- "origin",
25762
- canonicalGitHubRepositoryUrl(repository)
25763
- ], { stdio: "ignore" });
25764
- if (remote.error !== void 0 || remote.status !== 0) throw new Error("Failed to configure the credential-free Context Tree origin.");
25765
- return name;
25766
- }
25767
- function scaffoldTree(options) {
25768
- const title = parseGitHubRepositoryIdentity(options.repository);
25769
- const root = resolve(options.path);
25770
- const destination = lstatSync(root, { throwIfNoEntry: false });
25771
- if (destination !== void 0) {
25772
- if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
25773
- if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
26213
+ if (connection.tree.kind === "github") {
26214
+ git(root, [
26215
+ "pull",
26216
+ "--ff-only",
26217
+ "origin",
26218
+ branch
26219
+ ], {
26220
+ message: "Fast-forwarding the Context Tree failed.",
26221
+ runner
26222
+ });
26223
+ validateStoredTreeState(connection.tree, runner);
25774
26224
  }
25775
- const initialBranch = initializeGitRepository(root, options.repository);
25776
- const values = {
25777
- branchJson: JSON.stringify(initialBranch),
25778
- packageVersion: readPackageVersion(),
25779
- title,
25780
- titleJson: JSON.stringify(title)
26225
+ return {
26226
+ branch,
26227
+ schemaVersion: 1,
26228
+ sha: git(root, ["rev-parse", "HEAD"], {
26229
+ message: "Failed to resolve the Context Tree commit.",
26230
+ runner
26231
+ }),
26232
+ tree: connection.tree
25781
26233
  };
25782
- const regularFiles = [
25783
- ["NODE.md", "root-node.md"],
25784
- ["AGENTS.md", "AGENTS.md"],
25785
- [".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
25786
- ];
25787
- const files = [
25788
- "NODE.md",
25789
- "AGENTS.md",
25790
- "CLAUDE.md",
25791
- ".github/workflows/validate-context-tree.yml"
25792
- ];
25793
- for (const [relativePath, source] of regularFiles.slice(0, 2)) {
25794
- const path = join(root, relativePath);
25795
- mkdirSync(dirname(path), { recursive: true });
25796
- writeFileSync(path, template(source, values), {
25797
- encoding: "utf8",
25798
- flag: "wx",
25799
- mode: 420
26234
+ }
26235
+ //#endregion
26236
+ //#region src/core/write.ts
26237
+ const TASK_BRANCH_PREFIX = "context-tree/write/";
26238
+ /** A prepared worktree left untouched for longer than this is treated as abandoned. */
26239
+ const ABANDONED_WRITE_AGE_MS = 1440 * 60 * 1e3;
26240
+ /** Synchronize first, then create an isolated task worktree at the exact HEAD. */
26241
+ function prepareContextWrite(projectPath, runner) {
26242
+ const synchronized = syncProject(projectPath, runner);
26243
+ const root = synchronized.tree.path;
26244
+ reclaimAbandonedWrites(root, synchronized.branch, runner);
26245
+ const destination = mkdtempSync(join(tmpdir(), "context-tree-write-"));
26246
+ const taskBranch = `${TASK_BRANCH_PREFIX}${basename(destination)}`;
26247
+ try {
26248
+ git(root, [
26249
+ "worktree",
26250
+ "add",
26251
+ "--quiet",
26252
+ "-b",
26253
+ taskBranch,
26254
+ destination,
26255
+ synchronized.sha
26256
+ ], {
26257
+ message: "Creating the isolated write worktree failed.",
26258
+ runner
26259
+ });
26260
+ return {
26261
+ schemaVersion: 1,
26262
+ worktreePath: realDirectoryWithoutSymlinks(destination, "Write worktree")
26263
+ };
26264
+ } catch (error) {
26265
+ rmSync(destination, {
26266
+ force: true,
26267
+ recursive: true
25800
26268
  });
26269
+ throw error;
25801
26270
  }
25802
- symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
25803
- for (const [relativePath, source] of regularFiles.slice(2)) {
25804
- const path = join(root, relativePath);
25805
- mkdirSync(dirname(path), { recursive: true });
25806
- writeFileSync(path, template(source, values), {
25807
- encoding: "utf8",
25808
- flag: "wx",
25809
- mode: 420
26271
+ }
26272
+ /** Commit every pending change, then fast-forward locally or push once. */
26273
+ function finishContextWrite(options, runner) {
26274
+ const connection = resolveConnectionRecord(options.projectPath, runner);
26275
+ const root = connection.tree.path;
26276
+ const { taskBranch, worktreePath } = validatePreparedWorktree(root, options.worktreePath, runner);
26277
+ const branch = git(root, [
26278
+ "symbolic-ref",
26279
+ "--short",
26280
+ "HEAD"
26281
+ ], {
26282
+ message: "Failed to resolve the connected checkout branch.",
26283
+ runner
26284
+ });
26285
+ if (git(worktreePath, [
26286
+ "status",
26287
+ "--porcelain",
26288
+ "--untracked-files=all"
26289
+ ], {
26290
+ message: "Failed to inspect the prepared worktree.",
26291
+ runner
26292
+ }).length === 0) throw new Error("The prepared worktree has no pending changes.");
26293
+ 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}.`);
26294
+ git(worktreePath, ["add", "--all"], {
26295
+ message: "Staging the Context Tree changes failed.",
26296
+ runner
26297
+ });
26298
+ git(worktreePath, [
26299
+ "-c",
26300
+ "commit.gpgsign=false",
26301
+ "commit",
26302
+ "--quiet",
26303
+ "-m",
26304
+ options.message
26305
+ ], {
26306
+ message: "Committing the Context Tree changes failed.",
26307
+ runner
26308
+ });
26309
+ const sha = git(worktreePath, ["rev-parse", "HEAD"], {
26310
+ message: "Failed to resolve the write commit.",
26311
+ runner
26312
+ });
26313
+ try {
26314
+ if (connection.tree.kind === "local") git(root, [
26315
+ "merge",
26316
+ "--ff-only",
26317
+ taskBranch
26318
+ ], {
26319
+ message: "Fast-forwarding the local Context Tree failed.",
26320
+ runner
25810
26321
  });
26322
+ else git(worktreePath, [
26323
+ "push",
26324
+ "origin",
26325
+ `HEAD:refs/heads/${branch}`
26326
+ ], {
26327
+ message: "Publishing the Context Tree write failed.",
26328
+ runner
26329
+ });
26330
+ } catch (error) {
26331
+ if (isNonFastForward(error)) throw new ContextTreeError(CLI_ERROR_CODES.writeOutdated, `The Context Tree advanced; the prepared worktree is preserved at ${worktreePath}.`);
26332
+ throw error;
25811
26333
  }
26334
+ removeWorktree(root, worktreePath, taskBranch, runner);
25812
26335
  return {
25813
- files,
25814
- root,
26336
+ branch,
25815
26337
  schemaVersion: 1,
25816
- verification: verifyTree(root)
26338
+ sha
25817
26339
  };
25818
26340
  }
26341
+ function gitCommonDirectory(root, runner) {
26342
+ const value = git(root, ["rev-parse", "--git-common-dir"], {
26343
+ message: "Failed to resolve the Git common directory.",
26344
+ runner
26345
+ });
26346
+ return realDirectoryWithoutSymlinks(isAbsolute(value) ? value : resolve(root, value), "Git common directory");
26347
+ }
26348
+ function validatePreparedWorktree(root, suppliedPath, runner) {
26349
+ const worktreePath = realDirectoryWithoutSymlinks(suppliedPath, "Prepared worktree");
26350
+ if (gitCommonDirectory(worktreePath, runner) !== gitCommonDirectory(root, runner)) throw new Error("The prepared worktree does not belong to the connected Context Tree.");
26351
+ const taskBranch = git(worktreePath, [
26352
+ "symbolic-ref",
26353
+ "--short",
26354
+ "HEAD"
26355
+ ], {
26356
+ message: "Failed to resolve the worktree branch.",
26357
+ runner
26358
+ });
26359
+ if (!taskBranch.startsWith(TASK_BRANCH_PREFIX)) throw new Error("The prepared worktree is not on a reserved Context Tree write branch.");
26360
+ return {
26361
+ taskBranch,
26362
+ worktreePath
26363
+ };
26364
+ }
26365
+ function isNonFastForward(error) {
26366
+ 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);
26367
+ }
26368
+ function removeWorktree(root, worktreePath, taskBranch, runner) {
26369
+ git(root, [
26370
+ "worktree",
26371
+ "remove",
26372
+ worktreePath
26373
+ ], {
26374
+ message: "Removing the write worktree failed.",
26375
+ runner
26376
+ });
26377
+ git(root, [
26378
+ "branch",
26379
+ "-D",
26380
+ taskBranch
26381
+ ], {
26382
+ message: "Deleting the write branch failed.",
26383
+ runner
26384
+ });
26385
+ }
26386
+ /** Map every reserved write branch that still has a registered worktree to its path. */
26387
+ function listWriteWorktrees(root, runner) {
26388
+ const paths = /* @__PURE__ */ new Map();
26389
+ const output = optionalGit(root, [
26390
+ "worktree",
26391
+ "list",
26392
+ "--porcelain"
26393
+ ], runner);
26394
+ if (output === void 0) return paths;
26395
+ let path;
26396
+ for (const record of output.split("\n")) {
26397
+ if (record.startsWith("worktree ")) {
26398
+ path = record.slice(9).trim();
26399
+ continue;
26400
+ }
26401
+ if (!record.startsWith("branch refs/heads/")) continue;
26402
+ const branch = record.slice(18).trim();
26403
+ if (path !== void 0 && branch.startsWith(TASK_BRANCH_PREFIX)) paths.set(branch, path);
26404
+ }
26405
+ return paths;
26406
+ }
26407
+ function millisecondsSinceModification(path) {
26408
+ try {
26409
+ return Date.now() - statSync(path).mtimeMs;
26410
+ } catch {
26411
+ return;
26412
+ }
26413
+ }
26414
+ /**
26415
+ * A preparation is abandoned only when it carries no commit the connected
26416
+ * checkout lacks, has no pending edits, and has gone untouched. Every unknown
26417
+ * answer preserves the worktree, so a `WRITE_OUTDATED` commit awaiting its
26418
+ * retry and a concurrent preparation both survive.
26419
+ */
26420
+ function isAbandonedWrite(root, branch, checkoutBranch, path, runner) {
26421
+ if (optionalGit(root, [
26422
+ "rev-list",
26423
+ "--count",
26424
+ branch,
26425
+ "--not",
26426
+ checkoutBranch
26427
+ ], runner) !== "0") return false;
26428
+ if (path === void 0) return true;
26429
+ const age = millisecondsSinceModification(path);
26430
+ if (age === void 0 || age < ABANDONED_WRITE_AGE_MS) return false;
26431
+ return optionalGit(path, [
26432
+ "status",
26433
+ "--porcelain",
26434
+ "--untracked-files=all"
26435
+ ], runner) === "";
26436
+ }
26437
+ /** Reclaim earlier preparations that were never finished. Every step is best effort. */
26438
+ function reclaimAbandonedWrites(root, checkoutBranch, runner) {
26439
+ optionalGit(root, ["worktree", "prune"], runner);
26440
+ const paths = listWriteWorktrees(root, runner);
26441
+ const branches = optionalGit(root, [
26442
+ "for-each-ref",
26443
+ "--format=%(refname:short)",
26444
+ `refs/heads/${TASK_BRANCH_PREFIX}`
26445
+ ], runner);
26446
+ if (branches === void 0) return;
26447
+ for (const branch of branches.split("\n").filter((value) => value.length > 0)) {
26448
+ const path = paths.get(branch);
26449
+ if (!isAbandonedWrite(root, branch, checkoutBranch, path, runner)) continue;
26450
+ if (path !== void 0) optionalGit(root, [
26451
+ "worktree",
26452
+ "remove",
26453
+ path
26454
+ ], runner);
26455
+ optionalGit(root, [
26456
+ "branch",
26457
+ "-D",
26458
+ branch
26459
+ ], runner);
26460
+ }
26461
+ }
25819
26462
  //#endregion
25820
26463
  //#region src/cli/api.ts
25821
26464
  const defaultIo = {
@@ -25826,57 +26469,72 @@ function line(io, value) {
25826
26469
  io.stdout(`${value}\n`);
25827
26470
  }
25828
26471
  function createContextTreeCli(io = defaultIo) {
25829
- const program = new Command().name("context-tree").description("Portable tools for linking, resolving, scaffolding, reading, and validating Context Trees.").addHelpCommand(false).version(readPackageVersion()).exitOverride().configureOutput({
26472
+ const program = new Command().name("context-tree").description("Create, connect, list, read, write, and publish Context Trees.").addHelpCommand(false).version(readPackageVersion()).exitOverride().configureOutput({
25830
26473
  writeErr: () => void 0,
25831
26474
  writeOut: io.stdout
25832
26475
  });
25833
- program.command("link").description("Link a project to a verified Context Tree checkout.").requiredOption("--project-path <path>", "Git project or non-Git project directory").requiredOption("--tree-path <path>", "Context Tree checkout").action((options) => {
25834
- line(io, JSON.stringify(linkProject(resolve(io.cwd(), options.projectPath), resolve(io.cwd(), options.treePath))));
26476
+ program.command("create").description("Create and connect one uniquely named managed Context Tree for the current project.").option("--project-path <path>", "project directory", ".").action((options) => {
26477
+ line(io, JSON.stringify(createProject(resolve(io.cwd(), options.projectPath))));
26478
+ });
26479
+ program.command("connect").description("Connect the project by managed tree name, GitHub OWNER/REPO, or exact disk path.").argument("[name-or-repository]", "managed tree name or GitHub OWNER/REPO").option("--project-path <path>", "project directory", ".").option("--tree-path <path>", "exact Context Tree Git root to connect in place").action((target, options) => {
26480
+ const projectPath = resolve(io.cwd(), options.projectPath);
26481
+ if (target !== void 0 && options.treePath !== void 0) throw new Error("Connect requires exactly one of a name/repository or --tree-path.");
26482
+ if (target !== void 0) {
26483
+ line(io, JSON.stringify(connectProject({
26484
+ projectPath,
26485
+ target
26486
+ })));
26487
+ return;
26488
+ }
26489
+ if (options.treePath !== void 0) {
26490
+ line(io, JSON.stringify(connectProject({
26491
+ projectPath,
26492
+ treePath: resolve(io.cwd(), options.treePath)
26493
+ })));
26494
+ return;
26495
+ }
26496
+ throw new Error("Connect requires a managed tree name, GitHub OWNER/REPO, or --tree-path.");
25835
26497
  });
25836
- program.command("policy").description("Print the canonical packaged Context Tree policy.").action(() => {
25837
- line(io, JSON.stringify(readContextTreePolicy()));
26498
+ program.command("list").description("List valid clean managed Context Trees.").action(() => {
26499
+ line(io, JSON.stringify(listManagedTrees()));
25838
26500
  });
25839
- program.command("resolve").description("Resolve the linked Context Tree for a project.").option("--project-path <path>", "Git project or non-Git project directory", ".").action((options) => {
25840
- line(io, JSON.stringify(resolveLink(resolve(io.cwd(), options.projectPath))));
26501
+ program.command("resolve").description("Resolve the connected Context Tree for a project.").option("--project-path <path>", "project directory", ".").action((options) => {
26502
+ line(io, JSON.stringify(resolveConnection(resolve(io.cwd(), options.projectPath))));
25841
26503
  });
25842
- program.command("refresh").description("Refresh a linked Context Tree to its live default branch.").option("--project-path <path>", "Git Tree or non-Git project directory", ".").action((options) => {
25843
- line(io, JSON.stringify(refreshProject(resolve(io.cwd(), options.projectPath))));
26504
+ program.command("sync").description("Synchronize the connected Context Tree for a project.").option("--project-path <path>", "project directory", ".").action((options) => {
26505
+ line(io, JSON.stringify(syncProject(resolve(io.cwd(), options.projectPath))));
25844
26506
  });
25845
- program.command("stage").description("Prepare an isolated Context Tree worktree at the live default branch for a write.").option("--project-path <path>", "Git Tree or non-Git project directory", ".").action((options) => {
25846
- line(io, JSON.stringify(stageContextWrite(resolve(io.cwd(), options.projectPath))));
26507
+ program.command("prepare-write").description("Prepare an isolated Context Tree worktree for a write.").option("--project-path <path>", "project directory", ".").action((options) => {
26508
+ line(io, JSON.stringify(prepareContextWrite(resolve(io.cwd(), options.projectPath))));
25847
26509
  });
25848
- program.command("diff").description("Inspect the pending changes of a prepared Context Tree worktree.").argument("[tree-path]", "Context Tree root", ".").option("--base <ref>", "Base ref or commit to diff against (default HEAD)").action((treePath, options) => {
25849
- line(io, JSON.stringify(inspectContextTreeDiff(resolve(io.cwd(), treePath), options.base)));
26510
+ program.command("finish-write").description("Commit all pending changes in a prepared worktree and publish them.").requiredOption("--worktree-path <path>", "prepared worktree path").requiredOption("--message <message>", "commit message for the pending changes").option("--project-path <path>", "project directory", ".").action((options) => {
26511
+ line(io, JSON.stringify(finishContextWrite({
26512
+ message: options.message,
26513
+ projectPath: resolve(io.cwd(), options.projectPath),
26514
+ worktreePath: resolve(io.cwd(), options.worktreePath)
26515
+ })));
25850
26516
  });
25851
- program.command("init").description("Scaffold a new Context Tree.").requiredOption("--repository <owner/repo>", "GitHub repository identity").option("--tree-path <path>", "destination directory").action((options) => {
25852
- const repositoryName = parseGitHubRepositoryIdentity(options.repository);
25853
- const projectPath = resolve(io.cwd());
25854
- let project;
25855
- try {
25856
- project = identifyProject(projectPath);
25857
- } catch {}
25858
- const result = scaffoldTree({
25859
- path: resolve(io.cwd(), options.treePath ?? repositoryName),
25860
- repository: options.repository
25861
- });
25862
- if (project !== void 0) linkScaffoldedProject(projectPath, result.root);
25863
- line(io, JSON.stringify(result));
26517
+ program.command("publish").description("Publish the local tree as a new private GitHub repository.").argument("[repository]", "GitHub OWNER/REPO override; defaults to the authenticated account and tree name").option("--project-path <path>", "project directory", ".").action((repository, options) => {
26518
+ line(io, JSON.stringify(publishProject(resolve(io.cwd(), options.projectPath), { repository })));
25864
26519
  });
25865
26520
  program.command("read").description("Read an indexed Context Tree directory or Markdown leaf.").argument("[path]", "tree-relative path", ".").option("--tree-path <path>", "Context Tree root", ".").action((path, options) => {
25866
- if (!verifyTree(resolve(io.cwd(), options.treePath)).ok) throw new Error("Refusing to read an invalid Context Tree; run context-tree verify.");
25867
- const result = readTree(resolve(io.cwd(), options.treePath), path);
25868
- line(io, JSON.stringify(result));
26521
+ const treePath = resolve(io.cwd(), options.treePath);
26522
+ if (!verifyTree(treePath).ok) throw new ContextTreeError(CLI_ERROR_CODES.invalidTree, `Refusing to read an invalid Context Tree; run context-tree verify --tree-path ${treePath}.`);
26523
+ line(io, JSON.stringify(readTree(treePath, path)));
25869
26524
  });
25870
26525
  program.command("verify").description("Validate Context Tree structure and safety.").option("--tree-path <path>", "Context Tree root", ".").action((options) => {
25871
26526
  const result = verifyTree(resolve(io.cwd(), options.treePath));
25872
26527
  line(io, JSON.stringify(result));
25873
26528
  if (!result.ok) process.exitCode = 1;
25874
26529
  });
26530
+ program.command("install").description("Install the packaged Context Tree skills into each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, or all", "all").option("--project <path>", "install below this project root instead of the home directory").action((options) => {
26531
+ const request = {};
26532
+ if (options.host !== "all") request.hosts = [skillHostSchema.parse(options.host)];
26533
+ if (options.project !== void 0) request.projectPath = resolve(io.cwd(), options.project);
26534
+ line(io, JSON.stringify(installSkills(request)));
26535
+ });
25875
26536
  return program;
25876
26537
  }
25877
- function sanitizeError(message) {
25878
- return message.replace(/(?:https?|ssh):\/\/[^\s/@]+@/giu, "<redacted>@");
25879
- }
25880
26538
  async function runContextTreeCli(argv = process.argv, io = defaultIo) {
25881
26539
  const program = createContextTreeCli(io);
25882
26540
  try {
@@ -25886,8 +26544,8 @@ async function runContextTreeCli(argv = process.argv, io = defaultIo) {
25886
26544
  if (error instanceof CommanderError && error.exitCode === 0) return 0;
25887
26545
  const envelope = {
25888
26546
  error: {
25889
- code: error instanceof LinkError ? error.code : CLI_ERROR_CODES.failed,
25890
- message: sanitizeError(error instanceof Error ? error.message : String(error))
26547
+ code: error instanceof ContextTreeError ? error.code : CLI_ERROR_CODES.failed,
26548
+ message: sanitizeCommandOutput(error instanceof Error ? error.message : String(error))
25891
26549
  },
25892
26550
  ok: false,
25893
26551
  schemaVersion: 1