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
53 changes: 46 additions & 7 deletions server/typescript/packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { existsSync as existsSyncWrap, readFileSync as readFileSyncWrap } from "
import { DEFAULT_CONFIG, ConfigSchema, saveConfig, PACKAGE_MANIFEST_FILE, DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "@metaobjectsdev/sdk";
import {
assemble, resolveAgentContextRoot, planScaffold,
AGENT_CONTEXT_MANIFEST_PATH, type Manifest,
AGENT_CONTEXT_MANIFEST_PATH, type Manifest, type Stack,
} from "@metaobjectsdev/sdk/agent-context";
import { resolveStack } from "../lib/detect-stack.js";
import { parseInitArgs } from "../lib/args.js";
Expand Down Expand Up @@ -156,13 +156,36 @@ function warnIfMonorepoSubdir(opts: InitOptions, result: InitResult): void {
);
}

/**
* Resolve the stack for agent-context (re)scaffolding. Precedence:
* 1. explicit --server/--client overrides — the user is (re)declaring the stack;
* 2. the stack persisted in the prior manifest — ground truth from the last
* init/refresh, reused so a correct multi-package stack line is never REGRESSED
* by re-detecting from a root-only probe (issue #163: a monorepo's sibling-
* package client and its Maven-built Kotlin are invisible at the root);
* 3. best-effort detection from the root probe — a fresh project with no prior.
* This governs EVERY path that runs writeAgentContext — refresh, `init --force`, and
* `--docs-only` — so a declared stack survives on all of them unless the user passes
* explicit overrides. resolveStack filters servers/clients to the valid vocabularies
* and self-detects when handed empty arrays, so passing the manifest's persisted
* string[]s (or nothing) straight through is safe — no need to special-case an empty
* or absent prior.
*/
function stackForAgentContext(opts: InitOptions, prior: Manifest | undefined): Stack {
const hasOverride = (opts.servers?.length ?? 0) > 0 || (opts.clients?.length ?? 0) > 0;
const overrides = hasOverride
? { servers: opts.servers ?? [], clients: opts.clients ?? [] }
: { servers: prior?.servers ?? [], clients: prior?.clients ?? [] };
return resolveStack(opts.cwd, overrides);
}

async function writeAgentContext(opts: InitOptions, result: InitResult): Promise<void> {
warnIfMonorepoSubdir(opts, result);
const stack = resolveStack(opts.cwd, { servers: opts.servers ?? [], clients: opts.clients ?? [] });
const prior = await readManifest(opts.cwd);
const stack = stackForAgentContext(opts, prior);
let assembled = assemble({ contentRoot: resolveAgentContextRoot(), stack });
if (opts.noSkills) assembled = assembled.filter((f) => !f.path.startsWith(".claude/skills/"));

const prior = await readManifest(opts.cwd);
const decision = planScaffold({
stack, assembled, prior,
readCurrent: (rel) => {
Expand All @@ -172,13 +195,23 @@ async function writeAgentContext(opts: InitOptions, result: InitResult): Promise
generatedBy: cliVersion(),
});

for (const w of decision.writes) {
// --force: overwrite hand-edited docs in place rather than parking the fresh copy
// at <path>.new (issue #163 — a forced (re)scaffold means "I mean it"; applies to
// refresh and full-init alike). planScaffold already hashed every assembled file
// into the manifest, so the in-place write stays tracked and a later non-forced
// refresh sees it as unmodified.
const writes = opts.force
? [...decision.writes, ...decision.conflicts.map((c) => ({ path: c.path, contents: c.contents }))]
: decision.writes;
const conflicts = opts.force ? [] : decision.conflicts;

for (const w of writes) {
const abs = join(opts.cwd, w.path);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, w.contents, "utf8");
result.created.push(w.path);
}
for (const c of decision.conflicts) {
for (const c of conflicts) {
const abs = join(opts.cwd, c.newPath);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, c.contents, "utf8");
Expand Down Expand Up @@ -255,8 +288,14 @@ export async function init(opts: InitOptions): Promise<InitResult> {
return result;
}

if (opts.refreshDocs && exists && !opts.force) {
// Refresh-only path: scaffold the agent-context, leave everything else alone.
if (opts.refreshDocs && exists) {
// Refresh-only path: (re)write the agent-context docs and NOTHING else — never
// the project scaffold (metaobjects/, config.json, codegen/generators/,
// metaobjects.config.ts). `--force` on this path means "overwrite hand-edited
// docs in place instead of writing <path>.new" (handled in writeAgentContext),
// NOT a full re-init — so refresh must short-circuit BEFORE the scaffold path
// even when --force is set (issue #163). A refresh on a not-yet-initialized
// repo (!exists) still falls through to a full init, matching prior behavior.
await writeAgentContext(opts, result);
return result;
}
Expand Down
47 changes: 47 additions & 0 deletions server/typescript/packages/cli/test/unit/init-refresh-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,53 @@ describe("init --refresh-docs", () => {
});
});

describe("init --refresh-docs --force (issue #163)", () => {
test("refreshes docs only — does NOT re-scaffold the project", async () => {
await init({ cwd });
// Remove scaffold-only artifacts; a refresh must not recreate them.
rmSync(join(cwd, "metaobjects.config.ts"), { force: true });
rmSync(join(cwd, "metaobjects"), { recursive: true, force: true });

await init({ cwd, refreshDocs: true, force: true });

// Docs refreshed …
expect(existsSync(join(cwd, ".metaobjects", "AGENTS.md"))).toBe(true);
// … but the project scaffold is NOT re-created by a docs refresh.
expect(existsSync(join(cwd, "metaobjects.config.ts"))).toBe(false);
expect(existsSync(join(cwd, "metaobjects"))).toBe(false);
});

test("--force overwrites a hand-edited doc in place (no .new)", async () => {
await init({ cwd });
writeFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "stale content", "utf8");

await init({ cwd, refreshDocs: true, force: true });

expect(existsSync(join(cwd, ".metaobjects", "AGENTS.md.new"))).toBe(false);
expect(readFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "utf8")).not.toBe("stale content");
});
});

describe("init --refresh-docs preserves the persisted stack (issue #163)", () => {
test("refresh reuses the manifest stack instead of re-detecting from the root", async () => {
// A multi-package monorepo stack, as an explicit init would record it. The tmp
// cwd has no package.json / build files, so root-probe detection would yield an
// empty stack — the refresh must NOT regress to that.
await init({ cwd, servers: ["java", "kotlin"], clients: ["react", "tanstack"] });
const before = readFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "utf8");
expect(before).toContain("java, kotlin server");
expect(before).toContain("react, tanstack client");

await init({ cwd, refreshDocs: true });

const after = readFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "utf8");
expect(after).toContain("java, kotlin server");
expect(after).toContain("react, tanstack client");
// Same stack + unmodified doc → refreshed in place, never a spurious .new.
expect(existsSync(join(cwd, ".metaobjects", "AGENTS.md.new"))).toBe(false);
});
});

describe("metaobjects.config.ts wiring still scaffolded", () => {
test("init writes metaobjects.config.ts with defineConfig wiring", async () => {
await init({ cwd });
Expand Down
Loading