first-tree 0.0.2 → 0.0.3
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 +73 -39
- package/dist/cli.js +27 -13
- package/dist/help-xEI-s9iN.js +25 -0
- package/dist/init-DtOjj0wc.js +253 -0
- package/dist/installer-rcZpGLnM.js +47 -0
- package/dist/onboarding-6Fr5Gkrk.js +2 -0
- package/dist/onboarding-B9zPGvvG.js +10 -0
- package/dist/repo-BTJG8BU1.js +187 -0
- package/dist/upgrade-COGgI7Rj.js +96 -0
- package/dist/{verify-CSRIkuoM.js → verify-CxN6JiV9.js} +53 -24
- package/package.json +33 -10
- package/skills/first-tree/SKILL.md +109 -0
- package/skills/first-tree/agents/openai.yaml +4 -0
- package/skills/first-tree/assets/framework/VERSION +1 -0
- package/skills/first-tree/assets/framework/examples/claude-code/README.md +14 -0
- package/skills/first-tree/assets/framework/examples/claude-code/settings.json +14 -0
- package/skills/first-tree/assets/framework/helpers/generate-codeowners.ts +224 -0
- package/skills/first-tree/assets/framework/helpers/inject-tree-context.sh +15 -0
- package/skills/first-tree/assets/framework/helpers/run-review.ts +179 -0
- package/skills/first-tree/assets/framework/manifest.json +11 -0
- package/skills/first-tree/assets/framework/prompts/pr-review.md +38 -0
- package/skills/first-tree/assets/framework/templates/agent.md.template +48 -0
- package/skills/first-tree/assets/framework/templates/member-node.md.template +18 -0
- package/skills/first-tree/assets/framework/templates/members-domain.md.template +45 -0
- package/skills/first-tree/assets/framework/templates/root-node.md.template +38 -0
- package/skills/first-tree/assets/framework/workflows/codeowners.yml +31 -0
- package/skills/first-tree/assets/framework/workflows/pr-review.yml +146 -0
- package/skills/first-tree/assets/framework/workflows/validate.yml +19 -0
- package/skills/first-tree/engine/commands/help.ts +32 -0
- package/skills/first-tree/engine/commands/init.ts +1 -0
- package/skills/first-tree/engine/commands/upgrade.ts +1 -0
- package/skills/first-tree/engine/commands/verify.ts +1 -0
- package/skills/first-tree/engine/init.ts +145 -0
- package/skills/first-tree/engine/onboarding.ts +10 -0
- package/skills/first-tree/engine/repo.ts +184 -0
- package/skills/first-tree/engine/rules/agent-instructions.ts +37 -0
- package/skills/first-tree/engine/rules/agent-integration.ts +19 -0
- package/skills/first-tree/engine/rules/ci-validation.ts +72 -0
- package/skills/first-tree/engine/rules/framework.ts +13 -0
- package/skills/first-tree/engine/rules/index.ts +41 -0
- package/skills/first-tree/engine/rules/members.ts +21 -0
- package/skills/first-tree/engine/rules/populate-tree.ts +36 -0
- package/skills/first-tree/engine/rules/root-node.ts +41 -0
- package/skills/first-tree/engine/runtime/adapters.ts +22 -0
- package/skills/first-tree/engine/runtime/asset-loader.ts +134 -0
- package/skills/first-tree/engine/runtime/installer.ts +82 -0
- package/skills/first-tree/engine/runtime/upgrader.ts +23 -0
- package/skills/first-tree/engine/upgrade.ts +176 -0
- package/skills/first-tree/engine/validators/members.ts +215 -0
- package/skills/first-tree/engine/validators/nodes.ts +514 -0
- package/skills/first-tree/engine/verify.ts +97 -0
- package/skills/first-tree/references/about.md +36 -0
- package/skills/first-tree/references/maintainer-architecture.md +59 -0
- package/skills/first-tree/references/maintainer-build-and-distribution.md +56 -0
- package/skills/first-tree/references/maintainer-testing.md +58 -0
- package/skills/first-tree/references/maintainer-thin-cli.md +38 -0
- package/skills/first-tree/references/onboarding.md +162 -0
- package/skills/first-tree/references/ownership-and-naming.md +94 -0
- package/skills/first-tree/references/principles.md +113 -0
- package/skills/first-tree/references/source-map.md +94 -0
- package/skills/first-tree/references/upgrade-contract.md +85 -0
- package/skills/first-tree/scripts/check-skill-sync.sh +133 -0
- package/skills/first-tree/scripts/quick_validate.py +95 -0
- package/skills/first-tree/scripts/run-local-cli.sh +35 -0
- package/skills/first-tree/tests/asset-loader.test.ts +75 -0
- package/skills/first-tree/tests/generate-codeowners.test.ts +94 -0
- package/skills/first-tree/tests/helpers.ts +149 -0
- package/skills/first-tree/tests/init.test.ts +153 -0
- package/skills/first-tree/tests/repo.test.ts +362 -0
- package/skills/first-tree/tests/rules.test.ts +394 -0
- package/skills/first-tree/tests/run-review.test.ts +155 -0
- package/skills/first-tree/tests/skill-artifacts.test.ts +307 -0
- package/skills/first-tree/tests/thin-cli.test.ts +59 -0
- package/skills/first-tree/tests/upgrade.test.ts +89 -0
- package/skills/first-tree/tests/validate-members.test.ts +224 -0
- package/skills/first-tree/tests/validate-nodes.test.ts +198 -0
- package/skills/first-tree/tests/verify.test.ts +142 -0
- package/dist/init-CE_944sb.js +0 -283
- package/dist/repo-BByc3VvM.js +0 -111
- package/dist/upgrade-Chr7z0CY.js +0 -82
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
|
|
8
|
+
const ROOT = process.cwd();
|
|
9
|
+
|
|
10
|
+
/** Check if a path has any tracked files in git (handles dirs and files). */
|
|
11
|
+
function isTrackedInGit(relativePath: string): boolean {
|
|
12
|
+
const result = spawnSync("git", ["ls-files", relativePath], {
|
|
13
|
+
cwd: ROOT,
|
|
14
|
+
stdio: "pipe",
|
|
15
|
+
});
|
|
16
|
+
return (result.stdout?.toString().trim().length ?? 0) > 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("skill artifacts", () => {
|
|
20
|
+
it("keeps only the canonical skill in the source repo", () => {
|
|
21
|
+
expect(existsSync(join(ROOT, "skills", "first-tree", "SKILL.md"))).toBe(true);
|
|
22
|
+
expect(existsSync(join(ROOT, "skills", "first-tree", "references", "onboarding.md"))).toBe(true);
|
|
23
|
+
expect(existsSync(join(ROOT, "skills", "first-tree", "assets", "framework", "manifest.json"))).toBe(true);
|
|
24
|
+
expect(existsSync(join(ROOT, "skills", "first-tree", "engine", "init.ts"))).toBe(true);
|
|
25
|
+
expect(existsSync(join(ROOT, "skills", "first-tree", "tests", "init.test.ts"))).toBe(
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
expect(
|
|
29
|
+
existsSync(
|
|
30
|
+
join(ROOT, "evals", "context-tree-eval.test.ts"),
|
|
31
|
+
),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
expect(
|
|
34
|
+
existsSync(
|
|
35
|
+
join(
|
|
36
|
+
ROOT,
|
|
37
|
+
"skills",
|
|
38
|
+
"first-tree",
|
|
39
|
+
"engine",
|
|
40
|
+
"runtime",
|
|
41
|
+
"asset-loader.ts",
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
expect(
|
|
46
|
+
existsSync(
|
|
47
|
+
join(
|
|
48
|
+
ROOT,
|
|
49
|
+
"skills",
|
|
50
|
+
"first-tree",
|
|
51
|
+
"engine",
|
|
52
|
+
"rules",
|
|
53
|
+
"index.ts",
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
).toBe(true);
|
|
57
|
+
expect(
|
|
58
|
+
existsSync(
|
|
59
|
+
join(
|
|
60
|
+
ROOT,
|
|
61
|
+
"skills",
|
|
62
|
+
"first-tree",
|
|
63
|
+
"engine",
|
|
64
|
+
"validators",
|
|
65
|
+
"nodes.ts",
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
expect(
|
|
70
|
+
existsSync(
|
|
71
|
+
join(
|
|
72
|
+
ROOT,
|
|
73
|
+
"skills",
|
|
74
|
+
"first-tree",
|
|
75
|
+
"references",
|
|
76
|
+
"maintainer-architecture.md",
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
).toBe(true);
|
|
80
|
+
expect(
|
|
81
|
+
existsSync(
|
|
82
|
+
join(
|
|
83
|
+
ROOT,
|
|
84
|
+
"skills",
|
|
85
|
+
"first-tree",
|
|
86
|
+
"references",
|
|
87
|
+
"maintainer-thin-cli.md",
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
expect(
|
|
92
|
+
existsSync(
|
|
93
|
+
join(
|
|
94
|
+
ROOT,
|
|
95
|
+
"skills",
|
|
96
|
+
"first-tree",
|
|
97
|
+
"references",
|
|
98
|
+
"maintainer-build-and-distribution.md",
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
expect(
|
|
103
|
+
existsSync(
|
|
104
|
+
join(
|
|
105
|
+
ROOT,
|
|
106
|
+
"skills",
|
|
107
|
+
"first-tree",
|
|
108
|
+
"references",
|
|
109
|
+
"maintainer-testing.md",
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
// Legacy artifacts must not be tracked in git (untracked local files are OK)
|
|
114
|
+
expect(isTrackedInGit(".agents")).toBe(false);
|
|
115
|
+
expect(isTrackedInGit(".claude")).toBe(false);
|
|
116
|
+
expect(isTrackedInGit(".context-tree")).toBe(false);
|
|
117
|
+
expect(isTrackedInGit("skills/first-tree-cli-framework")).toBe(false);
|
|
118
|
+
expect(isTrackedInGit("docs")).toBe(false);
|
|
119
|
+
expect(isTrackedInGit("tests")).toBe(false);
|
|
120
|
+
expect(existsSync(join(ROOT, "evals"))).toBe(true);
|
|
121
|
+
expect(existsSync(join(ROOT, "src", "commands"))).toBe(false);
|
|
122
|
+
expect(existsSync(join(ROOT, "src", "runtime"))).toBe(false);
|
|
123
|
+
expect(existsSync(join(ROOT, "src", "rules"))).toBe(false);
|
|
124
|
+
expect(existsSync(join(ROOT, "src", "validators"))).toBe(false);
|
|
125
|
+
expect(existsSync(join(ROOT, "src", "init.ts"))).toBe(false);
|
|
126
|
+
expect(existsSync(join(ROOT, "src", "verify.ts"))).toBe(false);
|
|
127
|
+
expect(existsSync(join(ROOT, "src", "upgrade.ts"))).toBe(false);
|
|
128
|
+
expect(existsSync(join(ROOT, "src", "repo.ts"))).toBe(false);
|
|
129
|
+
expect(existsSync(join(ROOT, "src", "onboarding.ts"))).toBe(false);
|
|
130
|
+
expect(
|
|
131
|
+
existsSync(join(ROOT, "skills", "first-tree", "references", "repo-snapshot")),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
expect(existsSync(join(ROOT, "skills", "first-tree", "evals"))).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("passes skill validation helpers", () => {
|
|
137
|
+
execFileSync(
|
|
138
|
+
"python3",
|
|
139
|
+
["./skills/first-tree/scripts/quick_validate.py", "./skills/first-tree"],
|
|
140
|
+
{
|
|
141
|
+
cwd: ROOT,
|
|
142
|
+
stdio: "pipe",
|
|
143
|
+
encoding: "utf-8",
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
execFileSync("bash", ["./skills/first-tree/scripts/check-skill-sync.sh"], {
|
|
147
|
+
cwd: ROOT,
|
|
148
|
+
stdio: "pipe",
|
|
149
|
+
encoding: "utf-8",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("ships the canonical skill in the published tarball", () => {
|
|
154
|
+
const packDir = mkdtempSync(join(tmpdir(), "first-tree-pack-"));
|
|
155
|
+
try {
|
|
156
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")) as {
|
|
157
|
+
name: string;
|
|
158
|
+
version: string;
|
|
159
|
+
};
|
|
160
|
+
execFileSync("pnpm", ["pack", "--pack-destination", packDir], {
|
|
161
|
+
cwd: ROOT,
|
|
162
|
+
stdio: "pipe",
|
|
163
|
+
encoding: "utf-8",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const tarball = join(packDir, `${pkg.name}-${pkg.version}.tgz`);
|
|
167
|
+
const listing = execFileSync("tar", ["-tf", tarball], {
|
|
168
|
+
cwd: ROOT,
|
|
169
|
+
stdio: "pipe",
|
|
170
|
+
encoding: "utf-8",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(listing).toContain("package/dist/cli.js");
|
|
174
|
+
expect(listing).toContain("package/skills/first-tree/SKILL.md");
|
|
175
|
+
expect(listing).toContain(
|
|
176
|
+
"package/skills/first-tree/agents/openai.yaml",
|
|
177
|
+
);
|
|
178
|
+
expect(listing).toContain(
|
|
179
|
+
"package/skills/first-tree/engine/init.ts",
|
|
180
|
+
);
|
|
181
|
+
expect(listing).toContain(
|
|
182
|
+
"package/skills/first-tree/tests/init.test.ts",
|
|
183
|
+
);
|
|
184
|
+
expect(listing).not.toContain("package/skills/first-tree/evals/");
|
|
185
|
+
expect(listing).not.toContain("package/evals/");
|
|
186
|
+
expect(listing).not.toContain("package/src/cli.ts");
|
|
187
|
+
expect(listing).not.toContain("package/docs/");
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(packDir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps naming and installation guidance aligned", () => {
|
|
194
|
+
const read = (path: string) => readFileSync(join(ROOT, path), "utf-8");
|
|
195
|
+
|
|
196
|
+
expect(read("README.md")).not.toContain("seed-tree");
|
|
197
|
+
expect(read("AGENTS.md")).not.toContain("seed-tree");
|
|
198
|
+
expect(read("README.md")).toContain("Package Name vs Command");
|
|
199
|
+
expect(read("README.md")).toContain("Canonical Documentation");
|
|
200
|
+
expect(read("README.md")).toContain("references/source-map.md");
|
|
201
|
+
expect(read("README.md")).toContain("skills/first-tree/");
|
|
202
|
+
expect(read("README.md")).toContain("bundled canonical");
|
|
203
|
+
expect(read("README.md")).toContain("`first-tree` skill");
|
|
204
|
+
expect(read("AGENTS.md")).toContain("references/source-map.md");
|
|
205
|
+
expect(read("AGENTS.md")).toContain("bundled skill path");
|
|
206
|
+
expect(read("AGENTS.md")).not.toContain("### Running evals");
|
|
207
|
+
expect(read("AGENTS.md")).not.toContain("EVALS_TREE_REPO");
|
|
208
|
+
expect(read("src/cli.ts")).not.toContain("from upstream");
|
|
209
|
+
// Note: #evals/* import alias is in package.json but evals/ is excluded from "files" so it won't ship to npm
|
|
210
|
+
|
|
211
|
+
const onboarding = read("skills/first-tree/references/onboarding.md");
|
|
212
|
+
expect(onboarding).toContain("npx first-tree init");
|
|
213
|
+
expect(onboarding).toContain("npm install -g first-tree");
|
|
214
|
+
expect(onboarding).toContain("installed CLI command is");
|
|
215
|
+
expect(onboarding).toContain("currently running `first-tree` npm package");
|
|
216
|
+
expect(onboarding).toContain("npx first-tree@latest upgrade");
|
|
217
|
+
expect(onboarding).not.toContain("This clones the framework into `.context-tree/`");
|
|
218
|
+
expect(onboarding).not.toContain("from upstream");
|
|
219
|
+
|
|
220
|
+
const skillMd = read("skills/first-tree/SKILL.md");
|
|
221
|
+
expect(skillMd).not.toContain("sync-skill-artifacts.sh");
|
|
222
|
+
expect(skillMd).not.toContain("portable-smoke-test.sh");
|
|
223
|
+
expect(skillMd).toContain("maintainer-build-and-distribution.md");
|
|
224
|
+
expect(skillMd).toContain("maintainer-testing.md");
|
|
225
|
+
expect(skillMd).toContain("currently running `first-tree` package");
|
|
226
|
+
expect(skillMd).toContain("so it is not confused with the `first-tree`");
|
|
227
|
+
expect(skillMd).not.toContain("canonical eval harness");
|
|
228
|
+
|
|
229
|
+
const sourceMap = read("skills/first-tree/references/source-map.md");
|
|
230
|
+
expect(sourceMap).not.toContain("repo-snapshot");
|
|
231
|
+
expect(sourceMap).not.toContain("sync-skill-artifacts.sh");
|
|
232
|
+
expect(sourceMap).toContain("maintainer-architecture.md");
|
|
233
|
+
expect(sourceMap).toContain("maintainer-thin-cli.md");
|
|
234
|
+
expect(sourceMap).toContain("maintainer-build-and-distribution.md");
|
|
235
|
+
expect(sourceMap).toContain("maintainer-testing.md");
|
|
236
|
+
expect(sourceMap).toContain("engine/commands/");
|
|
237
|
+
expect(sourceMap).toContain("engine/runtime/asset-loader.ts");
|
|
238
|
+
expect(sourceMap).toContain("tests/init.test.ts");
|
|
239
|
+
expect(sourceMap).toContain("tests/thin-cli.test.ts");
|
|
240
|
+
expect(sourceMap).not.toContain("evals/context-tree-eval.test.ts");
|
|
241
|
+
expect(sourceMap).toContain("package.json");
|
|
242
|
+
expect(sourceMap).not.toContain("vitest.eval.config.ts");
|
|
243
|
+
expect(sourceMap).toContain(".github/workflows/ci.yml");
|
|
244
|
+
|
|
245
|
+
const maintainerArchitecture = read(
|
|
246
|
+
"skills/first-tree/references/maintainer-architecture.md",
|
|
247
|
+
);
|
|
248
|
+
expect(maintainerArchitecture).toContain("maintainer-only developer tooling");
|
|
249
|
+
expect(maintainerArchitecture).toContain("`evals/`");
|
|
250
|
+
expect(maintainerArchitecture).not.toContain("tests, and evals");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("keeps public OSS entrypoints and package metadata in place", () => {
|
|
254
|
+
const read = (path: string) => readFileSync(join(ROOT, path), "utf-8");
|
|
255
|
+
const pkg = JSON.parse(read("package.json")) as {
|
|
256
|
+
homepage?: string;
|
|
257
|
+
bugs?: { url?: string };
|
|
258
|
+
repository?: { type?: string; url?: string };
|
|
259
|
+
keywords?: string[];
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
expect(read("README.md")).toContain("Package Name vs Command");
|
|
263
|
+
expect(read("README.md")).toContain("CONTRIBUTING.md");
|
|
264
|
+
expect(read("README.md")).toContain("CODE_OF_CONDUCT.md");
|
|
265
|
+
expect(read("README.md")).toContain("SECURITY.md");
|
|
266
|
+
|
|
267
|
+
expect(read("CONTRIBUTING.md")).toContain("pnpm validate:skill");
|
|
268
|
+
expect(read("CONTRIBUTING.md")).toContain("pull request template");
|
|
269
|
+
expect(read("CONTRIBUTING.md")).toContain("source-map.md");
|
|
270
|
+
expect(read("CODE_OF_CONDUCT.md")).toContain("private maintainer follow-up");
|
|
271
|
+
expect(read("SECURITY.md")).toContain("Private Vulnerability Reporting");
|
|
272
|
+
expect(read("SECURITY.md")).toContain("do not post exploit details");
|
|
273
|
+
|
|
274
|
+
expect(existsSync(join(ROOT, ".github", "PULL_REQUEST_TEMPLATE.md"))).toBe(true);
|
|
275
|
+
|
|
276
|
+
const bugTemplate = parse(read(".github/ISSUE_TEMPLATE/bug-report.yml")) as {
|
|
277
|
+
name?: string;
|
|
278
|
+
body?: unknown[];
|
|
279
|
+
};
|
|
280
|
+
const featureTemplate = parse(read(".github/ISSUE_TEMPLATE/feature-request.yml")) as {
|
|
281
|
+
name?: string;
|
|
282
|
+
body?: unknown[];
|
|
283
|
+
};
|
|
284
|
+
const issueConfig = parse(read(".github/ISSUE_TEMPLATE/config.yml")) as {
|
|
285
|
+
contact_links?: unknown[];
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
expect(bugTemplate.name).toBe("Bug report");
|
|
289
|
+
expect(Array.isArray(bugTemplate.body)).toBe(true);
|
|
290
|
+
expect(featureTemplate.name).toBe("Feature request");
|
|
291
|
+
expect(Array.isArray(featureTemplate.body)).toBe(true);
|
|
292
|
+
expect(Array.isArray(issueConfig.contact_links)).toBe(true);
|
|
293
|
+
expect(issueConfig.contact_links).toHaveLength(3);
|
|
294
|
+
|
|
295
|
+
expect(pkg.homepage).toBe("https://github.com/agent-team-foundation/first-tree#readme");
|
|
296
|
+
expect(pkg.bugs).toEqual({
|
|
297
|
+
url: "https://github.com/agent-team-foundation/first-tree/issues",
|
|
298
|
+
});
|
|
299
|
+
expect(pkg.repository).toEqual({
|
|
300
|
+
type: "git",
|
|
301
|
+
url: "git+https://github.com/agent-team-foundation/first-tree.git",
|
|
302
|
+
});
|
|
303
|
+
expect(pkg.keywords).toEqual(
|
|
304
|
+
expect.arrayContaining(["context-tree", "cli", "agents"]),
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { USAGE, runCli } from "../../../src/cli.ts";
|
|
5
|
+
|
|
6
|
+
const ROOT = process.cwd();
|
|
7
|
+
|
|
8
|
+
function captureOutput(): { lines: string[]; write: (text: string) => void } {
|
|
9
|
+
const lines: string[] = [];
|
|
10
|
+
return {
|
|
11
|
+
lines,
|
|
12
|
+
write: (text: string) => {
|
|
13
|
+
lines.push(text);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("thin CLI shell", () => {
|
|
19
|
+
it("prints usage with no args", async () => {
|
|
20
|
+
const output = captureOutput();
|
|
21
|
+
|
|
22
|
+
const code = await runCli([], output.write);
|
|
23
|
+
|
|
24
|
+
expect(code).toBe(0);
|
|
25
|
+
expect(output.lines).toEqual([USAGE]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("prints the package version", async () => {
|
|
29
|
+
const output = captureOutput();
|
|
30
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")) as {
|
|
31
|
+
version: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const code = await runCli(["--version"], output.write);
|
|
35
|
+
|
|
36
|
+
expect(code).toBe(0);
|
|
37
|
+
expect(output.lines).toEqual([pkg.version]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("routes help onboarding through the CLI entrypoint", async () => {
|
|
41
|
+
const output = captureOutput();
|
|
42
|
+
|
|
43
|
+
const code = await runCli(["help", "onboarding"], output.write);
|
|
44
|
+
|
|
45
|
+
expect(code).toBe(0);
|
|
46
|
+
expect(output.lines.join("\n")).toContain("# Context Tree Onboarding");
|
|
47
|
+
expect(output.lines.join("\n")).toContain("Node.js 18+");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("fails with usage for an unknown command", async () => {
|
|
51
|
+
const output = captureOutput();
|
|
52
|
+
|
|
53
|
+
const code = await runCli(["wat"], output.write);
|
|
54
|
+
|
|
55
|
+
expect(code).toBe(1);
|
|
56
|
+
expect(output.lines[0]).toBe("Unknown command: wat");
|
|
57
|
+
expect(output.lines[1]).toBe(USAGE);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { Repo } from "#skill/engine/repo.js";
|
|
5
|
+
import { runUpgrade } from "#skill/engine/upgrade.js";
|
|
6
|
+
import {
|
|
7
|
+
FRAMEWORK_VERSION,
|
|
8
|
+
INSTALLED_PROGRESS,
|
|
9
|
+
} from "#skill/engine/runtime/asset-loader.js";
|
|
10
|
+
import {
|
|
11
|
+
makeFramework,
|
|
12
|
+
makeLegacyFramework,
|
|
13
|
+
makeLegacyNamedFramework,
|
|
14
|
+
makeSourceSkill,
|
|
15
|
+
useTmpDir,
|
|
16
|
+
} from "./helpers.js";
|
|
17
|
+
|
|
18
|
+
describe("runUpgrade", () => {
|
|
19
|
+
it("migrates a legacy repo to the installed skill layout", () => {
|
|
20
|
+
const repoDir = useTmpDir();
|
|
21
|
+
const sourceDir = useTmpDir();
|
|
22
|
+
makeLegacyFramework(repoDir.path, "0.1.0");
|
|
23
|
+
writeFileSync(
|
|
24
|
+
join(repoDir.path, "AGENT.md"),
|
|
25
|
+
"<!-- BEGIN CONTEXT-TREE FRAMEWORK -->\nstuff\n<!-- END CONTEXT-TREE FRAMEWORK -->\n",
|
|
26
|
+
);
|
|
27
|
+
makeSourceSkill(sourceDir.path, "0.2.0");
|
|
28
|
+
|
|
29
|
+
const result = runUpgrade(new Repo(repoDir.path), {
|
|
30
|
+
sourceRoot: sourceDir.path,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result).toBe(0);
|
|
34
|
+
expect(existsSync(join(repoDir.path, ".context-tree"))).toBe(false);
|
|
35
|
+
expect(readFileSync(join(repoDir.path, FRAMEWORK_VERSION), "utf-8").trim()).toBe("0.2.0");
|
|
36
|
+
expect(readFileSync(join(repoDir.path, INSTALLED_PROGRESS), "utf-8")).toContain(
|
|
37
|
+
"skills/first-tree/assets/framework/VERSION",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns early when the installed skill already matches the packaged skill", () => {
|
|
42
|
+
const repoDir = useTmpDir();
|
|
43
|
+
const sourceDir = useTmpDir();
|
|
44
|
+
makeFramework(repoDir.path, "0.2.0");
|
|
45
|
+
makeSourceSkill(sourceDir.path, "0.2.0");
|
|
46
|
+
|
|
47
|
+
const result = runUpgrade(new Repo(repoDir.path), {
|
|
48
|
+
sourceRoot: sourceDir.path,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(result).toBe(0);
|
|
52
|
+
expect(existsSync(join(repoDir.path, INSTALLED_PROGRESS))).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("migrates repos that still use the previous installed skill name", () => {
|
|
56
|
+
const repoDir = useTmpDir();
|
|
57
|
+
const sourceDir = useTmpDir();
|
|
58
|
+
makeLegacyNamedFramework(repoDir.path, "0.2.0");
|
|
59
|
+
makeSourceSkill(sourceDir.path, "0.2.0");
|
|
60
|
+
|
|
61
|
+
const result = runUpgrade(new Repo(repoDir.path), {
|
|
62
|
+
sourceRoot: sourceDir.path,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result).toBe(0);
|
|
66
|
+
expect(existsSync(join(repoDir.path, "skills", "first-tree-cli-framework"))).toBe(
|
|
67
|
+
false,
|
|
68
|
+
);
|
|
69
|
+
expect(readFileSync(join(repoDir.path, FRAMEWORK_VERSION), "utf-8").trim()).toBe("0.2.0");
|
|
70
|
+
expect(readFileSync(join(repoDir.path, INSTALLED_PROGRESS), "utf-8")).toContain(
|
|
71
|
+
"skills/first-tree-cli-framework/",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("refuses to replace a newer installed skill with an older packaged skill", () => {
|
|
76
|
+
const repoDir = useTmpDir();
|
|
77
|
+
const sourceDir = useTmpDir();
|
|
78
|
+
makeFramework(repoDir.path, "0.3.0");
|
|
79
|
+
makeSourceSkill(sourceDir.path, "0.2.0");
|
|
80
|
+
|
|
81
|
+
const result = runUpgrade(new Repo(repoDir.path), {
|
|
82
|
+
sourceRoot: sourceDir.path,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result).toBe(1);
|
|
86
|
+
expect(readFileSync(join(repoDir.path, FRAMEWORK_VERSION), "utf-8").trim()).toBe("0.3.0");
|
|
87
|
+
expect(existsSync(join(repoDir.path, INSTALLED_PROGRESS))).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
extractScalar,
|
|
6
|
+
extractList,
|
|
7
|
+
validateMember,
|
|
8
|
+
runValidateMembers,
|
|
9
|
+
} from "#skill/engine/validators/members.js";
|
|
10
|
+
import { useTmpDir } from "./helpers.js";
|
|
11
|
+
|
|
12
|
+
function write(root: string, relPath: string, content: string): string {
|
|
13
|
+
const p = join(root, relPath);
|
|
14
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
15
|
+
writeFileSync(p, content);
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const VALID_MEMBER = `---
|
|
20
|
+
title: Alice
|
|
21
|
+
owners: [alice]
|
|
22
|
+
type: human
|
|
23
|
+
role: Engineer
|
|
24
|
+
domains: [engineering]
|
|
25
|
+
---
|
|
26
|
+
# Alice
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const VALID_ASSISTANT = `---
|
|
30
|
+
title: Alice Assistant
|
|
31
|
+
owners: [alice]
|
|
32
|
+
type: personal_assistant
|
|
33
|
+
role: Assistant
|
|
34
|
+
domains: [engineering]
|
|
35
|
+
---
|
|
36
|
+
# Alice Assistant
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
// --- validateMember ---
|
|
40
|
+
|
|
41
|
+
describe("validateMember", () => {
|
|
42
|
+
it("accepts valid member", () => {
|
|
43
|
+
const tmp = useTmpDir();
|
|
44
|
+
const p = write(tmp.path, "members/alice/NODE.md", VALID_MEMBER);
|
|
45
|
+
expect(validateMember(p, tmp.path)).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reports missing title", () => {
|
|
49
|
+
const tmp = useTmpDir();
|
|
50
|
+
const content = "---\nowners: [alice]\ntype: human\nrole: Eng\ndomains: [eng]\n---\n";
|
|
51
|
+
const p = write(tmp.path, "members/alice/NODE.md", content);
|
|
52
|
+
const errors = validateMember(p, tmp.path);
|
|
53
|
+
expect(errors.some((e) => e.includes("title"))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reports missing type", () => {
|
|
57
|
+
const tmp = useTmpDir();
|
|
58
|
+
const content = "---\ntitle: Alice\nowners: [alice]\nrole: Eng\ndomains: [eng]\n---\n";
|
|
59
|
+
const p = write(tmp.path, "members/alice/NODE.md", content);
|
|
60
|
+
const errors = validateMember(p, tmp.path);
|
|
61
|
+
expect(errors.some((e) => e.includes("type"))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reports invalid type", () => {
|
|
65
|
+
const tmp = useTmpDir();
|
|
66
|
+
const content = "---\ntitle: Alice\nowners: [alice]\ntype: robot\nrole: Eng\ndomains: [eng]\n---\n";
|
|
67
|
+
const p = write(tmp.path, "members/alice/NODE.md", content);
|
|
68
|
+
const errors = validateMember(p, tmp.path);
|
|
69
|
+
expect(errors.some((e) => e.includes("invalid type"))).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reports missing domains", () => {
|
|
73
|
+
const tmp = useTmpDir();
|
|
74
|
+
const content = "---\ntitle: Alice\nowners: [alice]\ntype: human\nrole: Eng\n---\n";
|
|
75
|
+
const p = write(tmp.path, "members/alice/NODE.md", content);
|
|
76
|
+
const errors = validateMember(p, tmp.path);
|
|
77
|
+
expect(errors.some((e) => e.includes("domains"))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// --- runValidateMembers: recursive scanning ---
|
|
82
|
+
|
|
83
|
+
describe("runValidateMembers", () => {
|
|
84
|
+
it("validates nested members recursively", () => {
|
|
85
|
+
const tmp = useTmpDir();
|
|
86
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
87
|
+
write(tmp.path, "members/alice/NODE.md", VALID_MEMBER);
|
|
88
|
+
write(tmp.path, "members/team-a/NODE.md", `---\ntitle: Team A\nowners: [lead]\ntype: autonomous_agent\nrole: Team Lead\ndomains: [engineering]\n---\n`);
|
|
89
|
+
write(tmp.path, "members/team-a/bot-1/NODE.md", `---\ntitle: Bot 1\nowners: [lead]\ntype: autonomous_agent\nrole: Worker\ndomains: [engineering]\n---\n`);
|
|
90
|
+
|
|
91
|
+
const result = runValidateMembers(tmp.path);
|
|
92
|
+
expect(result.exitCode).toBe(0);
|
|
93
|
+
expect(result.errors).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("detects duplicate directory names across levels", () => {
|
|
97
|
+
const tmp = useTmpDir();
|
|
98
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
99
|
+
write(tmp.path, "members/alice/NODE.md", VALID_MEMBER);
|
|
100
|
+
// Same name "alice" nested under team-a
|
|
101
|
+
write(tmp.path, "members/team-a/NODE.md", `---\ntitle: Team A\nowners: [lead]\ntype: autonomous_agent\nrole: Lead\ndomains: [eng]\n---\n`);
|
|
102
|
+
write(tmp.path, "members/team-a/alice/NODE.md", `---\ntitle: Alice Clone\nowners: [alice2]\ntype: human\nrole: Eng\ndomains: [eng]\n---\n`);
|
|
103
|
+
|
|
104
|
+
const result = runValidateMembers(tmp.path);
|
|
105
|
+
expect(result.exitCode).toBe(1);
|
|
106
|
+
expect(result.errors.some((e) => e.includes("Duplicate member directory name 'alice'"))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("reports missing NODE.md in nested directories", () => {
|
|
110
|
+
const tmp = useTmpDir();
|
|
111
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
112
|
+
write(tmp.path, "members/alice/NODE.md", VALID_MEMBER);
|
|
113
|
+
// Create directory without NODE.md
|
|
114
|
+
mkdirSync(join(tmp.path, "members/team-a/orphan"), { recursive: true });
|
|
115
|
+
write(tmp.path, "members/team-a/NODE.md", `---\ntitle: Team A\nowners: [lead]\ntype: autonomous_agent\nrole: Lead\ndomains: [eng]\n---\n`);
|
|
116
|
+
|
|
117
|
+
const result = runValidateMembers(tmp.path);
|
|
118
|
+
expect(result.exitCode).toBe(1);
|
|
119
|
+
expect(result.errors.some((e) => e.includes("orphan") && e.includes("missing NODE.md"))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("validates deeply nested members (3+ levels)", () => {
|
|
123
|
+
const tmp = useTmpDir();
|
|
124
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
125
|
+
write(tmp.path, "members/org/NODE.md", `---\ntitle: Org\nowners: [admin]\ntype: autonomous_agent\nrole: Org\ndomains: [all]\n---\n`);
|
|
126
|
+
write(tmp.path, "members/org/team-a/NODE.md", `---\ntitle: Team A\nowners: [lead]\ntype: autonomous_agent\nrole: Lead\ndomains: [eng]\n---\n`);
|
|
127
|
+
write(tmp.path, "members/org/team-a/bot-1/NODE.md", `---\ntitle: Bot 1\nowners: [lead]\ntype: autonomous_agent\nrole: Worker\ndomains: [eng]\n---\n`);
|
|
128
|
+
|
|
129
|
+
const result = runValidateMembers(tmp.path);
|
|
130
|
+
expect(result.exitCode).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// --- runValidateMembers: delegate_mention cross-validation ---
|
|
135
|
+
|
|
136
|
+
describe("runValidateMembers delegate_mention", () => {
|
|
137
|
+
it("accepts valid delegate_mention to personal_assistant", () => {
|
|
138
|
+
const tmp = useTmpDir();
|
|
139
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
140
|
+
write(tmp.path, "members/alice/NODE.md", `---\ntitle: Alice\nowners: [alice]\ntype: human\nrole: Eng\ndomains: [eng]\ndelegate_mention: alice-assistant\n---\n`);
|
|
141
|
+
write(tmp.path, "members/alice-assistant/NODE.md", VALID_ASSISTANT);
|
|
142
|
+
|
|
143
|
+
const result = runValidateMembers(tmp.path);
|
|
144
|
+
expect(result.exitCode).toBe(0);
|
|
145
|
+
expect(result.errors).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("rejects delegate_mention to non-existent member", () => {
|
|
149
|
+
const tmp = useTmpDir();
|
|
150
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
151
|
+
write(tmp.path, "members/alice/NODE.md", `---\ntitle: Alice\nowners: [alice]\ntype: human\nrole: Eng\ndomains: [eng]\ndelegate_mention: ghost\n---\n`);
|
|
152
|
+
|
|
153
|
+
const result = runValidateMembers(tmp.path);
|
|
154
|
+
expect(result.exitCode).toBe(1);
|
|
155
|
+
expect(result.errors.some((e) => e.includes("delegate_mention 'ghost'") && e.includes("non-existent"))).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects delegate_mention to non-personal_assistant member", () => {
|
|
159
|
+
const tmp = useTmpDir();
|
|
160
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
161
|
+
write(tmp.path, "members/alice/NODE.md", `---\ntitle: Alice\nowners: [alice]\ntype: human\nrole: Eng\ndomains: [eng]\ndelegate_mention: bob\n---\n`);
|
|
162
|
+
write(tmp.path, "members/bob/NODE.md", `---\ntitle: Bob\nowners: [bob]\ntype: human\nrole: Eng\ndomains: [eng]\n---\n`);
|
|
163
|
+
|
|
164
|
+
const result = runValidateMembers(tmp.path);
|
|
165
|
+
expect(result.exitCode).toBe(1);
|
|
166
|
+
expect(result.errors.some((e) => e.includes("delegate_mention 'bob'") && e.includes("personal_assistant"))).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("accepts delegate_mention to nested personal_assistant", () => {
|
|
170
|
+
const tmp = useTmpDir();
|
|
171
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
172
|
+
write(tmp.path, "members/alice/NODE.md", `---\ntitle: Alice\nowners: [alice]\ntype: human\nrole: Eng\ndomains: [eng]\ndelegate_mention: helper\n---\n`);
|
|
173
|
+
write(tmp.path, "members/bots/NODE.md", `---\ntitle: Bots\nowners: [admin]\ntype: autonomous_agent\nrole: Group\ndomains: [infra]\n---\n`);
|
|
174
|
+
write(tmp.path, "members/bots/helper/NODE.md", `---\ntitle: Helper\nowners: [admin]\ntype: personal_assistant\nrole: Assistant\ndomains: [eng]\n---\n`);
|
|
175
|
+
|
|
176
|
+
const result = runValidateMembers(tmp.path);
|
|
177
|
+
expect(result.exitCode).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("allows member without delegate_mention", () => {
|
|
181
|
+
const tmp = useTmpDir();
|
|
182
|
+
write(tmp.path, "members/NODE.md", "---\ntitle: Members\n---\n");
|
|
183
|
+
write(tmp.path, "members/alice/NODE.md", VALID_MEMBER);
|
|
184
|
+
|
|
185
|
+
const result = runValidateMembers(tmp.path);
|
|
186
|
+
expect(result.exitCode).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// --- extractScalar ---
|
|
191
|
+
|
|
192
|
+
describe("extractScalar", () => {
|
|
193
|
+
it("extracts regular value", () => {
|
|
194
|
+
expect(extractScalar("title: Hello World\nowners: [a]", "title")).toBe("Hello World");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("extracts quoted value", () => {
|
|
198
|
+
expect(extractScalar('title: "Hello World"\nowners: [a]', "title")).toBe("Hello World");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns null for missing key", () => {
|
|
202
|
+
expect(extractScalar("owners: [a]", "title")).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// --- extractList ---
|
|
207
|
+
|
|
208
|
+
describe("extractList", () => {
|
|
209
|
+
it("extracts inline list", () => {
|
|
210
|
+
expect(extractList("domains: [eng, product]", "domains")).toEqual(["eng", "product"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("extracts block list", () => {
|
|
214
|
+
expect(extractList("domains:\n - eng\n - product\n", "domains")).toEqual(["eng", "product"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("handles empty list", () => {
|
|
218
|
+
expect(extractList("domains: []", "domains")).toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns null for missing key", () => {
|
|
222
|
+
expect(extractList("owners: [a]", "domains")).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
});
|