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
76 changes: 70 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,16 @@ When you update a convention — say, _"always use parameterized queries"_ — y

```bash
# Install
npm install -g openspec
npm install -g @menukfernando/openspec

# Initialize in your project
cd your-project
openspec init
npx @menukfernando/openspec init

# Edit your rules
# Option A: Let AI analyze your codebase and write rules for you
openspec generate # outputs codebase analysis for your AI agent

# Option B: Manually edit your rules
# (customize the modules in .openspec/modules/)

# Generate all AI context files
Expand Down Expand Up @@ -195,6 +198,7 @@ Usage: openspec [command] [options]

Commands:
init Scaffold .openspec/ with config + example modules
generate Analyze codebase and output context for AI-powered rule generation
sync [--quiet] Compile modules → generate all AI context files
watch Watch for module changes, auto-sync on save
status Show modules, targets, and sync status
Expand All @@ -219,6 +223,54 @@ Creates the `.openspec/` directory with a config file and four example modules:
testing.md ← Testing standards
```

### `openspec generate`

Performs deep codebase analysis — reading package.json, config files, directory structure, and actual source code — then outputs a structured context document. When run inside an AI agent (Claude Code, Cursor, etc.), the agent reads this output and uses it to write rich, project-specific module files.

```
$ openspec generate

Analyzing codebase...
# Codebase Analysis — my-app

## Tech Stack
- **Languages**: TypeScript (42 files)
- **Frontend**: React ^18.2.0
- **Backend**: Express ^4.18.0
- **Build**: Vite (vite.config.ts)
- **Testing**: Vitest (vitest.config.ts)
- **Styling**: Tailwind CSS
- **Database**: Prisma
...

## Code Samples
### Component Example (src/components/UserCard.tsx)
...

## Instructions for AI Agent
Using the analysis above, generate the following OpenSpec module files...
```

Options:

```bash
openspec generate # Markdown output to stdout (default)
openspec generate --json # JSON output for programmatic use
openspec generate -o report.md # Write analysis to file
openspec generate -q # Suppress non-essential output
```

**Typical workflow with an AI agent:**

```bash
# In Claude Code, Cursor, etc.:
# "Run openspec generate and fill in my rules"

openspec generate # AI reads the output
# → AI writes .openspec/modules/shared.md, frontend.md, backend.md, testing.md
openspec sync # Generate all 7 AI context files
```

### `openspec sync`

Reads all modules, filters per target, renders, and writes output files:
Expand Down Expand Up @@ -460,8 +512,19 @@ openspec/
│ ├── types.ts # TypeScript types & defaults
│ ├── commands/
│ │ ├── init.ts # 'openspec init' scaffolding
│ │ ├── generate.ts # 'openspec generate' handler
│ │ ├── sync.ts # 'openspec sync' handler
│ │ └── status.ts # 'openspec status' handler
│ ├── scanner/
│ │ ├── types.ts # ScanResult interfaces
│ │ ├── context.ts # Builds DetectorContext
│ │ ├── sampler.ts # Source file sampling
│ │ ├── formatter.ts # Markdown/JSON output formatting
│ │ └── detectors/ # Stack detection modules
│ │ ├── index.ts # Orchestrator
│ │ ├── language.ts # TS/JS/Python/Go/Rust
│ │ ├── framework.ts # React/Next/Express/etc
│ │ └── ... # build-tool, testing, linting, etc.
│ └── targets/
│ └── index.ts # Per-target renderers
├── assets/
Expand Down Expand Up @@ -513,9 +576,10 @@ npm run build # Compile TypeScript
- [x] `init` / `sync` / `watch` / `status` / `clean` / `diff` / `add` commands
- [x] `openspec diff` — preview changes before syncing
- [x] `openspec add <name>` — scaffold new modules from CLI
- [x] `openspec generate` — AI-powered codebase analysis + rule generation
- [x] CI pipeline (GitHub Actions — Linux/macOS/Windows, Node 18/20/22)
- [x] Test suite (vitest, 28 tests)
- [ ] `npx openspec` — zero-install usage (publish to npm)
- [x] Test suite (vitest, 68 tests)
- [ ] `npx @menukfernando/openspec` — zero-install usage (publish to npm)
- [ ] MCP server mode for dynamic context
- [ ] Module inheritance & composition
- [ ] Template variable interpolation
Expand All @@ -533,5 +597,5 @@ MIT — see [LICENSE](LICENSE) for details.

<p align="center">
<strong>Stop copy-pasting AI rules.</strong><br/>
<code>npx openspec init && npx openspec sync</code>
<code>npx @menukfernando/openspec init && npx @menukfernando/openspec sync</code>
</p>
12 changes: 12 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { runSync } from "./commands/sync.js";
import { runStatus } from "./commands/status.js";
import { runDiff } from "./commands/diff.js";
import { runAdd } from "./commands/add.js";
import { runGenerate } from "./commands/generate.js";
import { loadConfig } from "./config.js";
import { startWatcher } from "./watcher.js";
import { installHook, removeHook } from "./hooks.js";
Expand Down Expand Up @@ -102,6 +103,17 @@ program
await runAdd(root, name, options);
});

program
.command("generate")
.description("Analyze codebase and output context for AI-powered rule generation")
.option("--json", "Output as JSON instead of markdown")
.option("-o, --output <path>", "Write analysis to file instead of stdout")
.option("-q, --quiet", "Suppress non-essential output")
.action(async (options) => {
const root = resolve(".");
await runGenerate(root, options);
});

program
.command("clean")
.description("Remove all generated AI context files")
Expand Down
71 changes: 71 additions & 0 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { existsSync } from "fs";
import { writeFile, mkdir } from "fs/promises";
import { join, dirname } from "path";
import chalk from "chalk";
import { buildContext } from "../scanner/context.js";
import { runDetectors } from "../scanner/detectors/index.js";
import { sampleSourceFiles } from "../scanner/sampler.js";
import { formatMarkdown, formatJson } from "../scanner/formatter.js";

interface GenerateOptions {
json?: boolean;
output?: string;
quiet?: boolean;
}

export async function runGenerate(
root: string,
options: GenerateOptions
): Promise<void> {
const openspecDir = join(root, ".openspec");

// Auto-initialize if .openspec/ doesn't exist
if (!existsSync(openspecDir)) {
if (!options.quiet) {
console.log(
chalk.yellow("No .openspec/ directory found. Run `openspec init` first.")
);
}
process.exitCode = 1;
return;
}

if (!options.quiet) {
console.log(chalk.blue("Analyzing codebase..."));
}

// Build context
const ctx = await buildContext(root);

// Run all detectors
const scanResult = await runDetectors(ctx);

// Sample source files
const codeSamples = await sampleSourceFiles(root, scanResult);
scanResult.codeSamples = codeSamples;

// Format output
const output = options.json
? formatJson(scanResult)
: formatMarkdown(scanResult);

// Output
if (options.output) {
const outPath = join(root, options.output);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, output, "utf-8");
if (!options.quiet) {
console.log(chalk.green(`Analysis written to ${options.output}`));
}
} else {
console.log(output);
}

if (!options.quiet && !options.output) {
console.log(
chalk.dim(
"\nAI agent: use the analysis above to write module files in .openspec/modules/, then run 'openspec sync'"
)
);
}
}
42 changes: 42 additions & 0 deletions src/scanner/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { readFile } from "fs/promises";
import { join } from "path";
import fg from "fast-glob";
import type { DetectorContext } from "./types.js";

export async function buildContext(root: string): Promise<DetectorContext> {
const packageJson = await loadPackageJson(root);
const fileTree = await buildFileTree(root);

return { root, packageJson, fileTree };
}

async function loadPackageJson(
root: string
): Promise<Record<string, any> | null> {
try {
const raw = await readFile(join(root, "package.json"), "utf-8");
return JSON.parse(raw);
} catch {
return null;
}
}

async function buildFileTree(root: string): Promise<string[]> {
const entries = await fg(["**/*"], {
cwd: root,
deep: 2,
onlyFiles: false,
markDirectories: true,
dot: true,
ignore: [
"node_modules/**",
".git/**",
"dist/**",
"build/**",
".next/**",
"coverage/**",
".openspec/**",
],
});
return entries.sort();
}
44 changes: 44 additions & 0 deletions src/scanner/detectors/build-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Detector, BuildToolInfo } from "../types.js";

export const detectBuildTools: Detector = async (ctx) => {
const buildTools: BuildToolInfo[] = [];
const deps = { ...ctx.packageJson?.dependencies, ...ctx.packageJson?.devDependencies };

if (deps?.["vite"]) {
const config = ctx.fileTree.find((f) =>
/^vite\.config\.(ts|js|mjs)$/.test(f)
);
buildTools.push({ name: "Vite", config });
}

if (deps?.["webpack"] || ctx.fileTree.some((f) => f.startsWith("webpack.config"))) {
const config = ctx.fileTree.find((f) => f.startsWith("webpack.config"));
buildTools.push({ name: "Webpack", config });
}

if (deps?.["esbuild"]) {
buildTools.push({ name: "esbuild" });
}

if (deps?.["rollup"]) {
const config = ctx.fileTree.find((f) => f.startsWith("rollup.config"));
buildTools.push({ name: "Rollup", config });
}

if (deps?.["turbo"] || ctx.fileTree.some((f) => f === "turbo.json")) {
buildTools.push({ name: "Turborepo", config: "turbo.json" });
}

// TypeScript compiler
if (ctx.fileTree.some((f) => f === "tsconfig.json")) {
const scripts = ctx.packageJson?.scripts || {};
const useTsc = Object.values(scripts).some(
(s) => typeof s === "string" && s.includes("tsc")
);
if (useTsc) {
buildTools.push({ name: "tsc", config: "tsconfig.json" });
}
}

return { buildTools };
};
26 changes: 26 additions & 0 deletions src/scanner/detectors/cicd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Detector } from "../types.js";

export const detectCicd: Detector = async (ctx) => {
const cicd: string[] = [];

if (ctx.fileTree.some((f) => f.startsWith(".github/"))) {
cicd.push("GitHub Actions");
}
if (ctx.fileTree.includes(".gitlab-ci.yml")) {
cicd.push("GitLab CI");
}
if (ctx.fileTree.includes("Jenkinsfile")) {
cicd.push("Jenkins");
}
if (ctx.fileTree.includes(".circleci/")) {
cicd.push("CircleCI");
}
if (ctx.fileTree.includes(".travis.yml")) {
cicd.push("Travis CI");
}
if (ctx.fileTree.includes("Dockerfile") || ctx.fileTree.includes("docker-compose.yml")) {
cicd.push("Docker");
}

return { cicd };
};
39 changes: 39 additions & 0 deletions src/scanner/detectors/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Detector } from "../types.js";

export const detectDatabases: Detector = async (ctx) => {
const databases: string[] = [];
const deps = { ...ctx.packageJson?.dependencies, ...ctx.packageJson?.devDependencies };

if (deps?.["prisma"] || deps?.["@prisma/client"]) {
databases.push("Prisma");
}
if (deps?.["drizzle-orm"]) {
databases.push("Drizzle");
}
if (deps?.["mongoose"]) {
databases.push("Mongoose");
}
if (deps?.["pg"] || deps?.["postgres"]) {
databases.push("PostgreSQL");
}
if (deps?.["mysql2"] || deps?.["mysql"]) {
databases.push("MySQL");
}
if (deps?.["better-sqlite3"] || deps?.["sqlite3"]) {
databases.push("SQLite");
}
if (deps?.["redis"] || deps?.["ioredis"]) {
databases.push("Redis");
}
if (deps?.["typeorm"]) {
databases.push("TypeORM");
}
if (deps?.["sequelize"]) {
databases.push("Sequelize");
}
if (deps?.["knex"]) {
databases.push("Knex");
}

return { databases };
};
Loading
Loading