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
6 changes: 6 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ User-visible deltas as [ROADMAP.md](ROADMAP.md) items land. The roadmap is the s

## Unreleased

### #28 — Support current seed4j per-module history files

- **Shipped:** 2026-06-09
- **User impact:** `remove_module` now supports both seed4j history layouts: legacy `.seed4j/modules/history.json` and current timestamped per-module JSON files such as `.seed4j/modules/20260609195013012-maven-java.json`. Preview and replay behavior stay the same; confirmed removal rewrites legacy `history.json` or deletes the matching per-module action file.
- **Docs touched:** [tools.md](tools.md), [seed4j-api.md](seed4j-api.md).

### #27 — Add a remove-module prompt

- **Shipped:** 2026-05-29
Expand Down
39 changes: 30 additions & 9 deletions docs/seed4j-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,20 +225,18 @@ Spring Boot Actuator info endpoint. Used by `ping_seed4j` as a best-effort versi

## Project-local files

seed4j persists project history inside the project folder. The MCP server reads this file directly (and, for the `remove_module` tool, writes it back) — there is no HTTP endpoint to mutate history.
seed4j persists project history inside the project folder. The MCP server reads these files directly (and, for the `remove_module` tool, updates them) — there is no HTTP endpoint to mutate history.

### `.seed4j/modules/history.json`
### `.seed4j/modules` History Files

**Source:** [`FileSystemProjectsRepository.java`](https://github.com/seed4j/seed4j/blob/main/src/main/java/com/seed4j/project/infrastructure/secondary/FileSystemProjectsRepository.java) (`HISTORY_FOLDER = ".seed4j/modules"`, `HISTORY_FILE = "history.json"`), [`PersistedProjectHistory.java`](https://github.com/seed4j/seed4j/blob/main/src/main/java/com/seed4j/project/infrastructure/secondary/PersistedProjectHistory.java), [`PersistedProjectAction.java`](https://github.com/seed4j/seed4j/blob/main/src/main/java/com/seed4j/project/infrastructure/secondary/PersistedProjectAction.java).

**Shape:**
**Legacy shape:** older seed4j versions store all actions in `.seed4j/modules/history.json`.

```jsonc
{
"actions": [
{
"module": "string", // module slug (e.g. "init", "maven-java")
"date": "string", // ISO-8601 instant (Jackson Instant serialiser)
"date": "string", // ISO-8601 instant
"properties": {
/* Map<String, Object> — the properties the module was applied with */
},
Expand All @@ -247,12 +245,35 @@ seed4j persists project history inside the project folder. The MCP server reads
}
```

**Consumed by:** `remove_module` reads the file to determine which modules are applied and with what per-action properties; on a successful confirmed removal it writes the file back **atomically** (temp file in `.seed4j/modules/` + `rename`) with the targeted action filtered out. `get_project_status` indirectly relies on this file too — seed4j's `GET /api/projects` derives `RestProjectHistory` from it.
**Current shape:** recent seed4j versions store one JSON file per applied module in `.seed4j/modules/`, ordered by the timestamp prefix in the filename.

Examples:

```text
20260609195012528-init.json
20260609195013012-maven-java.json
20260609195013668-spring-boot.json
```

Each file contains one action:

```jsonc
{
"module": "string",
"date": "string",
"properties": {
/* Map<String, Object> — the properties the module was applied with */
},
}
```

**Consumed by:** `remove_module` reads either layout to determine which modules are applied and with what per-action properties. On a successful confirmed removal it writes legacy `history.json` back **atomically** (temp file in `.seed4j/modules/` + `rename`) or deletes the matching timestamped action file for current per-module history. `get_project_status` indirectly relies on this history too — seed4j's `GET /api/projects` derives `RestProjectHistory` from it.

**Notes:**

- Missing file or unparseable JSON → `remove_module` returns `action: "not-applied"`.
- The MCP server is **not** holding a lock on the file. If seed4j is concurrently mutating it during a `remove_module` call, the atomic rename minimises the corruption window but cannot eliminate it. Operators running both side-by-side should serialise their operations.
- Missing history files or unparseable legacy JSON → `remove_module` returns `action: "not-applied"`.
- Malformed per-module JSON files are skipped; valid timestamped files are still used.
- The MCP server is **not** holding a lock on the history files. If seed4j is concurrently mutating them during a `remove_module` call, operators running both side-by-side should serialise their operations.
- The on-disk file shape is part of seed4j's persistence layer rather than its public HTTP API; re-verify it against the seed4j source when bumping the seed4j version.

## Endpoints we don't use
Expand Down
2 changes: 1 addition & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Set `commit: true` when scaffolding a project end-to-end and the caller wants a
### `remove_module`

- **Input:** `moduleSlug: string`, `projectFolder: string`, `confirm?: boolean` (default `false`), `force?: boolean` (default `false`).
- **Behaviour:** removes a previously-applied module by reading the project's [`.seed4j/modules/history.json`](seed4j-api.md#seed4jmoduleshistoryjson), replaying the full history twice into scratch dirs — final state with the target and final state without — using each action's **own** properties, and diffing both against the current project folder. Classifies each touched file as **clean** (current bytes match the generated final state with the target) or **locally-modified** (current bytes differ — typically business code the user added on top of the scaffold). With `confirm: true`, deletes clean added files, reverts clean modified files to their generated final state without the target, skips locally-modified files (unless `force: true`), and writes `.seed4j/modules/history.json` back atomically with the targeted action removed. Cost: up to `2 × N - 1` apply-patch calls, where N is the number of applied modules. `.git/` and `.seed4j/` are excluded from the diff on every side.
- **Behaviour:** removes a previously-applied module by reading the project's [`.seed4j/modules` history files](seed4j-api.md#seed4jmodules-history-files), replaying the full history twice into scratch dirs — final state with the target and final state without — using each action's **own** properties, and diffing both against the current project folder. Classifies each touched file as **clean** (current bytes match the generated final state with the target) or **locally-modified** (current bytes differ — typically business code the user added on top of the scaffold). With `confirm: true`, deletes clean added files, reverts clean modified files to their generated final state without the target, skips locally-modified files (unless `force: true`), and updates the seed4j history by either rewriting legacy `.seed4j/modules/history.json` atomically or deleting the target timestamped module JSON file. Cost: up to `2 × N - 1` apply-patch calls, where N is the number of applied modules. `.git/` and `.seed4j/` are excluded from the diff on every side.
- **Output (preview, default):**
```json
{
Expand Down
105 changes: 86 additions & 19 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,22 @@ const REMOVE_SKIP_SEGMENTS = new Set([".git", ".seed4j"]);

const SEED4J_HISTORY_DIR = ".seed4j/modules";
const SEED4J_HISTORY_FILE = "history.json";
const SEED4J_HISTORY_JSON_SUFFIX = ".json";

// Contract: docs/seed4j-api.md#project-local-files
// .seed4j/modules/history.json shape inside a seed4j project folder.
// seed4j stores history either as legacy .seed4j/modules/history.json or as one
// timestamp-prefixed JSON file per applied module in .seed4j/modules/.
interface SeedHistoryAction {
module: string;
date: string;
properties: Properties;
}

type SeedHistoryStorage = { kind: "legacy" } | { kind: "module-files"; actionFiles: string[] };

interface SeedHistory {
actions: SeedHistoryAction[];
storage: SeedHistoryStorage;
}

interface RemoveModuleOptions {
Expand Down Expand Up @@ -544,8 +549,9 @@ export class Seed4jClient {

const updatedHistory: SeedHistory = {
actions: history.actions.filter((_: SeedHistoryAction, i: number) => i !== actionIndex),
storage: history.storage,
};
await writeSeed4jHistoryAtomic(projectFolder, updatedHistory);
await removeSeed4jHistoryAction(projectFolder, history, updatedHistory, actionIndex);

return JSON.stringify({
moduleSlug,
Expand Down Expand Up @@ -1067,13 +1073,18 @@ async function walkInto(
}

async function readSeed4jHistory(projectFolder: string): Promise<SeedHistory | null> {
const filePath = path.join(projectFolder, SEED4J_HISTORY_DIR, SEED4J_HISTORY_FILE);
const dirPath = path.join(projectFolder, SEED4J_HISTORY_DIR);
const filePath = path.join(dirPath, SEED4J_HISTORY_FILE);
let raw: string;
try {
raw = await readFile(filePath, "utf8");
return parseLegacySeed4jHistory(raw);
} catch {
return null;
return readSeed4jModuleFileHistory(dirPath);
}
}

function parseLegacySeed4jHistory(raw: string): SeedHistory | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
Expand All @@ -1082,32 +1093,88 @@ async function readSeed4jHistory(projectFolder: string): Promise<SeedHistory | n
}
if (!parsed || typeof parsed !== "object") return null;
const root = parsed as { actions?: unknown };
if (!Array.isArray(root.actions)) return { actions: [] };
if (!Array.isArray(root.actions)) return { actions: [], storage: { kind: "legacy" } };
const actions: SeedHistoryAction[] = [];
for (const item of root.actions) {
if (!item || typeof item !== "object") continue;
const candidate = item as Record<string, unknown>;
const module = typeof candidate.module === "string" ? candidate.module : null;
if (!module) continue;
const date = typeof candidate.date === "string" ? candidate.date : "";
const properties =
candidate.properties && typeof candidate.properties === "object"
? (candidate.properties as Properties)
: {};
actions.push({ module, date, properties });
const action = parseSeed4jHistoryAction(item);
if (action) actions.push(action);
}
return { actions };
return { actions, storage: { kind: "legacy" } };
}

async function writeSeed4jHistoryAtomic(
async function readSeed4jModuleFileHistory(dirPath: string): Promise<SeedHistory | null> {
let entries;
try {
entries = await readdir(dirPath, { withFileTypes: true });
} catch {
return null;
}
const files = entries
.filter(
(entry) =>
entry.isFile() &&
entry.name !== SEED4J_HISTORY_FILE &&
entry.name.endsWith(SEED4J_HISTORY_JSON_SUFFIX),
)
.map((entry) => entry.name)
.sort();

if (files.length === 0) return null;

const actions: SeedHistoryAction[] = [];
const actionFiles: string[] = [];
for (const file of files) {
const raw = await readFile(path.join(dirPath, file), "utf8").catch(() => null);
if (raw === null) continue;

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}

const action = parseSeed4jHistoryAction(parsed);
if (!action) continue;
actions.push(action);
actionFiles.push(file);
}

return { actions, storage: { kind: "module-files", actionFiles } };
}

function parseSeed4jHistoryAction(item: unknown): SeedHistoryAction | null {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
const module = typeof candidate.module === "string" ? candidate.module : null;
if (!module) return null;
const date = typeof candidate.date === "string" ? candidate.date : "";
const properties =
candidate.properties && typeof candidate.properties === "object"
? (candidate.properties as Properties)
: {};
return { module, date, properties };
}

async function removeSeed4jHistoryAction(
projectFolder: string,
history: SeedHistory,
previousHistory: SeedHistory,
updatedHistory: SeedHistory,
actionIndex: number,
): Promise<void> {
if (previousHistory.storage.kind === "module-files") {
const file = previousHistory.storage.actionFiles[actionIndex];
if (file) {
await rm(path.join(projectFolder, SEED4J_HISTORY_DIR, file), { force: true });
}
return;
}

const dirPath = path.join(projectFolder, SEED4J_HISTORY_DIR);
await mkdir(dirPath, { recursive: true });
const finalPath = path.join(dirPath, SEED4J_HISTORY_FILE);
const tempPath = path.join(dirPath, `.${SEED4J_HISTORY_FILE}.tmp-${process.pid}-${Date.now()}`);
const body = `${JSON.stringify(history, null, 2)}\n`;
const body = `${JSON.stringify({ actions: updatedHistory.actions }, null, 2)}\n`;
await writeFile(tempPath, body);
await rename(tempPath, finalPath);
}
Expand Down
6 changes: 3 additions & 3 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,19 +198,19 @@ export function buildTools(client: Seed4jClient): ToolDefinition[] {
{
name: "remove_module",
description:
"Remove a previously-applied seed4j module from a project: identifies the files that module installed (by replaying the project's history twice — with and without the target — into scratch dirs), classifies them as clean-since-install vs locally-modified (the latter typically contains business code the user added on top of the scaffold), and either previews or executes deletion/revert. Default mode is preview — no disk mutation. Set confirm: true to execute. By default, locally-modified files are skipped; set force: true to act on them too. On a successful confirm, also updates .seed4j/modules/history.json so get_project_status reflects the removal. The operation is heavyweight: replays ~2N apply-patch calls (N = number of applied modules in the project's history). Before flipping confirm: true, surface the locallyModifiedFiles list to the user — those are the files the caller will skip or destroy.",
"Remove a previously-applied seed4j module from a project: identifies the files that module installed (by replaying the project's history twice — with and without the target — into scratch dirs), classifies them as clean-since-install vs locally-modified (the latter typically contains business code the user added on top of the scaffold), and either previews or executes deletion/revert. Default mode is preview — no disk mutation. Set confirm: true to execute. By default, locally-modified files are skipped; set force: true to act on them too. On a successful confirm, also updates .seed4j/modules history so get_project_status reflects the removal; legacy history.json and current timestamped per-module JSON files are both supported. The operation is heavyweight: replays ~2N apply-patch calls (N = number of applied modules in the project's history). Before flipping confirm: true, surface the locallyModifiedFiles list to the user — those are the files the caller will skip or destroy.",
inputSchema: {
moduleSlug: z.string().describe("Slug identifier of the seed4j module to remove."),
projectFolder: z
.string()
.describe(
"Absolute path to the project folder. Must contain a `.seed4j/modules/history.json` (otherwise the call returns action: 'not-applied').",
"Absolute path to the project folder. Must contain seed4j module history in `.seed4j/modules/history.json` or timestamped `.seed4j/modules/*.json` files (otherwise the call returns action: 'not-applied').",
),
confirm: z
.boolean()
.optional()
.describe(
"Default false. When false (or omitted), returns a preview only — no disk mutation. When true, actually deletes/reverts the files and updates history.json.",
"Default false. When false (or omitted), returns a preview only — no disk mutation. When true, actually deletes/reverts the files and updates seed4j module history.",
),
force: z
.boolean()
Expand Down
Loading
Loading