first-tree 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/dist/bootstrap-YRjfHJp7.js +28 -0
- package/dist/cli.js +14 -5
- package/dist/{help-DV9-AaFp.js → help-CDfaFrzl.js} +1 -1
- package/dist/{init-BgGH2_yC.js → init-DjSVkUeR.js} +19 -8
- package/dist/onboarding-BiHx2jy5.js +10 -0
- package/dist/onboarding-Ce033qaW.js +2 -0
- package/dist/publish-D0crNDjz.js +504 -0
- package/dist/{repo-Cc5U4DWT.js → repo-BeVpMoHi.js} +2 -15
- package/dist/{source-integration-CuKjoheT.js → source-integration-DMxnl8Dw.js} +2 -6
- package/dist/{upgrade-BvA9oKmi.js → upgrade-B_NTlNrx.js} +2 -4
- package/dist/{verify-G8gNXzDX.js → verify-Chhm1vOF.js} +3 -3
- package/package.json +1 -1
- package/skills/first-tree/SKILL.md +25 -6
- package/skills/first-tree/agents/openai.yaml +1 -1
- package/skills/first-tree/assets/framework/VERSION +1 -1
- package/skills/first-tree/engine/commands/publish.ts +5 -0
- package/skills/first-tree/engine/init.ts +24 -6
- package/skills/first-tree/engine/publish.ts +807 -0
- package/skills/first-tree/engine/repo.ts +0 -8
- package/skills/first-tree/engine/runtime/adapters.ts +0 -2
- package/skills/first-tree/engine/runtime/asset-loader.ts +1 -36
- package/skills/first-tree/engine/runtime/bootstrap.ts +40 -0
- package/skills/first-tree/engine/runtime/installer.ts +0 -2
- package/skills/first-tree/engine/upgrade.ts +0 -11
- package/skills/first-tree/engine/validators/nodes.ts +2 -11
- package/skills/first-tree/references/maintainer-build-and-distribution.md +3 -0
- package/skills/first-tree/references/maintainer-thin-cli.md +1 -1
- package/skills/first-tree/references/onboarding.md +18 -12
- package/skills/first-tree/references/source-map.md +3 -1
- package/skills/first-tree/references/source-workspace-installation.md +25 -13
- package/skills/first-tree/references/upgrade-contract.md +15 -8
- package/skills/first-tree/scripts/check-skill-sync.sh +0 -1
- package/skills/first-tree/tests/asset-loader.test.ts +0 -24
- package/skills/first-tree/tests/helpers.ts +0 -14
- package/skills/first-tree/tests/init.test.ts +25 -0
- package/skills/first-tree/tests/publish.test.ts +248 -0
- package/skills/first-tree/tests/repo.test.ts +0 -25
- package/skills/first-tree/tests/skill-artifacts.test.ts +16 -1
- package/skills/first-tree/tests/thin-cli.test.ts +6 -0
- package/skills/first-tree/tests/upgrade.test.ts +0 -21
- package/dist/onboarding-D7fGGOMN.js +0 -10
- package/dist/onboarding-lASHHmgO.js +0 -2
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
4
|
+
import { Repo } from "#skill/engine/repo.js";
|
|
5
|
+
import { readBootstrapState } from "#skill/engine/runtime/bootstrap.js";
|
|
6
|
+
import {
|
|
7
|
+
AGENT_INSTRUCTIONS_FILE,
|
|
8
|
+
CLAUDE_INSTRUCTIONS_FILE,
|
|
9
|
+
CLAUDE_SKILL_ROOT,
|
|
10
|
+
SKILL_ROOT,
|
|
11
|
+
} from "#skill/engine/runtime/asset-loader.js";
|
|
12
|
+
|
|
13
|
+
export const PUBLISH_USAGE = `usage: context-tree publish [--open-pr] [--tree-path PATH] [--source-repo PATH] [--submodule-path PATH] [--source-remote NAME]
|
|
14
|
+
|
|
15
|
+
Run this from the dedicated tree repo after \`context-tree init\`. The command
|
|
16
|
+
creates or reuses the GitHub \`*-context\` repo, pushes the current tree
|
|
17
|
+
commit, adds that repo back to the source/workspace repo as a git submodule,
|
|
18
|
+
and prepares the source-repo branch.
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--open-pr Open a PR in the source/workspace repo after pushing the branch
|
|
22
|
+
--tree-path PATH Publish a tree repo from another working directory
|
|
23
|
+
--source-repo PATH Explicit source/workspace repo path when it cannot be inferred
|
|
24
|
+
--submodule-path PATH Path to use inside the source/workspace repo (default: tree repo name)
|
|
25
|
+
--source-remote NAME Source/workspace repo remote to mirror on GitHub (default: origin)
|
|
26
|
+
--help Show this help message
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
interface CommandRunOptions {
|
|
30
|
+
cwd: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CommandRunner = (
|
|
34
|
+
command: string,
|
|
35
|
+
args: string[],
|
|
36
|
+
options: CommandRunOptions,
|
|
37
|
+
) => string;
|
|
38
|
+
|
|
39
|
+
export interface ParsedPublishArgs {
|
|
40
|
+
openPr?: boolean;
|
|
41
|
+
sourceRemote?: string;
|
|
42
|
+
sourceRepoPath?: string;
|
|
43
|
+
submodulePath?: string;
|
|
44
|
+
treePath?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PublishOptions extends ParsedPublishArgs {
|
|
48
|
+
commandRunner?: CommandRunner;
|
|
49
|
+
currentCwd?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface GitHubRemote {
|
|
53
|
+
cloneStyle: "https" | "ssh";
|
|
54
|
+
owner: string;
|
|
55
|
+
repo: string;
|
|
56
|
+
slug: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GitHubRepoMetadata {
|
|
60
|
+
defaultBranch: string;
|
|
61
|
+
nameWithOwner: string;
|
|
62
|
+
visibility: "internal" | "private" | "public";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultCommandRunner(
|
|
66
|
+
command: string,
|
|
67
|
+
args: string[],
|
|
68
|
+
options: CommandRunOptions,
|
|
69
|
+
): string {
|
|
70
|
+
try {
|
|
71
|
+
return execFileSync(command, args, {
|
|
72
|
+
cwd: options.cwd,
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
env: {
|
|
75
|
+
...process.env,
|
|
76
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
77
|
+
},
|
|
78
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
79
|
+
}).trim();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const message = err instanceof Error ? err.message : "unknown error";
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Command failed in ${options.cwd}: ${command} ${args.join(" ")}\n${message}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function commandSucceeds(
|
|
89
|
+
runner: CommandRunner,
|
|
90
|
+
command: string,
|
|
91
|
+
args: string[],
|
|
92
|
+
cwd: string,
|
|
93
|
+
): boolean {
|
|
94
|
+
try {
|
|
95
|
+
runner(command, args, { cwd });
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseGitHubRemote(url: string): GitHubRemote | null {
|
|
103
|
+
if (url.startsWith("https://") || url.startsWith("http://")) {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = new URL(url);
|
|
106
|
+
if (parsed.hostname !== "github.com") {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const parts = parsed.pathname.replace(/^\/+/, "").replace(/\.git$/, "").split("/");
|
|
110
|
+
if (parts.length !== 2 || parts.some((part) => part.trim() === "")) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
cloneStyle: "https",
|
|
115
|
+
owner: parts[0],
|
|
116
|
+
repo: parts[1],
|
|
117
|
+
slug: `${parts[0]}/${parts[1]}`,
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (url.startsWith("ssh://")) {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = new URL(url);
|
|
127
|
+
if (parsed.hostname !== "github.com") {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const parts = parsed.pathname.replace(/^\/+/, "").replace(/\.git$/, "").split("/");
|
|
131
|
+
if (parts.length !== 2 || parts.some((part) => part.trim() === "")) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
cloneStyle: "ssh",
|
|
136
|
+
owner: parts[0],
|
|
137
|
+
repo: parts[1],
|
|
138
|
+
slug: `${parts[0]}/${parts[1]}`,
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const scpMatch = url.match(/^git@github\.com:(.+?)\/(.+?)(?:\.git)?$/);
|
|
146
|
+
if (scpMatch === null) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
cloneStyle: "ssh",
|
|
151
|
+
owner: scpMatch[1],
|
|
152
|
+
repo: scpMatch[2],
|
|
153
|
+
slug: `${scpMatch[1]}/${scpMatch[2]}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function visibilityFlag(
|
|
158
|
+
visibility: GitHubRepoMetadata["visibility"],
|
|
159
|
+
): "--internal" | "--private" | "--public" {
|
|
160
|
+
switch (visibility) {
|
|
161
|
+
case "internal":
|
|
162
|
+
return "--internal";
|
|
163
|
+
case "private":
|
|
164
|
+
return "--private";
|
|
165
|
+
default:
|
|
166
|
+
return "--public";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildGitHubCloneUrl(
|
|
171
|
+
slug: string,
|
|
172
|
+
cloneStyle: GitHubRemote["cloneStyle"],
|
|
173
|
+
): string {
|
|
174
|
+
if (cloneStyle === "ssh") {
|
|
175
|
+
return `git@github.com:${slug}.git`;
|
|
176
|
+
}
|
|
177
|
+
return `https://github.com/${slug}.git`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readGitHubRepoMetadata(
|
|
181
|
+
runner: CommandRunner,
|
|
182
|
+
slug: string,
|
|
183
|
+
cwd: string,
|
|
184
|
+
): GitHubRepoMetadata {
|
|
185
|
+
const raw = runner(
|
|
186
|
+
"gh",
|
|
187
|
+
["repo", "view", slug, "--json", "defaultBranchRef,nameWithOwner,visibility"],
|
|
188
|
+
{ cwd },
|
|
189
|
+
);
|
|
190
|
+
const parsed = JSON.parse(raw) as {
|
|
191
|
+
defaultBranchRef?: { name?: string };
|
|
192
|
+
nameWithOwner?: string;
|
|
193
|
+
visibility?: string;
|
|
194
|
+
};
|
|
195
|
+
const defaultBranch = parsed.defaultBranchRef?.name;
|
|
196
|
+
const nameWithOwner = parsed.nameWithOwner;
|
|
197
|
+
const visibility = parsed.visibility?.toLowerCase();
|
|
198
|
+
if (
|
|
199
|
+
typeof defaultBranch !== "string"
|
|
200
|
+
|| typeof nameWithOwner !== "string"
|
|
201
|
+
|| (visibility !== "internal" && visibility !== "private" && visibility !== "public")
|
|
202
|
+
) {
|
|
203
|
+
throw new Error(`Could not read GitHub metadata for ${slug}.`);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
defaultBranch,
|
|
207
|
+
nameWithOwner,
|
|
208
|
+
visibility,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readCurrentBranch(
|
|
213
|
+
runner: CommandRunner,
|
|
214
|
+
root: string,
|
|
215
|
+
): string {
|
|
216
|
+
return runner("git", ["branch", "--show-current"], { cwd: root }).trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function hasCommit(
|
|
220
|
+
runner: CommandRunner,
|
|
221
|
+
root: string,
|
|
222
|
+
): boolean {
|
|
223
|
+
return commandSucceeds(runner, "git", ["rev-parse", "--verify", "HEAD"], root);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function hasIndexedChanges(
|
|
227
|
+
runner: CommandRunner,
|
|
228
|
+
root: string,
|
|
229
|
+
paths?: string[],
|
|
230
|
+
): boolean {
|
|
231
|
+
const args = ["diff", "--cached", "--quiet"];
|
|
232
|
+
if (paths && paths.length > 0) {
|
|
233
|
+
args.push("--", ...paths);
|
|
234
|
+
}
|
|
235
|
+
return !commandSucceeds(runner, "git", args, root);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function commitTreeState(
|
|
239
|
+
runner: CommandRunner,
|
|
240
|
+
treeRepo: Repo,
|
|
241
|
+
): boolean {
|
|
242
|
+
const hadCommit = hasCommit(runner, treeRepo.root);
|
|
243
|
+
runner("git", ["add", "-A"], { cwd: treeRepo.root });
|
|
244
|
+
if (!hasIndexedChanges(runner, treeRepo.root)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
runner(
|
|
248
|
+
"git",
|
|
249
|
+
["commit", "-m", hadCommit ? "chore: update context tree" : "chore: bootstrap context tree"],
|
|
250
|
+
{ cwd: treeRepo.root },
|
|
251
|
+
);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveSourceRepoRoot(
|
|
256
|
+
treeRepo: Repo,
|
|
257
|
+
options?: PublishOptions,
|
|
258
|
+
): string | null {
|
|
259
|
+
const cwd = options?.currentCwd ?? process.cwd();
|
|
260
|
+
|
|
261
|
+
if (options?.sourceRepoPath) {
|
|
262
|
+
return resolve(cwd, options.sourceRepoPath);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const bootstrap = readBootstrapState(treeRepo.root);
|
|
266
|
+
if (bootstrap !== null) {
|
|
267
|
+
return resolve(treeRepo.root, bootstrap.sourceRepoPath);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (treeRepo.repoName().endsWith("-context")) {
|
|
271
|
+
return join(
|
|
272
|
+
dirname(treeRepo.root),
|
|
273
|
+
treeRepo.repoName().slice(0, -"-context".length),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function normalizeSubmodulePath(input: string): string | null {
|
|
281
|
+
if (input.trim() === "") {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (isAbsolute(input)) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const normalized = normalize(input).replaceAll("\\", "/");
|
|
288
|
+
if (
|
|
289
|
+
normalized === "."
|
|
290
|
+
|| normalized === ""
|
|
291
|
+
|| normalized.startsWith("../")
|
|
292
|
+
|| normalized.includes("/../")
|
|
293
|
+
) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return normalized;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function getGitRemoteUrl(
|
|
300
|
+
runner: CommandRunner,
|
|
301
|
+
root: string,
|
|
302
|
+
remote: string,
|
|
303
|
+
): string | null {
|
|
304
|
+
try {
|
|
305
|
+
return runner("git", ["remote", "get-url", remote], { cwd: root }).trim();
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function localBranchExists(
|
|
312
|
+
runner: CommandRunner,
|
|
313
|
+
root: string,
|
|
314
|
+
branch: string,
|
|
315
|
+
): boolean {
|
|
316
|
+
return commandSucceeds(
|
|
317
|
+
runner,
|
|
318
|
+
"git",
|
|
319
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
320
|
+
root,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function remoteTrackingBranchExists(
|
|
325
|
+
runner: CommandRunner,
|
|
326
|
+
root: string,
|
|
327
|
+
remote: string,
|
|
328
|
+
branch: string,
|
|
329
|
+
): boolean {
|
|
330
|
+
return commandSucceeds(
|
|
331
|
+
runner,
|
|
332
|
+
"git",
|
|
333
|
+
["rev-parse", "--verify", `refs/remotes/${remote}/${branch}`],
|
|
334
|
+
root,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildPublishBranchName(treeRepoName: string): string {
|
|
339
|
+
const token = treeRepoName
|
|
340
|
+
.toLowerCase()
|
|
341
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
342
|
+
.replace(/^-+|-+$/g, "");
|
|
343
|
+
return `chore/connect-${token}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function ensureSourceBranch(
|
|
347
|
+
runner: CommandRunner,
|
|
348
|
+
sourceRepo: Repo,
|
|
349
|
+
sourceRemote: string,
|
|
350
|
+
defaultBranch: string,
|
|
351
|
+
treeRepoName: string,
|
|
352
|
+
): string {
|
|
353
|
+
const branch = buildPublishBranchName(treeRepoName);
|
|
354
|
+
const currentBranch = readCurrentBranch(runner, sourceRepo.root);
|
|
355
|
+
|
|
356
|
+
if (currentBranch === branch) {
|
|
357
|
+
return branch;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (localBranchExists(runner, sourceRepo.root, branch)) {
|
|
361
|
+
runner("git", ["switch", branch], { cwd: sourceRepo.root });
|
|
362
|
+
return branch;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!remoteTrackingBranchExists(runner, sourceRepo.root, sourceRemote, defaultBranch)) {
|
|
366
|
+
runner("git", ["fetch", sourceRemote, defaultBranch], { cwd: sourceRepo.root });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (remoteTrackingBranchExists(runner, sourceRepo.root, sourceRemote, defaultBranch)) {
|
|
370
|
+
runner(
|
|
371
|
+
"git",
|
|
372
|
+
["switch", "-c", branch, "--track", `${sourceRemote}/${defaultBranch}`],
|
|
373
|
+
{ cwd: sourceRepo.root },
|
|
374
|
+
);
|
|
375
|
+
return branch;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
runner("git", ["switch", "-c", branch], { cwd: sourceRepo.root });
|
|
379
|
+
return branch;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isTrackedSubmodule(
|
|
383
|
+
runner: CommandRunner,
|
|
384
|
+
sourceRoot: string,
|
|
385
|
+
submodulePath: string,
|
|
386
|
+
): boolean {
|
|
387
|
+
try {
|
|
388
|
+
const output = runner(
|
|
389
|
+
"git",
|
|
390
|
+
["ls-files", "--stage", "--", submodulePath],
|
|
391
|
+
{ cwd: sourceRoot },
|
|
392
|
+
);
|
|
393
|
+
return output
|
|
394
|
+
.split(/\r?\n/)
|
|
395
|
+
.some((line) => line.startsWith("160000 "));
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function ensureSubmodule(
|
|
402
|
+
runner: CommandRunner,
|
|
403
|
+
sourceRepo: Repo,
|
|
404
|
+
submodulePath: string,
|
|
405
|
+
remoteUrl: string,
|
|
406
|
+
): "added" | "updated" {
|
|
407
|
+
if (isTrackedSubmodule(runner, sourceRepo.root, submodulePath)) {
|
|
408
|
+
runner(
|
|
409
|
+
"git",
|
|
410
|
+
["submodule", "set-url", "--", submodulePath, remoteUrl],
|
|
411
|
+
{ cwd: sourceRepo.root },
|
|
412
|
+
);
|
|
413
|
+
runner(
|
|
414
|
+
"git",
|
|
415
|
+
["submodule", "sync", "--", submodulePath],
|
|
416
|
+
{ cwd: sourceRepo.root },
|
|
417
|
+
);
|
|
418
|
+
runner(
|
|
419
|
+
"git",
|
|
420
|
+
["submodule", "update", "--init", "--", submodulePath],
|
|
421
|
+
{ cwd: sourceRepo.root },
|
|
422
|
+
);
|
|
423
|
+
return "updated";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const submoduleRoot = join(sourceRepo.root, submodulePath);
|
|
427
|
+
if (existsSync(submoduleRoot)) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`Cannot add the submodule at ${submodulePath} because that path already exists in the source/workspace repo.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
runner(
|
|
434
|
+
"git",
|
|
435
|
+
["submodule", "add", remoteUrl, submodulePath],
|
|
436
|
+
{ cwd: sourceRepo.root },
|
|
437
|
+
);
|
|
438
|
+
return "added";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function commitSourceIntegration(
|
|
442
|
+
runner: CommandRunner,
|
|
443
|
+
sourceRepo: Repo,
|
|
444
|
+
submodulePath: string,
|
|
445
|
+
treeRepoName: string,
|
|
446
|
+
): boolean {
|
|
447
|
+
const managedPaths = [
|
|
448
|
+
...[
|
|
449
|
+
SKILL_ROOT,
|
|
450
|
+
CLAUDE_SKILL_ROOT,
|
|
451
|
+
AGENT_INSTRUCTIONS_FILE,
|
|
452
|
+
CLAUDE_INSTRUCTIONS_FILE,
|
|
453
|
+
].filter((path) => existsSync(join(sourceRepo.root, path))),
|
|
454
|
+
".gitmodules",
|
|
455
|
+
submodulePath,
|
|
456
|
+
].filter((path, index, items) => items.indexOf(path) === index);
|
|
457
|
+
|
|
458
|
+
runner("git", ["add", "--", ...managedPaths], { cwd: sourceRepo.root });
|
|
459
|
+
if (!hasIndexedChanges(runner, sourceRepo.root, managedPaths)) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
runner(
|
|
463
|
+
"git",
|
|
464
|
+
[
|
|
465
|
+
"commit",
|
|
466
|
+
"-m",
|
|
467
|
+
`chore: connect ${treeRepoName} context tree`,
|
|
468
|
+
"--",
|
|
469
|
+
...managedPaths,
|
|
470
|
+
],
|
|
471
|
+
{ cwd: sourceRepo.root },
|
|
472
|
+
);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function ensureTreeRemotePublished(
|
|
477
|
+
runner: CommandRunner,
|
|
478
|
+
treeRepo: Repo,
|
|
479
|
+
treeSlug: string,
|
|
480
|
+
sourceCloneStyle: GitHubRemote["cloneStyle"],
|
|
481
|
+
visibility: GitHubRepoMetadata["visibility"],
|
|
482
|
+
): { createdRemote: boolean; remoteUrl: string } {
|
|
483
|
+
const existingOrigin = getGitRemoteUrl(runner, treeRepo.root, "origin");
|
|
484
|
+
if (existingOrigin !== null) {
|
|
485
|
+
runner("git", ["push", "-u", "origin", "HEAD"], { cwd: treeRepo.root });
|
|
486
|
+
return {
|
|
487
|
+
createdRemote: false,
|
|
488
|
+
remoteUrl: existingOrigin,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const desiredCloneUrl = buildGitHubCloneUrl(treeSlug, sourceCloneStyle);
|
|
493
|
+
const repoAlreadyExists = commandSucceeds(
|
|
494
|
+
runner,
|
|
495
|
+
"gh",
|
|
496
|
+
["repo", "view", treeSlug, "--json", "nameWithOwner"],
|
|
497
|
+
treeRepo.root,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
if (repoAlreadyExists) {
|
|
501
|
+
runner(
|
|
502
|
+
"git",
|
|
503
|
+
["remote", "add", "origin", desiredCloneUrl],
|
|
504
|
+
{ cwd: treeRepo.root },
|
|
505
|
+
);
|
|
506
|
+
runner("git", ["push", "-u", "origin", "HEAD"], { cwd: treeRepo.root });
|
|
507
|
+
return {
|
|
508
|
+
createdRemote: false,
|
|
509
|
+
remoteUrl: desiredCloneUrl,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
runner(
|
|
514
|
+
"gh",
|
|
515
|
+
[
|
|
516
|
+
"repo",
|
|
517
|
+
"create",
|
|
518
|
+
treeSlug,
|
|
519
|
+
visibilityFlag(visibility),
|
|
520
|
+
"--source",
|
|
521
|
+
treeRepo.root,
|
|
522
|
+
"--remote",
|
|
523
|
+
"origin",
|
|
524
|
+
"--push",
|
|
525
|
+
],
|
|
526
|
+
{ cwd: treeRepo.root },
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
createdRemote: true,
|
|
531
|
+
remoteUrl: getGitRemoteUrl(runner, treeRepo.root, "origin") ?? desiredCloneUrl,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function buildPrBody(
|
|
536
|
+
treeRepoName: string,
|
|
537
|
+
treeSlug: string,
|
|
538
|
+
submodulePath: string,
|
|
539
|
+
): string {
|
|
540
|
+
return [
|
|
541
|
+
`Connect the published \`${treeRepoName}\` Context Tree back into this source/workspace repo.`,
|
|
542
|
+
"",
|
|
543
|
+
`- add \`${submodulePath}\` as the tracked Context Tree submodule`,
|
|
544
|
+
"- keep the local first-tree skill + source integration marker lines in this repo",
|
|
545
|
+
`- use \`${treeSlug}\` as the GitHub home for tree content`,
|
|
546
|
+
].join("\n");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function parsePublishArgs(
|
|
550
|
+
args: string[],
|
|
551
|
+
): ParsedPublishArgs | { error: string } {
|
|
552
|
+
const parsed: ParsedPublishArgs = {};
|
|
553
|
+
|
|
554
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
555
|
+
const arg = args[index];
|
|
556
|
+
switch (arg) {
|
|
557
|
+
case "--open-pr":
|
|
558
|
+
parsed.openPr = true;
|
|
559
|
+
break;
|
|
560
|
+
case "--tree-path": {
|
|
561
|
+
const value = args[index + 1];
|
|
562
|
+
if (!value) {
|
|
563
|
+
return { error: "Missing value for --tree-path" };
|
|
564
|
+
}
|
|
565
|
+
parsed.treePath = value;
|
|
566
|
+
index += 1;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case "--source-repo": {
|
|
570
|
+
const value = args[index + 1];
|
|
571
|
+
if (!value) {
|
|
572
|
+
return { error: "Missing value for --source-repo" };
|
|
573
|
+
}
|
|
574
|
+
parsed.sourceRepoPath = value;
|
|
575
|
+
index += 1;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case "--submodule-path": {
|
|
579
|
+
const value = args[index + 1];
|
|
580
|
+
if (!value) {
|
|
581
|
+
return { error: "Missing value for --submodule-path" };
|
|
582
|
+
}
|
|
583
|
+
parsed.submodulePath = value;
|
|
584
|
+
index += 1;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
case "--source-remote": {
|
|
588
|
+
const value = args[index + 1];
|
|
589
|
+
if (!value) {
|
|
590
|
+
return { error: "Missing value for --source-remote" };
|
|
591
|
+
}
|
|
592
|
+
parsed.sourceRemote = value;
|
|
593
|
+
index += 1;
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
default:
|
|
597
|
+
return { error: `Unknown publish option: ${arg}` };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return parsed;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function runPublish(repo?: Repo, options?: PublishOptions): number {
|
|
605
|
+
const cwd = options?.currentCwd ?? process.cwd();
|
|
606
|
+
const runner = options?.commandRunner ?? defaultCommandRunner;
|
|
607
|
+
const treeRepo = repo
|
|
608
|
+
?? new Repo(options?.treePath ? resolve(cwd, options.treePath) : undefined);
|
|
609
|
+
|
|
610
|
+
if (treeRepo.hasSourceWorkspaceIntegration() && !treeRepo.looksLikeTreeRepo()) {
|
|
611
|
+
console.error(
|
|
612
|
+
`Error: this repo only has the first-tree source/workspace integration installed. Run \`context-tree publish --tree-path ../${treeRepo.repoName()}-context\` or switch into the dedicated tree repo first.`,
|
|
613
|
+
);
|
|
614
|
+
return 1;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!treeRepo.hasFramework() || !treeRepo.looksLikeTreeRepo()) {
|
|
618
|
+
console.error(
|
|
619
|
+
"Error: `context-tree publish` must run from a dedicated tree repo (or use `--tree-path` to point at one). Run `context-tree init` first.",
|
|
620
|
+
);
|
|
621
|
+
return 1;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const sourceRepoRoot = resolveSourceRepoRoot(treeRepo, options);
|
|
625
|
+
if (sourceRepoRoot === null) {
|
|
626
|
+
console.error(
|
|
627
|
+
"Error: could not determine the source/workspace repo for this tree. Re-run `context-tree init` from the source repo first, or pass `--source-repo PATH`.",
|
|
628
|
+
);
|
|
629
|
+
return 1;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const sourceRepo = new Repo(sourceRepoRoot);
|
|
633
|
+
if (!sourceRepo.isGitRepo()) {
|
|
634
|
+
console.error(
|
|
635
|
+
`Error: the resolved source/workspace repo is not a git repository: ${sourceRepoRoot}`,
|
|
636
|
+
);
|
|
637
|
+
return 1;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (sourceRepo.root === treeRepo.root) {
|
|
641
|
+
console.error(
|
|
642
|
+
"Error: the source/workspace repo and dedicated tree repo resolved to the same path. `context-tree publish` expects two separate repos.",
|
|
643
|
+
);
|
|
644
|
+
return 1;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (!sourceRepo.hasCurrentInstalledSkill() || !sourceRepo.hasSourceWorkspaceIntegration()) {
|
|
648
|
+
console.error(
|
|
649
|
+
"Error: the source/workspace repo does not have the first-tree source integration installed. Run `context-tree init` from the source/workspace repo first.",
|
|
650
|
+
);
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const submodulePath = normalizeSubmodulePath(
|
|
655
|
+
options?.submodulePath ?? treeRepo.repoName(),
|
|
656
|
+
);
|
|
657
|
+
if (submodulePath === null) {
|
|
658
|
+
console.error(
|
|
659
|
+
"Error: `--submodule-path` must be a relative path inside the source/workspace repo.",
|
|
660
|
+
);
|
|
661
|
+
return 1;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const sourceRemoteName = options?.sourceRemote ?? "origin";
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
console.log("Context Tree Publish\n");
|
|
668
|
+
console.log(` Tree repo: ${treeRepo.root}`);
|
|
669
|
+
console.log(` Source repo: ${sourceRepo.root}\n`);
|
|
670
|
+
|
|
671
|
+
const sourceRemoteUrl = getGitRemoteUrl(runner, sourceRepo.root, sourceRemoteName);
|
|
672
|
+
if (sourceRemoteUrl === null) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
`Could not read git remote \`${sourceRemoteName}\` from the source/workspace repo.`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const sourceGitHub = parseGitHubRemote(sourceRemoteUrl);
|
|
679
|
+
if (sourceGitHub === null) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`The source/workspace remote \`${sourceRemoteName}\` is not a GitHub remote: ${sourceRemoteUrl}`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const sourceMetadata = readGitHubRepoMetadata(
|
|
686
|
+
runner,
|
|
687
|
+
sourceGitHub.slug,
|
|
688
|
+
sourceRepo.root,
|
|
689
|
+
);
|
|
690
|
+
const treeSlug = `${sourceGitHub.owner}/${treeRepo.repoName()}`;
|
|
691
|
+
|
|
692
|
+
const committedTreeChanges = commitTreeState(runner, treeRepo);
|
|
693
|
+
if (committedTreeChanges) {
|
|
694
|
+
console.log(" Committed the current tree state.");
|
|
695
|
+
} else {
|
|
696
|
+
console.log(" Tree repo already had a committed working state.");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const treeRemote = ensureTreeRemotePublished(
|
|
700
|
+
runner,
|
|
701
|
+
treeRepo,
|
|
702
|
+
treeSlug,
|
|
703
|
+
sourceGitHub.cloneStyle,
|
|
704
|
+
sourceMetadata.visibility,
|
|
705
|
+
);
|
|
706
|
+
if (treeRemote.createdRemote) {
|
|
707
|
+
console.log(` Created and pushed ${treeSlug}.`);
|
|
708
|
+
} else {
|
|
709
|
+
console.log(` Pushed the tree repo to ${treeRemote.remoteUrl}.`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const sourceBranch = ensureSourceBranch(
|
|
713
|
+
runner,
|
|
714
|
+
sourceRepo,
|
|
715
|
+
sourceRemoteName,
|
|
716
|
+
sourceMetadata.defaultBranch,
|
|
717
|
+
treeRepo.repoName(),
|
|
718
|
+
);
|
|
719
|
+
console.log(` Working on source/workspace branch \`${sourceBranch}\`.`);
|
|
720
|
+
|
|
721
|
+
const submoduleAction = ensureSubmodule(
|
|
722
|
+
runner,
|
|
723
|
+
sourceRepo,
|
|
724
|
+
submodulePath,
|
|
725
|
+
treeRemote.remoteUrl,
|
|
726
|
+
);
|
|
727
|
+
console.log(
|
|
728
|
+
submoduleAction === "added"
|
|
729
|
+
? ` Added \`${submodulePath}\` as a git submodule.`
|
|
730
|
+
: ` Updated the \`${submodulePath}\` submodule URL and checkout.`,
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const committedSourceChanges = commitSourceIntegration(
|
|
734
|
+
runner,
|
|
735
|
+
sourceRepo,
|
|
736
|
+
submodulePath,
|
|
737
|
+
treeRepo.repoName(),
|
|
738
|
+
);
|
|
739
|
+
if (committedSourceChanges) {
|
|
740
|
+
console.log(" Committed the source/workspace integration branch.");
|
|
741
|
+
} else {
|
|
742
|
+
console.log(
|
|
743
|
+
" Source/workspace integration was already up to date; no new commit was needed.",
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (committedSourceChanges || options?.openPr) {
|
|
748
|
+
runner(
|
|
749
|
+
"git",
|
|
750
|
+
["push", "-u", sourceRemoteName, sourceBranch],
|
|
751
|
+
{ cwd: sourceRepo.root },
|
|
752
|
+
);
|
|
753
|
+
console.log(` Pushed \`${sourceBranch}\` to \`${sourceRemoteName}\`.`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (options?.openPr) {
|
|
757
|
+
const prUrl = runner(
|
|
758
|
+
"gh",
|
|
759
|
+
[
|
|
760
|
+
"pr",
|
|
761
|
+
"create",
|
|
762
|
+
"--repo",
|
|
763
|
+
sourceMetadata.nameWithOwner,
|
|
764
|
+
"--base",
|
|
765
|
+
sourceMetadata.defaultBranch,
|
|
766
|
+
"--head",
|
|
767
|
+
sourceBranch,
|
|
768
|
+
"--title",
|
|
769
|
+
`chore: connect ${treeRepo.repoName()} context tree`,
|
|
770
|
+
"--body",
|
|
771
|
+
buildPrBody(treeRepo.repoName(), treeSlug, submodulePath),
|
|
772
|
+
],
|
|
773
|
+
{ cwd: sourceRepo.root },
|
|
774
|
+
);
|
|
775
|
+
console.log(` Opened PR: ${prUrl}`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
console.log();
|
|
779
|
+
console.log(
|
|
780
|
+
`The source/workspace repo's \`${submodulePath}\` checkout is now the canonical local working copy for this tree.`,
|
|
781
|
+
);
|
|
782
|
+
console.log(
|
|
783
|
+
`You can delete the temporary bootstrap checkout at ${treeRepo.root} once you no longer need it.`,
|
|
784
|
+
);
|
|
785
|
+
return 0;
|
|
786
|
+
} catch (err) {
|
|
787
|
+
const message = err instanceof Error ? err.message : "unknown error";
|
|
788
|
+
console.error(`Error: ${message}`);
|
|
789
|
+
return 1;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export function runPublishCli(args: string[] = []): number {
|
|
794
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
795
|
+
console.log(PUBLISH_USAGE);
|
|
796
|
+
return 0;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const parsed = parsePublishArgs(args);
|
|
800
|
+
if ("error" in parsed) {
|
|
801
|
+
console.error(parsed.error);
|
|
802
|
+
console.log(PUBLISH_USAGE);
|
|
803
|
+
return 1;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return runPublish(undefined, parsed);
|
|
807
|
+
}
|