Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/application/dtos/InitProjectDto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface IdeaAnswers {
problem: string;
audience: string;
differentiator: string;
}

export interface InitProjectDto {
/** GitHub owner (org or user). Resolved from git remote when available. */
repoOwner?: string;
/** GitHub repository name. Resolved from git remote when available. */
repoName?: string;
/** Answers from interactive IDEA.md prompts; omit to skip IDEA.md generation. */
ideaAnswers?: IdeaAnswers;
/** Whether to scaffold docs/decisions/ and docs/scenarios/. */
scaffoldDocs: boolean;
}
179 changes: 179 additions & 0 deletions src/application/use-cases/InitProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { DocumentStore } from "../../domain/interfaces/DocumentStore";
import { InitProjectDto, IdeaAnswers } from "../dtos/InitProjectDto";

export interface InitProjectResult {
/** Files created during this run. */
created: string[];
/** Files that were skipped because they already exist or data was absent. */
skipped: string[];
/** Existing context files discovered before any writes occurred. */
discoveredContextFiles: string[];
}

/**
* Standard context files read by cforge-dev at runtime, in loading order.
* A well-structured repository means CFORGE_DEV.md needs minimal overrides.
*/
export const CONTEXT_FILE_ORDER = [
"README.md",
"IDEA.md",
"ARCHITECTURE.md",
"MEMORANDUM.md",
"MILESTONES.md",
"CONTRIBUTING.md",
"CFORGE_DEV.md",
] as const;

export class InitProject {
constructor(private readonly store: DocumentStore) {}

execute(input: InitProjectDto): InitProjectResult {
const created: string[] = [];
const skipped: string[] = [];

// Step 1 — Discover existing context before any writes
const discoveredContextFiles = this.discoverContextFiles();

// Step 2 — CFORGE_DEV.md
if (!this.store.exists("CFORGE_DEV.md")) {
this.store.write("CFORGE_DEV.md", this.cforgeDevContent(input.repoOwner, input.repoName));
created.push("CFORGE_DEV.md");
} else {
skipped.push("CFORGE_DEV.md");
}

// Step 2 — README.md
if (!this.store.exists("README.md")) {
this.store.write("README.md", this.readmeContent(input.repoName));
created.push("README.md");
} else {
skipped.push("README.md");
}

// Step 2 — IDEA.md (only when user provided answers)
if (!this.store.exists("IDEA.md")) {
if (input.ideaAnswers) {
this.store.write("IDEA.md", this.ideaContent(input.ideaAnswers));
created.push("IDEA.md");
} else {
skipped.push("IDEA.md");
}
} else {
skipped.push("IDEA.md");
}

// Step 2 — ARCHITECTURE.md
if (!this.store.exists("ARCHITECTURE.md")) {
this.store.write("ARCHITECTURE.md", this.architectureContent(input.repoName));
created.push("ARCHITECTURE.md");
} else {
skipped.push("ARCHITECTURE.md");
}

// Step 3 — Scaffold docs/ if requested
if (input.scaffoldDocs) {
if (!this.store.exists("docs/decisions")) {
this.store.ensureDir("docs/decisions");
this.store.write(
"docs/decisions/0001-record-architecture-decisions.md",
this.adrTemplate(),
);
created.push("docs/decisions/");
}

if (!this.store.exists("docs/scenarios")) {
this.store.ensureDir("docs/scenarios");
this.store.write("docs/scenarios/example-scenario.md", this.sceTemplate());
created.push("docs/scenarios/");
}
}

return { created, skipped, discoveredContextFiles };
}

// ---------------------------------------------------------------------------
// Private helpers — discovery
// ---------------------------------------------------------------------------

private discoverContextFiles(): string[] {
const discovered: string[] = [];
for (const file of CONTEXT_FILE_ORDER) {
if (this.store.exists(file)) {
discovered.push(file);
}
}
const docsFiles = this.store.glob("docs/**/*.md");
discovered.push(...docsFiles);
return discovered;
}

// ---------------------------------------------------------------------------
// Private helpers — content generators
// ---------------------------------------------------------------------------

private cforgeDevContent(owner?: string, repo?: string): string {
const ownerLine = owner ?? "<org>";
const repoLine = repo ?? "<repo-name>";
return (
`# CFORGE_DEV.md\n` +
`## Repository\n` +
`owner: ${ownerLine}\n` +
`repo: ${repoLine}\n` +
`## Context files\n` +
`# List any non-standard files to include\n`
);
}

private readmeContent(repoName?: string): string {
const name = repoName ?? "My Project";
return (
`# ${name}\n\n` +
`> TODO: Add project description.\n\n` +
`## Getting Started\n\n` +
`TODO: Add setup instructions.\n`
);
}

private ideaContent(answers: IdeaAnswers): string {
return (
`# IDEA.md\n\n` +
`## What problem does this solve?\n\n` +
`${answers.problem}\n\n` +
`## Who is it for?\n\n` +
`${answers.audience}\n\n` +
`## What makes it different?\n\n` +
`${answers.differentiator}\n`
);
}

private architectureContent(repoName?: string): string {
const name = repoName ?? "this project";
return (
`# Architecture\n\n` +
`## Overview\n\n` +
`TODO: Describe the architecture of ${name}.\n\n` +
`## Layers\n\n` +
`TODO: Document architectural layers and dependencies.\n`
);
}

private adrTemplate(): string {
return (
`# ADR-0001: Record Architecture Decisions\n\n` +
`## Status\n\nAccepted\n\n` +
`## Context\n\nWe need to record the architectural decisions made on this project.\n\n` +
`## Decision\n\nWe will use Architecture Decision Records (ADRs) to capture significant decisions.\n\n` +
`## Consequences\n\nA log of architectural decisions will be maintained.\n`
);
}

private sceTemplate(): string {
return (
`# Scenario: Example\n\n` +
`## Context\n\nDescribe the scenario context.\n\n` +
`## Given\n\n- Initial conditions\n\n` +
`## When\n\n- Triggering event\n\n` +
`## Then\n\n- Expected outcomes\n`
);
}
}
77 changes: 77 additions & 0 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as readline from "readline";
import { InitProject } from "../../application/use-cases/InitProject";
import { FileSystemDocumentStore } from "../../infrastructure/filesystem/FileSystemDocumentStore";
import { resolveRepoFromGit } from "../utils/resolveRepo";
import { USAGE_INIT } from "../validation";
import { c } from "../utils/ui";

function prompt(rl: readline.Interface, question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => resolve(answer.trim()));
});
}

export async function initCommand(arg?: string): Promise<void> {
if (arg === "--help") {
console.log(USAGE_INIT);
process.exit(0);
}

// Resolve repo coordinates from git remote (best-effort)
const repoCoords = resolveRepoFromGit();

const store = new FileSystemDocumentStore(process.cwd());

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

try {
// Prompt for IDEA.md answers only when the file does not yet exist
let ideaAnswers: { problem: string; audience: string; differentiator: string } | undefined;
if (!store.exists("IDEA.md")) {
console.log(`\n${c.bold("Generating IDEA.md — answer three quick questions:")}\n`);
ideaAnswers = {
problem: await prompt(rl, " 1. What problem does this solve? "),
audience: await prompt(rl, " 2. Who is it for? "),
differentiator: await prompt(rl, " 3. What makes it different? "),
};
}

// Ask about docs scaffolding
const docsAnswer = await prompt(
rl,
`\nScaffold docs/decisions/ (ADRs) and docs/scenarios/ (SCEs)? [y/N] `,
);
const scaffoldDocs = docsAnswer.toLowerCase() === "y";

rl.close();

const result = new InitProject(store).execute({
repoOwner: repoCoords?.owner,
repoName: repoCoords?.repo,
ideaAnswers,
scaffoldDocs,
});

// Output summary
console.log();
if (result.created.length > 0) {
console.log(c.success(`\u2713 Created:`));
for (const f of result.created) {
console.log(` ${c.bold(f)}`);
}
}

if (result.skipped.length > 0) {
console.log(c.dim(`\n Skipped (already exist): ${result.skipped.join(", ")}`));
}

if (result.discoveredContextFiles.length > 0) {
console.log(c.dim(`\n Discovered context: ${result.discoveredContextFiles.join(", ")}`));
}

console.log(`\n${c.success("\u2713")} ${c.bold("cforge-dev init complete.")}\n`);
} catch (err) {
rl.close();
throw err;
}
}
5 changes: 5 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { verifyCommand } from "./commands/verify";
import { releaseCommand } from "./commands/release";
import { chatCommand } from "./commands/chat";
import { auditCommand } from "./commands/audit";
import { initCommand } from "./commands/init";
import { getVersion } from "./utils/getVersion";
import { printWelcome } from "./utils/ui";

export const USAGE = `cforge-dev — AI-native SDLC orchestrator

Usage:
cforge-dev init Scaffold and maintain project documentation
cforge-dev plan <prd-file> Plan a sprint from a PRD file
cforge-dev implement <issue-number> Generate Claude Code prompt for an issue
cforge-dev implement <n> --auto Autonomous: Claude Code implements + opens PR
Expand All @@ -38,6 +40,9 @@ export async function main(): Promise<void> {
}

switch (command) {
case "init":
await initCommand(args[0]);
break;
case "plan":
await planCommand(args[0]);
break;
Expand Down
1 change: 1 addition & 0 deletions src/cli/validation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const USAGE_INIT = "Usage: cforge-dev init";
export const USAGE_IMPLEMENT = "Usage: cforge-dev implement [issue-number] [--auto] [--max-budget <usd>]";
export const USAGE_VERIFY = "Usage: cforge-dev verify [pr-number]";
export const USAGE_RELEASE = "Usage: cforge-dev release [version]";
Expand Down
20 changes: 20 additions & 0 deletions src/domain/interfaces/DocumentStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Abstraction over filesystem operations, scoped to a project root.
* Paths are relative to the project root passed to the concrete implementation.
*/
export interface DocumentStore {
/** Returns true if the path exists (file or directory). */
exists(relativePath: string): boolean;

/** Reads a file and returns its content as a UTF-8 string. */
read(relativePath: string): string;

/** Writes content to a file, creating parent directories as needed. */
write(relativePath: string, content: string): void;

/** Creates a directory (and any missing parents) if it does not exist. */
ensureDir(relativePath: string): void;

/** Returns matching relative paths for a glob pattern rooted at the project root. */
glob(pattern: string): string[];
}
65 changes: 65 additions & 0 deletions src/infrastructure/filesystem/FileSystemDocumentStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as fs from "fs";
import * as path from "path";
import { DocumentStore } from "../../domain/interfaces/DocumentStore";

/**
* Filesystem-backed DocumentStore.
* All relative paths are resolved against `root` (defaults to `process.cwd()`).
*/
export class FileSystemDocumentStore implements DocumentStore {
private readonly root: string;

constructor(root: string = process.cwd()) {
this.root = root;
}

exists(relativePath: string): boolean {
return fs.existsSync(this.abs(relativePath));
}

read(relativePath: string): string {
return fs.readFileSync(this.abs(relativePath), "utf-8");
}

write(relativePath: string, content: string): void {
const absPath = this.abs(relativePath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content, "utf-8");
}

ensureDir(relativePath: string): void {
fs.mkdirSync(this.abs(relativePath), { recursive: true });
}

glob(pattern: string): string[] {
// Simple recursive glob that avoids adding a new dependency.
// Supports patterns of the form "docs/**/*.md".
const [base, ...rest] = pattern.split("/**");
const baseDir = this.abs(base);

if (!fs.existsSync(baseDir)) return [];

// Extract the file suffix after the last `*` in the glob tail (e.g. "/*.md" → ".md")
const ext = rest[0]?.replace(/^.*\*/, "") ?? "";
const results: string[] = [];
this.walk(baseDir, ext, results);

return results.map((abs) => path.relative(this.root, abs).replace(/\\/g, "/"));
}

private walk(dir: string, ext: string, results: string[]): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
this.walk(full, ext, results);
} else if (!ext || entry.name.endsWith(ext)) {
results.push(full);
}
}
}

private abs(relativePath: string): string {
return path.join(this.root, relativePath);
}
}
Loading
Loading