autocrew 0.1.0

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 (165) hide show
  1. package/HAMLETDEER.md +562 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/README_CN.md +190 -0
  5. package/adapters/openclaw/index.ts +68 -0
  6. package/bin/autocrew.mjs +23 -0
  7. package/bin/autocrew.ts +13 -0
  8. package/openclaw.plugin.json +36 -0
  9. package/package.json +74 -0
  10. package/skills/_writing-style/SKILL.md +68 -0
  11. package/skills/audience-profiler/SKILL.md +241 -0
  12. package/skills/content-attribution/SKILL.md +128 -0
  13. package/skills/content-review/SKILL.md +257 -0
  14. package/skills/cover-generator/SKILL.md +93 -0
  15. package/skills/humanizer-zh/SKILL.md +75 -0
  16. package/skills/intel-digest/SKILL.md +57 -0
  17. package/skills/intel-pull/SKILL.md +74 -0
  18. package/skills/manage-pipeline/SKILL.md +63 -0
  19. package/skills/memory-distill/SKILL.md +89 -0
  20. package/skills/onboarding/SKILL.md +117 -0
  21. package/skills/pipeline-status/SKILL.md +51 -0
  22. package/skills/platform-rewrite/SKILL.md +125 -0
  23. package/skills/pre-publish/SKILL.md +142 -0
  24. package/skills/publish-content/SKILL.md +500 -0
  25. package/skills/remix-content/SKILL.md +77 -0
  26. package/skills/research/SKILL.md +127 -0
  27. package/skills/setup/SKILL.md +353 -0
  28. package/skills/spawn-batch-writer/SKILL.md +66 -0
  29. package/skills/spawn-planner/SKILL.md +72 -0
  30. package/skills/spawn-writer/SKILL.md +60 -0
  31. package/skills/teardown/SKILL.md +144 -0
  32. package/skills/title-craft/SKILL.md +234 -0
  33. package/skills/topic-ideas/SKILL.md +105 -0
  34. package/skills/video-timeline/SKILL.md +117 -0
  35. package/skills/write-script/SKILL.md +232 -0
  36. package/skills/xhs-cover-review/SKILL.md +48 -0
  37. package/src/adapters/browser/browser-cdp.ts +260 -0
  38. package/src/adapters/browser/browser-relay.ts +236 -0
  39. package/src/adapters/browser/gateway-client.ts +148 -0
  40. package/src/adapters/browser/types.ts +36 -0
  41. package/src/adapters/image/gemini.ts +219 -0
  42. package/src/adapters/research/tikhub.ts +19 -0
  43. package/src/cli/banner.ts +18 -0
  44. package/src/cli/bootstrap.ts +33 -0
  45. package/src/cli/commands/adapt.ts +28 -0
  46. package/src/cli/commands/advance.ts +28 -0
  47. package/src/cli/commands/assets.ts +24 -0
  48. package/src/cli/commands/audit.ts +18 -0
  49. package/src/cli/commands/contents.ts +18 -0
  50. package/src/cli/commands/cover.ts +58 -0
  51. package/src/cli/commands/events.ts +17 -0
  52. package/src/cli/commands/humanize.ts +27 -0
  53. package/src/cli/commands/index.ts +80 -0
  54. package/src/cli/commands/init.ts +28 -0
  55. package/src/cli/commands/intel.ts +55 -0
  56. package/src/cli/commands/learn.ts +34 -0
  57. package/src/cli/commands/memory.ts +18 -0
  58. package/src/cli/commands/migrate.ts +24 -0
  59. package/src/cli/commands/open.ts +21 -0
  60. package/src/cli/commands/pipelines.ts +18 -0
  61. package/src/cli/commands/pre-publish.ts +27 -0
  62. package/src/cli/commands/profile.ts +31 -0
  63. package/src/cli/commands/research.ts +36 -0
  64. package/src/cli/commands/restore.ts +28 -0
  65. package/src/cli/commands/review.ts +61 -0
  66. package/src/cli/commands/start.ts +28 -0
  67. package/src/cli/commands/status.ts +14 -0
  68. package/src/cli/commands/templates.ts +15 -0
  69. package/src/cli/commands/topics.ts +18 -0
  70. package/src/cli/commands/trash.ts +28 -0
  71. package/src/cli/commands/upgrade.ts +48 -0
  72. package/src/cli/commands/versions.ts +24 -0
  73. package/src/cli/index.ts +40 -0
  74. package/src/data/sensitive-words-builtin.json +114 -0
  75. package/src/data/source-presets.yaml +54 -0
  76. package/src/e2e.test.ts +596 -0
  77. package/src/modules/auth/cookie-manager.ts +113 -0
  78. package/src/modules/cards/template-engine.ts +74 -0
  79. package/src/modules/cards/templates/comparison-table.ts +71 -0
  80. package/src/modules/cards/templates/data-chart.ts +76 -0
  81. package/src/modules/cards/templates/flow-chart.ts +49 -0
  82. package/src/modules/cards/templates/key-points.ts +59 -0
  83. package/src/modules/cover/prompt-builder.test.ts +157 -0
  84. package/src/modules/cover/prompt-builder.ts +212 -0
  85. package/src/modules/cover/ratio-adapter.test.ts +122 -0
  86. package/src/modules/cover/ratio-adapter.ts +104 -0
  87. package/src/modules/filter/sensitive-words.test.ts +72 -0
  88. package/src/modules/filter/sensitive-words.ts +212 -0
  89. package/src/modules/humanizer/zh.test.ts +75 -0
  90. package/src/modules/humanizer/zh.ts +175 -0
  91. package/src/modules/intel/collector.ts +19 -0
  92. package/src/modules/intel/collectors/competitor.test.ts +71 -0
  93. package/src/modules/intel/collectors/competitor.ts +65 -0
  94. package/src/modules/intel/collectors/rss.test.ts +56 -0
  95. package/src/modules/intel/collectors/rss.ts +70 -0
  96. package/src/modules/intel/collectors/trends.test.ts +80 -0
  97. package/src/modules/intel/collectors/trends.ts +107 -0
  98. package/src/modules/intel/collectors/web-search.test.ts +85 -0
  99. package/src/modules/intel/collectors/web-search.ts +81 -0
  100. package/src/modules/intel/integration.test.ts +203 -0
  101. package/src/modules/intel/intel-engine.test.ts +103 -0
  102. package/src/modules/intel/intel-engine.ts +96 -0
  103. package/src/modules/intel/source-config.test.ts +113 -0
  104. package/src/modules/intel/source-config.ts +131 -0
  105. package/src/modules/learnings/diff-tracker.test.ts +144 -0
  106. package/src/modules/learnings/diff-tracker.ts +189 -0
  107. package/src/modules/learnings/rule-distiller.ts +141 -0
  108. package/src/modules/memory/distill.ts +208 -0
  109. package/src/modules/migrate/legacy-migrate.test.ts +169 -0
  110. package/src/modules/migrate/legacy-migrate.ts +229 -0
  111. package/src/modules/pro/api-client.ts +192 -0
  112. package/src/modules/pro/gate.test.ts +110 -0
  113. package/src/modules/pro/gate.ts +104 -0
  114. package/src/modules/profile/creator-profile.test.ts +178 -0
  115. package/src/modules/profile/creator-profile.ts +248 -0
  116. package/src/modules/publish/douyin-api.ts +34 -0
  117. package/src/modules/publish/wechat-mp.ts +320 -0
  118. package/src/modules/publish/xiaohongshu-api.ts +127 -0
  119. package/src/modules/research/free-engine.ts +360 -0
  120. package/src/modules/timeline/markup-generator.ts +63 -0
  121. package/src/modules/timeline/parser.ts +275 -0
  122. package/src/modules/workflow/templates.ts +124 -0
  123. package/src/modules/writing/platform-rewrite.ts +190 -0
  124. package/src/modules/writing/title-hashtag.ts +385 -0
  125. package/src/runtime/context.test.ts +97 -0
  126. package/src/runtime/context.ts +129 -0
  127. package/src/runtime/events.test.ts +83 -0
  128. package/src/runtime/events.ts +104 -0
  129. package/src/runtime/hooks.ts +174 -0
  130. package/src/runtime/tool-runner.test.ts +204 -0
  131. package/src/runtime/tool-runner.ts +282 -0
  132. package/src/runtime/workflow-engine.test.ts +455 -0
  133. package/src/runtime/workflow-engine.ts +391 -0
  134. package/src/server/index.ts +409 -0
  135. package/src/server/start.ts +39 -0
  136. package/src/storage/local-store.test.ts +304 -0
  137. package/src/storage/local-store.ts +704 -0
  138. package/src/storage/pipeline-store.test.ts +363 -0
  139. package/src/storage/pipeline-store.ts +698 -0
  140. package/src/tools/asset.ts +96 -0
  141. package/src/tools/content-save.ts +276 -0
  142. package/src/tools/cover-review.ts +221 -0
  143. package/src/tools/humanize.ts +54 -0
  144. package/src/tools/init.ts +133 -0
  145. package/src/tools/intel.ts +92 -0
  146. package/src/tools/memory.ts +76 -0
  147. package/src/tools/pipeline-ops.ts +109 -0
  148. package/src/tools/pipeline.ts +168 -0
  149. package/src/tools/pre-publish.ts +232 -0
  150. package/src/tools/publish.ts +183 -0
  151. package/src/tools/registry.ts +198 -0
  152. package/src/tools/research.ts +304 -0
  153. package/src/tools/review.ts +305 -0
  154. package/src/tools/rewrite.ts +165 -0
  155. package/src/tools/status.ts +30 -0
  156. package/src/tools/timeline.ts +234 -0
  157. package/src/tools/topic-create.ts +50 -0
  158. package/src/types/providers.ts +69 -0
  159. package/src/types/timeline.test.ts +147 -0
  160. package/src/types/timeline.ts +83 -0
  161. package/src/utils/retry.test.ts +97 -0
  162. package/src/utils/retry.ts +85 -0
  163. package/templates/AGENTS.md +99 -0
  164. package/templates/SOUL.md +31 -0
  165. package/templates/TOOLS.md +76 -0
@@ -0,0 +1,363 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ PIPELINE_STAGES,
7
+ initPipeline,
8
+ pipelinePath,
9
+ stagePath,
10
+ slugify,
11
+ saveIntel,
12
+ listIntel,
13
+ archiveExpiredIntel,
14
+ intelToMarkdown,
15
+ parseIntelFile,
16
+ saveTopic,
17
+ listTopics,
18
+ decayTopicScores,
19
+ topicToMarkdown,
20
+ parseTopicFile,
21
+ startProject,
22
+ advanceProject,
23
+ addDraftVersion,
24
+ trashProject,
25
+ restoreProject,
26
+ getProjectMeta,
27
+ listProjects,
28
+ type IntelItem,
29
+ type TopicCandidate,
30
+ } from "../storage/pipeline-store.js";
31
+
32
+ let testDir: string;
33
+
34
+ beforeEach(async () => {
35
+ testDir = await fs.mkdtemp(
36
+ path.join(os.tmpdir(), "autocrew-pipeline-test-"),
37
+ );
38
+ });
39
+
40
+ afterEach(async () => {
41
+ await fs.rm(testDir, { recursive: true, force: true });
42
+ });
43
+
44
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
45
+
46
+ function makeIntel(overrides: Partial<IntelItem> = {}): IntelItem {
47
+ return {
48
+ title: "AI内容创作新趋势",
49
+ domain: "ai-content",
50
+ source: "web_search",
51
+ collectedAt: new Date().toISOString(),
52
+ relevance: 85,
53
+ tags: ["AI", "内容创作"],
54
+ expiresAfter: 7,
55
+ summary: "AI正在改变内容创作流程",
56
+ keyPoints: ["效率提升3倍", "质量可控"],
57
+ topicPotential: "可做系列教程选题",
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ function makeTopic(overrides: Partial<TopicCandidate> = {}): TopicCandidate {
63
+ return {
64
+ title: "AI写作工具评测",
65
+ domain: "ai-content",
66
+ score: { heat: 80, differentiation: 70, audienceFit: 90, overall: 80 },
67
+ formats: ["video", "article"],
68
+ suggestedPlatforms: ["小红书", "B站"],
69
+ createdAt: new Date().toISOString(),
70
+ intelRefs: ["2024-01-15-ai-content-trend.md"],
71
+ angles: ["横向对比", "实操演示"],
72
+ audienceResonance: "目标用户对AI工具有强烈兴趣",
73
+ references: ["https://example.com/ai-tools"],
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ // ─── Task 2: Pipeline Init ──────────────────────────────────────────────────
79
+
80
+ describe("Pipeline Init", () => {
81
+ it("pipelinePath returns correct path", () => {
82
+ expect(pipelinePath(testDir)).toBe(path.join(testDir, "pipeline"));
83
+ });
84
+
85
+ it("stagePath returns correct path", () => {
86
+ expect(stagePath("intel", testDir)).toBe(
87
+ path.join(testDir, "pipeline", "intel"),
88
+ );
89
+ });
90
+
91
+ it("initPipeline creates all stage directories", async () => {
92
+ await initPipeline(testDir);
93
+ for (const stage of PIPELINE_STAGES) {
94
+ const stat = await fs.stat(stagePath(stage, testDir));
95
+ expect(stat.isDirectory()).toBe(true);
96
+ }
97
+ });
98
+
99
+ it("initPipeline creates intel subdirectories", async () => {
100
+ await initPipeline(testDir);
101
+ const intelDir = stagePath("intel", testDir);
102
+ const sources = await fs.stat(path.join(intelDir, "_sources"));
103
+ const archive = await fs.stat(path.join(intelDir, "_archive"));
104
+ expect(sources.isDirectory()).toBe(true);
105
+ expect(archive.isDirectory()).toBe(true);
106
+ });
107
+
108
+ it("initPipeline is idempotent", async () => {
109
+ await initPipeline(testDir);
110
+ await initPipeline(testDir);
111
+ for (const stage of PIPELINE_STAGES) {
112
+ const stat = await fs.stat(stagePath(stage, testDir));
113
+ expect(stat.isDirectory()).toBe(true);
114
+ }
115
+ });
116
+ });
117
+
118
+ // ─── Slugify ─────────────────────────────────────────────────────────────────
119
+
120
+ describe("slugify", () => {
121
+ it("handles English text", () => {
122
+ expect(slugify("Hello World!")).toBe("hello-world");
123
+ });
124
+
125
+ it("handles Chinese text", () => {
126
+ expect(slugify("AI内容创作趋势")).toBe("ai内容创作趋势");
127
+ });
128
+
129
+ it("truncates to 60 chars", () => {
130
+ const long = "a".repeat(100);
131
+ expect(slugify(long).length).toBeLessThanOrEqual(60);
132
+ });
133
+ });
134
+
135
+ // ─── Task 3: Intel Storage ──────────────────────────────────────────────────
136
+
137
+ describe("Intel Storage", () => {
138
+ it("saves intel as markdown with correct frontmatter", async () => {
139
+ const item = makeIntel();
140
+ const filePath = await saveIntel(item, testDir);
141
+ expect(filePath).toContain("ai-content");
142
+ expect(filePath.endsWith(".md")).toBe(true);
143
+
144
+ const content = await fs.readFile(filePath, "utf-8");
145
+ const parsed = parseIntelFile(content);
146
+ expect(parsed.title).toBe(item.title);
147
+ expect(parsed.domain).toBe(item.domain);
148
+ expect(parsed.source).toBe(item.source);
149
+ expect(parsed.relevance).toBe(item.relevance);
150
+ expect(parsed.tags).toEqual(item.tags);
151
+ expect(parsed.keyPoints).toEqual(item.keyPoints);
152
+ expect(parsed.summary).toBe(item.summary);
153
+ });
154
+
155
+ it("lists intel by domain", async () => {
156
+ await saveIntel(makeIntel({ domain: "ai-content" }), testDir);
157
+ await saveIntel(
158
+ makeIntel({ title: "电商新玩法", domain: "ecommerce" }),
159
+ testDir,
160
+ );
161
+
162
+ const all = await listIntel(undefined, testDir);
163
+ expect(all.length).toBe(2);
164
+
165
+ const aiOnly = await listIntel("ai-content", testDir);
166
+ expect(aiOnly.length).toBe(1);
167
+ expect(aiOnly[0].domain).toBe("ai-content");
168
+ });
169
+
170
+ it("deduplicates by title slug", async () => {
171
+ const item = makeIntel();
172
+ await saveIntel(item, testDir);
173
+ await saveIntel({ ...item, relevance: 99 }, testDir);
174
+
175
+ const items = await listIntel(undefined, testDir);
176
+ expect(items.length).toBe(1);
177
+ expect(items[0].relevance).toBe(99);
178
+ });
179
+
180
+ it("archives expired intel", async () => {
181
+ const expired = makeIntel({
182
+ collectedAt: new Date(Date.now() - 30 * 86400000).toISOString(),
183
+ expiresAfter: 7,
184
+ });
185
+ const fresh = makeIntel({
186
+ title: "新鲜资讯",
187
+ collectedAt: new Date().toISOString(),
188
+ expiresAfter: 7,
189
+ });
190
+
191
+ await saveIntel(expired, testDir);
192
+ await saveIntel(fresh, testDir);
193
+
194
+ const result = await archiveExpiredIntel(testDir);
195
+ expect(result.archived).toBe(1);
196
+
197
+ const remaining = await listIntel(undefined, testDir);
198
+ expect(remaining.length).toBe(1);
199
+ expect(remaining[0].title).toBe("新鲜资讯");
200
+ });
201
+
202
+ it("roundtrips intel through markdown", () => {
203
+ const item = makeIntel();
204
+ const md = intelToMarkdown(item);
205
+ const parsed = parseIntelFile(md);
206
+ expect(parsed.title).toBe(item.title);
207
+ expect(parsed.keyPoints).toEqual(item.keyPoints);
208
+ expect(parsed.topicPotential).toBe(item.topicPotential);
209
+ });
210
+ });
211
+
212
+ // ─── Task 4: Topic Pool ─────────────────────────────────────────────────────
213
+
214
+ describe("Topic Pool", () => {
215
+ it("saves topic with frontmatter scores", async () => {
216
+ const topic = makeTopic();
217
+ const filePath = await saveTopic(topic, testDir);
218
+ expect(filePath.endsWith(".md")).toBe(true);
219
+
220
+ const content = await fs.readFile(filePath, "utf-8");
221
+ const parsed = parseTopicFile(content);
222
+ expect(parsed.title).toBe(topic.title);
223
+ expect(parsed.score.overall).toBe(80);
224
+ expect(parsed.score.heat).toBe(80);
225
+ expect(parsed.angles).toEqual(topic.angles);
226
+ });
227
+
228
+ it("lists topics sorted by overall score desc", async () => {
229
+ await saveTopic(
230
+ makeTopic({ title: "低分选题", score: { heat: 30, differentiation: 30, audienceFit: 30, overall: 30 } }),
231
+ testDir,
232
+ );
233
+ await saveTopic(
234
+ makeTopic({ title: "高分选题", score: { heat: 90, differentiation: 90, audienceFit: 90, overall: 90 } }),
235
+ testDir,
236
+ );
237
+
238
+ const topics = await listTopics(undefined, testDir);
239
+ expect(topics.length).toBe(2);
240
+ expect(topics[0].title).toBe("高分选题");
241
+ expect(topics[1].title).toBe("低分选题");
242
+ });
243
+
244
+ it("decays and trashes old low-score topics", async () => {
245
+ // 20 days old, score 30 → decay = (20-14)*2 = 12 → 18 → trash
246
+ const old = makeTopic({
247
+ title: "旧选题",
248
+ createdAt: new Date(Date.now() - 20 * 86400000).toISOString(),
249
+ score: { heat: 30, differentiation: 30, audienceFit: 30, overall: 30 },
250
+ });
251
+ // Fresh topic, should not decay
252
+ const fresh = makeTopic({
253
+ title: "新选题",
254
+ createdAt: new Date().toISOString(),
255
+ score: { heat: 80, differentiation: 80, audienceFit: 80, overall: 80 },
256
+ });
257
+
258
+ await saveTopic(old, testDir);
259
+ await saveTopic(fresh, testDir);
260
+
261
+ const result = await decayTopicScores(testDir);
262
+ expect(result.decayed).toBe(1);
263
+ expect(result.trashed).toBe(1);
264
+
265
+ const remaining = await listTopics(undefined, testDir);
266
+ expect(remaining.length).toBe(1);
267
+ expect(remaining[0].title).toBe("新选题");
268
+ });
269
+
270
+ it("roundtrips topic through markdown", () => {
271
+ const topic = makeTopic();
272
+ const md = topicToMarkdown(topic);
273
+ const parsed = parseTopicFile(md);
274
+ expect(parsed.title).toBe(topic.title);
275
+ expect(parsed.score).toEqual(topic.score);
276
+ expect(parsed.audienceResonance).toBe(topic.audienceResonance);
277
+ });
278
+ });
279
+
280
+ // ─── Task 5: Project Lifecycle ──────────────────────────────────────────────
281
+
282
+ describe("Project Lifecycle", () => {
283
+ it("starts project from topic", async () => {
284
+ const topic = makeTopic({ title: "测试项目选题" });
285
+ await saveTopic(topic, testDir);
286
+
287
+ const projectDir = await startProject("测试项目选题", testDir);
288
+ expect(projectDir).toContain("drafting");
289
+
290
+ // Topic file should be removed
291
+ const topics = await listTopics(undefined, testDir);
292
+ expect(topics.length).toBe(0);
293
+
294
+ // Project dir should contain meta.yaml, draft-v1.md, draft.md, references/
295
+ const files = await fs.readdir(projectDir);
296
+ expect(files).toContain("meta.yaml");
297
+ expect(files).toContain("draft-v1.md");
298
+ expect(files).toContain("draft.md");
299
+ expect(files).toContain("references");
300
+ });
301
+
302
+ it("advances project from drafting to production", async () => {
303
+ await saveTopic(makeTopic({ title: "推进测试" }), testDir);
304
+ await startProject("推进测试", testDir);
305
+
306
+ const projectName = slugify("推进测试");
307
+ const newDir = await advanceProject(projectName, testDir);
308
+ expect(newDir).toContain("production");
309
+
310
+ const meta = await getProjectMeta(projectName, testDir);
311
+ expect(meta).not.toBeNull();
312
+ expect(meta!.history.length).toBe(2);
313
+ expect(meta!.history[1].stage).toBe("production");
314
+ });
315
+
316
+ it("adds draft versions", async () => {
317
+ await saveTopic(makeTopic({ title: "版本测试" }), testDir);
318
+ await startProject("版本测试", testDir);
319
+
320
+ const projectName = slugify("版本测试");
321
+ await addDraftVersion(projectName, "# V2 内容", "second draft", testDir);
322
+
323
+ const meta = await getProjectMeta(projectName, testDir);
324
+ expect(meta!.versions.length).toBe(2);
325
+ expect(meta!.current).toBe("draft-v2.md");
326
+
327
+ const found = await import("node:fs/promises").then((f) =>
328
+ f.readFile(
329
+ path.join(stagePath("drafting", testDir), projectName, "draft.md"),
330
+ "utf-8",
331
+ ),
332
+ );
333
+ expect(found).toBe("# V2 内容");
334
+ });
335
+
336
+ it("trashes and restores project", async () => {
337
+ await saveTopic(makeTopic({ title: "回收测试" }), testDir);
338
+ await startProject("回收测试", testDir);
339
+
340
+ const projectName = slugify("回收测试");
341
+ await trashProject(projectName, testDir);
342
+
343
+ // Should be in trash
344
+ const trashProjects = await listProjects("trash", testDir);
345
+ expect(trashProjects).toContain(projectName);
346
+
347
+ // Restore
348
+ const restoredDir = await restoreProject(projectName, testDir);
349
+ expect(restoredDir).toContain("drafting");
350
+
351
+ const meta = await getProjectMeta(projectName, testDir);
352
+ expect(meta!.history.filter((h) => h.stage === "trash").length).toBe(1);
353
+ expect(meta!.history.at(-1)!.stage).toBe("drafting");
354
+ });
355
+
356
+ it("listProjects returns project names in stage", async () => {
357
+ await saveTopic(makeTopic({ title: "项目A" }), testDir);
358
+ await startProject("项目a", testDir);
359
+
360
+ const projects = await listProjects("drafting", testDir);
361
+ expect(projects.length).toBeGreaterThan(0);
362
+ });
363
+ });