@os-eco/seeds-cli 0.2.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.
- package/README.md +226 -0
- package/package.json +42 -0
- package/src/commands/blocked.ts +31 -0
- package/src/commands/close.ts +80 -0
- package/src/commands/create.test.ts +507 -0
- package/src/commands/create.ts +103 -0
- package/src/commands/dep.test.ts +215 -0
- package/src/commands/dep.ts +108 -0
- package/src/commands/doctor.test.ts +524 -0
- package/src/commands/doctor.ts +732 -0
- package/src/commands/init.test.ts +86 -0
- package/src/commands/init.ts +49 -0
- package/src/commands/list.ts +69 -0
- package/src/commands/migrate.ts +117 -0
- package/src/commands/onboard.test.ts +155 -0
- package/src/commands/onboard.ts +140 -0
- package/src/commands/prime.test.ts +94 -0
- package/src/commands/prime.ts +146 -0
- package/src/commands/ready.ts +31 -0
- package/src/commands/show.ts +20 -0
- package/src/commands/stats.ts +58 -0
- package/src/commands/sync.ts +76 -0
- package/src/commands/tpl.test.ts +330 -0
- package/src/commands/tpl.ts +279 -0
- package/src/commands/update.ts +97 -0
- package/src/config.ts +39 -0
- package/src/id.test.ts +55 -0
- package/src/id.ts +22 -0
- package/src/index.ts +122 -0
- package/src/markers.test.ts +73 -0
- package/src/markers.ts +19 -0
- package/src/output.ts +63 -0
- package/src/store.test.ts +173 -0
- package/src/store.ts +143 -0
- package/src/types.ts +61 -0
- package/src/yaml.test.ts +79 -0
- package/src/yaml.ts +27 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
let tmpDir: string;
|
|
7
|
+
|
|
8
|
+
// Path to the CLI entry point (relative to repo root)
|
|
9
|
+
const CLI = join(import.meta.dir, "../../src/index.ts");
|
|
10
|
+
|
|
11
|
+
async function run(
|
|
12
|
+
args: string[],
|
|
13
|
+
cwd: string,
|
|
14
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
15
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
16
|
+
cwd,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
});
|
|
20
|
+
const stdout = await new Response(proc.stdout).text();
|
|
21
|
+
const stderr = await new Response(proc.stderr).text();
|
|
22
|
+
const exitCode = await proc.exited;
|
|
23
|
+
return { stdout, stderr, exitCode };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
tmpDir = await mkdtemp(join(tmpdir(), "seeds-init-test-"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("sd init", () => {
|
|
35
|
+
test("creates .seeds directory", async () => {
|
|
36
|
+
const { exitCode } = await run(["init"], tmpDir);
|
|
37
|
+
expect(exitCode).toBe(0);
|
|
38
|
+
const stat = await Bun.file(join(tmpDir, ".seeds", "config.yaml")).exists();
|
|
39
|
+
expect(stat).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("creates config.yaml with project name", async () => {
|
|
43
|
+
await run(["init"], tmpDir);
|
|
44
|
+
const config = await Bun.file(join(tmpDir, ".seeds", "config.yaml")).text();
|
|
45
|
+
expect(config).toContain("project:");
|
|
46
|
+
expect(config).toContain("version:");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("creates empty issues.jsonl", async () => {
|
|
50
|
+
await run(["init"], tmpDir);
|
|
51
|
+
const exists = await Bun.file(join(tmpDir, ".seeds", "issues.jsonl")).exists();
|
|
52
|
+
expect(exists).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("creates empty templates.jsonl", async () => {
|
|
56
|
+
await run(["init"], tmpDir);
|
|
57
|
+
const exists = await Bun.file(join(tmpDir, ".seeds", "templates.jsonl")).exists();
|
|
58
|
+
expect(exists).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("creates .gitignore ignoring lock files", async () => {
|
|
62
|
+
await run(["init"], tmpDir);
|
|
63
|
+
const gitignore = await Bun.file(join(tmpDir, ".seeds", ".gitignore")).text();
|
|
64
|
+
expect(gitignore).toContain("*.lock");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("appends gitattributes to project root", async () => {
|
|
68
|
+
await run(["init"], tmpDir);
|
|
69
|
+
const gitattributes = await Bun.file(join(tmpDir, ".gitattributes")).text();
|
|
70
|
+
expect(gitattributes).toContain(".seeds/issues.jsonl merge=union");
|
|
71
|
+
expect(gitattributes).toContain(".seeds/templates.jsonl merge=union");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("is idempotent — second init does not fail", async () => {
|
|
75
|
+
await run(["init"], tmpDir);
|
|
76
|
+
const { exitCode } = await run(["init"], tmpDir);
|
|
77
|
+
expect(exitCode).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("--json flag returns success JSON", async () => {
|
|
81
|
+
const { stdout, exitCode } = await run(["init", "--json"], tmpDir);
|
|
82
|
+
expect(exitCode).toBe(0);
|
|
83
|
+
const result = JSON.parse(stdout) as { success: boolean };
|
|
84
|
+
expect(result.success).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { outputJson, printSuccess } from "../output.ts";
|
|
4
|
+
import { CONFIG_FILE, ISSUES_FILE, SEEDS_DIR_NAME, TEMPLATES_FILE } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export async function run(args: string[]): Promise<void> {
|
|
7
|
+
const jsonMode = args.includes("--json");
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const seedsDir = join(cwd, SEEDS_DIR_NAME);
|
|
10
|
+
|
|
11
|
+
if (existsSync(join(seedsDir, CONFIG_FILE))) {
|
|
12
|
+
if (jsonMode) {
|
|
13
|
+
outputJson({ success: true, command: "init", dir: seedsDir });
|
|
14
|
+
} else {
|
|
15
|
+
printSuccess(`Already initialized: ${seedsDir}`);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
mkdirSync(seedsDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
// config.yaml
|
|
23
|
+
writeFileSync(join(seedsDir, CONFIG_FILE), 'project: "seeds"\nversion: "1"\n');
|
|
24
|
+
|
|
25
|
+
// empty JSONL files
|
|
26
|
+
writeFileSync(join(seedsDir, ISSUES_FILE), "");
|
|
27
|
+
writeFileSync(join(seedsDir, TEMPLATES_FILE), "");
|
|
28
|
+
|
|
29
|
+
// .gitignore inside .seeds/
|
|
30
|
+
writeFileSync(join(seedsDir, ".gitignore"), "*.lock\n");
|
|
31
|
+
|
|
32
|
+
// Append .gitattributes to project root
|
|
33
|
+
const gitattrsPath = join(cwd, ".gitattributes");
|
|
34
|
+
const entry = ".seeds/issues.jsonl merge=union\n.seeds/templates.jsonl merge=union\n";
|
|
35
|
+
if (existsSync(gitattrsPath)) {
|
|
36
|
+
const existing = readFileSync(gitattrsPath, "utf8");
|
|
37
|
+
if (!existing.includes(".seeds/issues.jsonl")) {
|
|
38
|
+
writeFileSync(gitattrsPath, `${existing}\n${entry}`);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
writeFileSync(gitattrsPath, entry);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (jsonMode) {
|
|
45
|
+
outputJson({ success: true, command: "init", dir: seedsDir });
|
|
46
|
+
} else {
|
|
47
|
+
printSuccess(`Initialized .seeds/ in ${cwd}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { findSeedsDir } from "../config.ts";
|
|
2
|
+
import { outputJson, printIssueOneLine } from "../output.ts";
|
|
3
|
+
import { readIssues } from "../store.ts";
|
|
4
|
+
import type { Issue } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
function parseArgs(args: string[]) {
|
|
7
|
+
const flags: Record<string, string | boolean> = {};
|
|
8
|
+
let i = 0;
|
|
9
|
+
while (i < args.length) {
|
|
10
|
+
const arg = args[i];
|
|
11
|
+
if (!arg) {
|
|
12
|
+
i++;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (arg.startsWith("--")) {
|
|
16
|
+
const key = arg.slice(2);
|
|
17
|
+
const eqIdx = key.indexOf("=");
|
|
18
|
+
if (eqIdx !== -1) {
|
|
19
|
+
flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
|
|
20
|
+
i++;
|
|
21
|
+
} else {
|
|
22
|
+
const next = args[i + 1];
|
|
23
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
24
|
+
flags[key] = next;
|
|
25
|
+
i += 2;
|
|
26
|
+
} else {
|
|
27
|
+
flags[key] = true;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return flags;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function run(args: string[], seedsDir?: string): Promise<void> {
|
|
39
|
+
const jsonMode = args.includes("--json");
|
|
40
|
+
const flags = parseArgs(args);
|
|
41
|
+
|
|
42
|
+
const statusFilter = typeof flags.status === "string" ? flags.status : undefined;
|
|
43
|
+
const typeFilter = typeof flags.type === "string" ? flags.type : undefined;
|
|
44
|
+
const assigneeFilter = typeof flags.assignee === "string" ? flags.assignee : undefined;
|
|
45
|
+
const limitStr = typeof flags.limit === "string" ? flags.limit : "50";
|
|
46
|
+
const limit = Number.parseInt(limitStr, 10) || 50;
|
|
47
|
+
|
|
48
|
+
const dir = seedsDir ?? (await findSeedsDir());
|
|
49
|
+
let issues = await readIssues(dir);
|
|
50
|
+
|
|
51
|
+
if (statusFilter) issues = issues.filter((i: Issue) => i.status === statusFilter);
|
|
52
|
+
if (typeFilter) issues = issues.filter((i: Issue) => i.type === typeFilter);
|
|
53
|
+
if (assigneeFilter) issues = issues.filter((i: Issue) => i.assignee === assigneeFilter);
|
|
54
|
+
|
|
55
|
+
issues = issues.slice(0, limit);
|
|
56
|
+
|
|
57
|
+
if (jsonMode) {
|
|
58
|
+
outputJson({ success: true, command: "list", issues, count: issues.length });
|
|
59
|
+
} else {
|
|
60
|
+
if (issues.length === 0) {
|
|
61
|
+
console.log("No issues found.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const issue of issues) {
|
|
65
|
+
printIssueOneLine(issue);
|
|
66
|
+
}
|
|
67
|
+
console.log(`\n${issues.length} issue(s)`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { findSeedsDir, projectRootFromSeedsDir } from "../config.ts";
|
|
4
|
+
import { outputJson } from "../output.ts";
|
|
5
|
+
import { issuesPath, readIssues, withLock, writeIssues } from "../store.ts";
|
|
6
|
+
import type { Issue } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
interface BeadsIssue {
|
|
9
|
+
id?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
status?: string;
|
|
12
|
+
issue_type?: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
priority?: number;
|
|
15
|
+
owner?: string;
|
|
16
|
+
assignee?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
close_reason?: string;
|
|
19
|
+
closeReason?: string;
|
|
20
|
+
blocks?: string[];
|
|
21
|
+
blocked_by?: string[];
|
|
22
|
+
blockedBy?: string[];
|
|
23
|
+
created_at?: string;
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
updated_at?: string;
|
|
26
|
+
updatedAt?: string;
|
|
27
|
+
closed_at?: string;
|
|
28
|
+
closedAt?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mapStatus(s: string | undefined): Issue["status"] {
|
|
32
|
+
if (s === "in_progress" || s === "in-progress") return "in_progress";
|
|
33
|
+
if (s === "closed" || s === "done" || s === "complete") return "closed";
|
|
34
|
+
return "open";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mapType(t: string | undefined): Issue["type"] {
|
|
38
|
+
if (t === "bug") return "bug";
|
|
39
|
+
if (t === "feature") return "feature";
|
|
40
|
+
if (t === "epic") return "epic";
|
|
41
|
+
return "task";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mapBeadsIssue(b: BeadsIssue): Issue | null {
|
|
45
|
+
if (!b.id || !b.title) return null;
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
const issue: Issue = {
|
|
48
|
+
id: b.id,
|
|
49
|
+
title: b.title,
|
|
50
|
+
status: mapStatus(b.status),
|
|
51
|
+
type: mapType(b.issue_type ?? b.type),
|
|
52
|
+
priority: b.priority ?? 2,
|
|
53
|
+
createdAt: b.created_at ?? b.createdAt ?? now,
|
|
54
|
+
updatedAt: b.updated_at ?? b.updatedAt ?? now,
|
|
55
|
+
};
|
|
56
|
+
const assignee = b.owner ?? b.assignee;
|
|
57
|
+
if (assignee) issue.assignee = assignee;
|
|
58
|
+
if (b.description) issue.description = b.description;
|
|
59
|
+
const closeReason = b.close_reason ?? b.closeReason;
|
|
60
|
+
if (closeReason) issue.closeReason = closeReason;
|
|
61
|
+
const blockedBy = b.blocked_by ?? b.blockedBy;
|
|
62
|
+
if (blockedBy?.length) issue.blockedBy = blockedBy;
|
|
63
|
+
if (b.blocks?.length) issue.blocks = b.blocks;
|
|
64
|
+
const closedAt = b.closed_at ?? b.closedAt;
|
|
65
|
+
if (closedAt) issue.closedAt = closedAt;
|
|
66
|
+
return issue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function run(args: string[], seedsDir?: string): Promise<void> {
|
|
70
|
+
const jsonMode = args.includes("--json");
|
|
71
|
+
const dir = seedsDir ?? (await findSeedsDir());
|
|
72
|
+
const projectRoot = projectRootFromSeedsDir(dir);
|
|
73
|
+
|
|
74
|
+
const beadsPath = join(projectRoot, ".beads", "issues.jsonl");
|
|
75
|
+
if (!existsSync(beadsPath)) {
|
|
76
|
+
throw new Error(`Beads issues not found at: ${beadsPath}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const file = Bun.file(beadsPath);
|
|
80
|
+
const content = await file.text();
|
|
81
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
82
|
+
|
|
83
|
+
const beadsIssues: BeadsIssue[] = [];
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
try {
|
|
86
|
+
beadsIssues.push(JSON.parse(line) as BeadsIssue);
|
|
87
|
+
} catch {
|
|
88
|
+
// skip malformed lines
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const mapped: Issue[] = [];
|
|
93
|
+
const skipped: string[] = [];
|
|
94
|
+
for (const b of beadsIssues) {
|
|
95
|
+
const issue = mapBeadsIssue(b);
|
|
96
|
+
if (issue) mapped.push(issue);
|
|
97
|
+
else skipped.push(b.id ?? "(unknown)");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let written = 0;
|
|
101
|
+
await withLock(issuesPath(dir), async () => {
|
|
102
|
+
const existing = await readIssues(dir);
|
|
103
|
+
const existingIds = new Set(existing.map((i) => i.id));
|
|
104
|
+
const newIssues = mapped.filter((i) => !existingIds.has(i.id));
|
|
105
|
+
await writeIssues(dir, [...existing, ...newIssues]);
|
|
106
|
+
written = newIssues.length;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (jsonMode) {
|
|
110
|
+
outputJson({ success: true, command: "migrate-from-beads", written, skipped: skipped.length });
|
|
111
|
+
} else {
|
|
112
|
+
console.log(`Migrated ${written} issues from beads.`);
|
|
113
|
+
if (skipped.length > 0) {
|
|
114
|
+
console.log(`Skipped ${skipped.length} malformed issues.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
let tmpDir: string;
|
|
7
|
+
|
|
8
|
+
const CLI = join(import.meta.dir, "../../src/index.ts");
|
|
9
|
+
|
|
10
|
+
async function run(
|
|
11
|
+
args: string[],
|
|
12
|
+
cwd: string,
|
|
13
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
14
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
15
|
+
cwd,
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
});
|
|
19
|
+
const stdout = await new Response(proc.stdout).text();
|
|
20
|
+
const stderr = await new Response(proc.stderr).text();
|
|
21
|
+
const exitCode = await proc.exited;
|
|
22
|
+
return { stdout, stderr, exitCode };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function initSeeds(cwd: string): Promise<void> {
|
|
26
|
+
await run(["init"], cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
tmpDir = await mkdtemp(join(tmpdir(), "seeds-onboard-test-"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("sd onboard", () => {
|
|
38
|
+
test("fails without .seeds/ initialized", async () => {
|
|
39
|
+
const { exitCode, stderr } = await run(["onboard"], tmpDir);
|
|
40
|
+
expect(exitCode).toBe(1);
|
|
41
|
+
expect(stderr).toContain("Not in a seeds project");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("creates CLAUDE.md when no target file exists", async () => {
|
|
45
|
+
await initSeeds(tmpDir);
|
|
46
|
+
const { exitCode } = await run(["onboard"], tmpDir);
|
|
47
|
+
expect(exitCode).toBe(0);
|
|
48
|
+
const content = await Bun.file(join(tmpDir, "CLAUDE.md")).text();
|
|
49
|
+
expect(content).toContain("<!-- seeds:start -->");
|
|
50
|
+
expect(content).toContain("<!-- seeds:end -->");
|
|
51
|
+
expect(content).toContain("Issue Tracking (Seeds)");
|
|
52
|
+
expect(content).toContain("sd prime");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("appends to existing CLAUDE.md", async () => {
|
|
56
|
+
await initSeeds(tmpDir);
|
|
57
|
+
await Bun.write(join(tmpDir, "CLAUDE.md"), "# My Project\n\nExisting content.\n");
|
|
58
|
+
const { exitCode } = await run(["onboard"], tmpDir);
|
|
59
|
+
expect(exitCode).toBe(0);
|
|
60
|
+
const content = await Bun.file(join(tmpDir, "CLAUDE.md")).text();
|
|
61
|
+
expect(content).toContain("# My Project");
|
|
62
|
+
expect(content).toContain("Existing content.");
|
|
63
|
+
expect(content).toContain("<!-- seeds:start -->");
|
|
64
|
+
expect(content).toContain("Issue Tracking (Seeds)");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("is idempotent — second onboard does not duplicate", async () => {
|
|
68
|
+
await initSeeds(tmpDir);
|
|
69
|
+
await run(["onboard"], tmpDir);
|
|
70
|
+
const first = await Bun.file(join(tmpDir, "CLAUDE.md")).text();
|
|
71
|
+
await run(["onboard"], tmpDir);
|
|
72
|
+
const second = await Bun.file(join(tmpDir, "CLAUDE.md")).text();
|
|
73
|
+
expect(second).toBe(first);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("--check reports missing when no file exists", async () => {
|
|
77
|
+
await initSeeds(tmpDir);
|
|
78
|
+
const { stdout, exitCode } = await run(["onboard", "--check"], tmpDir);
|
|
79
|
+
expect(exitCode).toBe(0);
|
|
80
|
+
expect(stdout).toContain("missing");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("--check reports current after onboard", async () => {
|
|
84
|
+
await initSeeds(tmpDir);
|
|
85
|
+
await run(["onboard"], tmpDir);
|
|
86
|
+
const { stdout, exitCode } = await run(["onboard", "--check"], tmpDir);
|
|
87
|
+
expect(exitCode).toBe(0);
|
|
88
|
+
expect(stdout).toContain("current");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("--check with --json returns structured output", async () => {
|
|
92
|
+
await initSeeds(tmpDir);
|
|
93
|
+
await run(["onboard"], tmpDir);
|
|
94
|
+
const { stdout, exitCode } = await run(["onboard", "--check", "--json"], tmpDir);
|
|
95
|
+
expect(exitCode).toBe(0);
|
|
96
|
+
const result = JSON.parse(stdout) as { success: boolean; status: string };
|
|
97
|
+
expect(result.success).toBe(true);
|
|
98
|
+
expect(result.status).toBe("current");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("--stdout prints snippet without writing", async () => {
|
|
102
|
+
await initSeeds(tmpDir);
|
|
103
|
+
const { stdout, exitCode } = await run(["onboard", "--stdout"], tmpDir);
|
|
104
|
+
expect(exitCode).toBe(0);
|
|
105
|
+
expect(stdout).toContain("<!-- seeds:start -->");
|
|
106
|
+
expect(stdout).toContain("Issue Tracking (Seeds)");
|
|
107
|
+
// Should not have created the file
|
|
108
|
+
const exists = await Bun.file(join(tmpDir, "CLAUDE.md")).exists();
|
|
109
|
+
expect(exists).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("detects existing CLAUDE.md in .claude/ subdirectory", async () => {
|
|
113
|
+
await initSeeds(tmpDir);
|
|
114
|
+
const claudeDir = join(tmpDir, ".claude");
|
|
115
|
+
await Bun.write(join(claudeDir, "CLAUDE.md"), "# Agent Instructions\n");
|
|
116
|
+
const { exitCode } = await run(["onboard"], tmpDir);
|
|
117
|
+
expect(exitCode).toBe(0);
|
|
118
|
+
const content = await Bun.file(join(claudeDir, "CLAUDE.md")).text();
|
|
119
|
+
expect(content).toContain("<!-- seeds:start -->");
|
|
120
|
+
// Root CLAUDE.md should NOT have been created
|
|
121
|
+
const rootExists = await Bun.file(join(tmpDir, "CLAUDE.md")).exists();
|
|
122
|
+
expect(rootExists).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("updates outdated section when version changes", async () => {
|
|
126
|
+
await initSeeds(tmpDir);
|
|
127
|
+
// Write a seeds section with an old version marker
|
|
128
|
+
const oldContent =
|
|
129
|
+
"# Project\n\n<!-- seeds:start -->\n## Old Seeds Section\n<!-- seeds-onboard-v:0 -->\nold content\n<!-- seeds:end -->\n";
|
|
130
|
+
await Bun.write(join(tmpDir, "CLAUDE.md"), oldContent);
|
|
131
|
+
const { exitCode } = await run(["onboard"], tmpDir);
|
|
132
|
+
expect(exitCode).toBe(0);
|
|
133
|
+
const content = await Bun.file(join(tmpDir, "CLAUDE.md")).text();
|
|
134
|
+
expect(content).toContain("# Project");
|
|
135
|
+
expect(content).toContain("seeds-onboard-v:1");
|
|
136
|
+
expect(content).not.toContain("seeds-onboard-v:0");
|
|
137
|
+
expect(content).not.toContain("Old Seeds Section");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("--json output on create", async () => {
|
|
141
|
+
await initSeeds(tmpDir);
|
|
142
|
+
const { stdout, exitCode } = await run(["onboard", "--json"], tmpDir);
|
|
143
|
+
expect(exitCode).toBe(0);
|
|
144
|
+
const result = JSON.parse(stdout) as { success: boolean; action: string };
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
expect(result.action).toBe("created");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("includes version marker in output", async () => {
|
|
150
|
+
await initSeeds(tmpDir);
|
|
151
|
+
await run(["onboard"], tmpDir);
|
|
152
|
+
const content = await Bun.file(join(tmpDir, "CLAUDE.md")).text();
|
|
153
|
+
expect(content).toContain("seeds-onboard-v:1");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { findSeedsDir, projectRootFromSeedsDir } from "../config.ts";
|
|
4
|
+
import { hasMarkerSection, replaceMarkerSection, wrapInMarkers } from "../markers.ts";
|
|
5
|
+
import { outputJson, printSuccess } from "../output.ts";
|
|
6
|
+
|
|
7
|
+
const ONBOARD_VERSION = 1;
|
|
8
|
+
const VERSION_MARKER = `<!-- seeds-onboard-v:${String(ONBOARD_VERSION)} -->`;
|
|
9
|
+
|
|
10
|
+
const CANDIDATE_FILES = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md"] as const;
|
|
11
|
+
|
|
12
|
+
function onboardSnippet(): string {
|
|
13
|
+
return `## Issue Tracking (Seeds)
|
|
14
|
+
${VERSION_MARKER}
|
|
15
|
+
|
|
16
|
+
This project uses [Seeds](https://github.com/jayminwest/seeds) for git-native issue tracking.
|
|
17
|
+
|
|
18
|
+
**At the start of every session**, run:
|
|
19
|
+
\`\`\`
|
|
20
|
+
sd prime
|
|
21
|
+
\`\`\`
|
|
22
|
+
|
|
23
|
+
This injects session context: rules, command reference, and workflows.
|
|
24
|
+
|
|
25
|
+
**Quick reference:**
|
|
26
|
+
- \`sd ready\` — Find unblocked work
|
|
27
|
+
- \`sd create --title "..." --type task --priority 2\` — Create issue
|
|
28
|
+
- \`sd update <id> --status in_progress\` — Claim work
|
|
29
|
+
- \`sd close <id>\` — Complete work
|
|
30
|
+
- \`sd sync\` — Sync with git (run before pushing)
|
|
31
|
+
|
|
32
|
+
### Before You Finish
|
|
33
|
+
1. Close completed issues: \`sd close <id>\`
|
|
34
|
+
2. File issues for remaining work: \`sd create --title "..."\`
|
|
35
|
+
3. Sync and push: \`sd sync && git push\``;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findTargetFile(projectRoot: string): string | null {
|
|
39
|
+
for (const candidate of CANDIDATE_FILES) {
|
|
40
|
+
const fullPath = join(projectRoot, candidate);
|
|
41
|
+
if (existsSync(fullPath)) {
|
|
42
|
+
return fullPath;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function detectStatus(content: string): "missing" | "current" | "outdated" {
|
|
49
|
+
if (!hasMarkerSection(content)) return "missing";
|
|
50
|
+
if (content.includes(VERSION_MARKER)) return "current";
|
|
51
|
+
return "outdated";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function run(args: string[]): Promise<void> {
|
|
55
|
+
const jsonMode = args.includes("--json");
|
|
56
|
+
const stdoutMode = args.includes("--stdout");
|
|
57
|
+
const checkMode = args.includes("--check");
|
|
58
|
+
|
|
59
|
+
const seedsDir = await findSeedsDir();
|
|
60
|
+
const projectRoot = projectRootFromSeedsDir(seedsDir);
|
|
61
|
+
|
|
62
|
+
const targetPath = findTargetFile(projectRoot);
|
|
63
|
+
const snippet = onboardSnippet();
|
|
64
|
+
|
|
65
|
+
// --check mode: report status only
|
|
66
|
+
if (checkMode) {
|
|
67
|
+
if (!targetPath) {
|
|
68
|
+
if (jsonMode) {
|
|
69
|
+
outputJson({ success: true, command: "onboard", status: "missing", file: null });
|
|
70
|
+
} else {
|
|
71
|
+
console.log("Status: missing (no CLAUDE.md found)");
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const content = await Bun.file(targetPath).text();
|
|
76
|
+
const status = detectStatus(content);
|
|
77
|
+
if (jsonMode) {
|
|
78
|
+
outputJson({ success: true, command: "onboard", status, file: targetPath });
|
|
79
|
+
} else {
|
|
80
|
+
console.log(`Status: ${status} (${targetPath})`);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --stdout mode: print what would be written
|
|
86
|
+
if (stdoutMode) {
|
|
87
|
+
process.stdout.write(wrapInMarkers(snippet));
|
|
88
|
+
process.stdout.write("\n");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Default mode: write to file
|
|
93
|
+
const filePath = targetPath ?? join(projectRoot, "CLAUDE.md");
|
|
94
|
+
const fileExists = existsSync(filePath);
|
|
95
|
+
const wrappedSnippet = wrapInMarkers(snippet);
|
|
96
|
+
|
|
97
|
+
if (!fileExists) {
|
|
98
|
+
await Bun.write(filePath, `${wrappedSnippet}\n`);
|
|
99
|
+
if (jsonMode) {
|
|
100
|
+
outputJson({ success: true, command: "onboard", action: "created", file: filePath });
|
|
101
|
+
} else {
|
|
102
|
+
printSuccess(`Created ${filePath} with seeds section`);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const content = await Bun.file(filePath).text();
|
|
108
|
+
const status = detectStatus(content);
|
|
109
|
+
|
|
110
|
+
if (status === "current") {
|
|
111
|
+
if (jsonMode) {
|
|
112
|
+
outputJson({ success: true, command: "onboard", action: "unchanged", file: filePath });
|
|
113
|
+
} else {
|
|
114
|
+
printSuccess("Seeds section is already up to date");
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (status === "outdated") {
|
|
120
|
+
const updated = replaceMarkerSection(content, snippet);
|
|
121
|
+
if (updated) {
|
|
122
|
+
await Bun.write(filePath, updated);
|
|
123
|
+
if (jsonMode) {
|
|
124
|
+
outputJson({ success: true, command: "onboard", action: "updated", file: filePath });
|
|
125
|
+
} else {
|
|
126
|
+
printSuccess(`Updated seeds section in ${filePath}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// status === "missing": append
|
|
133
|
+
const separator = content.endsWith("\n") ? "\n" : "\n\n";
|
|
134
|
+
await Bun.write(filePath, `${content}${separator}${wrappedSnippet}\n`);
|
|
135
|
+
if (jsonMode) {
|
|
136
|
+
outputJson({ success: true, command: "onboard", action: "appended", file: filePath });
|
|
137
|
+
} else {
|
|
138
|
+
printSuccess(`Added seeds section to ${filePath}`);
|
|
139
|
+
}
|
|
140
|
+
}
|