Skip to content

Commit a0fa3a5

Browse files
dmealingclaude
andauthored
fix(cli): meta init --refresh-docs must not re-scaffold or regress stack in a monorepo (#163) (#164)
Two coupled bugs made `meta init --refresh-docs` unusable on a multi-module monorepo: 1. `--force` cancelled the refresh-only short-circuit. The early return was gated on `!opts.force`, so `--refresh-docs --force` fell through to the FULL project scaffold — re-creating metaobjects/, metaobjects.config.ts, codegen/generators/, config.json, package.meta.json (and scaffolding into sibling packages). Refresh now short-circuits whenever the project exists, regardless of --force; --force on this path instead means "overwrite hand-edited docs in place rather than writing the .new sidecar". 2. Refresh re-detected the stack from a root-only probe, discarding the correct persisted stack. On a monorepo the client deps live in a sibling package and a Maven-built Kotlin server has no gradle file, so root detection yields "java server, no client" and the refreshed docs regress (and shrink, since a narrower stack assembles fewer reference fragments). The correct stack is already persisted in .metaobjects/.agent-context.json; refresh now reuses it (precedence: explicit --server/--client > persisted manifest > detection). TS-CLI-only: meta init / refresh / stack-detection are the Node CLI's; the other ports only expose agent-docs (which redirects to the Node CLI). No cross-port change and no conformance fixture — this is CLI scaffolding, not metamodel behavior. Adds regression tests: --refresh-docs --force writes docs only (no re-scaffold) and preserves a persisted multi-package stack. Claude-Session: https://claude.ai/code/session_01Ew1XfYSbEAezxjs9opynAe Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent eed48d9 commit a0fa3a5

2 files changed

Lines changed: 93 additions & 7 deletions

File tree

server/typescript/packages/cli/src/commands/init.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { existsSync as existsSyncWrap, readFileSync as readFileSyncWrap } from "
55
import { DEFAULT_CONFIG, ConfigSchema, saveConfig, PACKAGE_MANIFEST_FILE, DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "@metaobjectsdev/sdk";
66
import {
77
assemble, resolveAgentContextRoot, planScaffold,
8-
AGENT_CONTEXT_MANIFEST_PATH, type Manifest,
8+
AGENT_CONTEXT_MANIFEST_PATH, type Manifest, type Stack,
99
} from "@metaobjectsdev/sdk/agent-context";
1010
import { resolveStack } from "../lib/detect-stack.js";
1111
import { parseInitArgs } from "../lib/args.js";
@@ -156,13 +156,36 @@ function warnIfMonorepoSubdir(opts: InitOptions, result: InitResult): void {
156156
);
157157
}
158158

159+
/**
160+
* Resolve the stack for agent-context (re)scaffolding. Precedence:
161+
* 1. explicit --server/--client overrides — the user is (re)declaring the stack;
162+
* 2. the stack persisted in the prior manifest — ground truth from the last
163+
* init/refresh, reused so a correct multi-package stack line is never REGRESSED
164+
* by re-detecting from a root-only probe (issue #163: a monorepo's sibling-
165+
* package client and its Maven-built Kotlin are invisible at the root);
166+
* 3. best-effort detection from the root probe — a fresh project with no prior.
167+
* This governs EVERY path that runs writeAgentContext — refresh, `init --force`, and
168+
* `--docs-only` — so a declared stack survives on all of them unless the user passes
169+
* explicit overrides. resolveStack filters servers/clients to the valid vocabularies
170+
* and self-detects when handed empty arrays, so passing the manifest's persisted
171+
* string[]s (or nothing) straight through is safe — no need to special-case an empty
172+
* or absent prior.
173+
*/
174+
function stackForAgentContext(opts: InitOptions, prior: Manifest | undefined): Stack {
175+
const hasOverride = (opts.servers?.length ?? 0) > 0 || (opts.clients?.length ?? 0) > 0;
176+
const overrides = hasOverride
177+
? { servers: opts.servers ?? [], clients: opts.clients ?? [] }
178+
: { servers: prior?.servers ?? [], clients: prior?.clients ?? [] };
179+
return resolveStack(opts.cwd, overrides);
180+
}
181+
159182
async function writeAgentContext(opts: InitOptions, result: InitResult): Promise<void> {
160183
warnIfMonorepoSubdir(opts, result);
161-
const stack = resolveStack(opts.cwd, { servers: opts.servers ?? [], clients: opts.clients ?? [] });
184+
const prior = await readManifest(opts.cwd);
185+
const stack = stackForAgentContext(opts, prior);
162186
let assembled = assemble({ contentRoot: resolveAgentContextRoot(), stack });
163187
if (opts.noSkills) assembled = assembled.filter((f) => !f.path.startsWith(".claude/skills/"));
164188

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

175-
for (const w of decision.writes) {
198+
// --force: overwrite hand-edited docs in place rather than parking the fresh copy
199+
// at <path>.new (issue #163 — a forced (re)scaffold means "I mean it"; applies to
200+
// refresh and full-init alike). planScaffold already hashed every assembled file
201+
// into the manifest, so the in-place write stays tracked and a later non-forced
202+
// refresh sees it as unmodified.
203+
const writes = opts.force
204+
? [...decision.writes, ...decision.conflicts.map((c) => ({ path: c.path, contents: c.contents }))]
205+
: decision.writes;
206+
const conflicts = opts.force ? [] : decision.conflicts;
207+
208+
for (const w of writes) {
176209
const abs = join(opts.cwd, w.path);
177210
await mkdir(dirname(abs), { recursive: true });
178211
await writeFile(abs, w.contents, "utf8");
179212
result.created.push(w.path);
180213
}
181-
for (const c of decision.conflicts) {
214+
for (const c of conflicts) {
182215
const abs = join(opts.cwd, c.newPath);
183216
await mkdir(dirname(abs), { recursive: true });
184217
await writeFile(abs, c.contents, "utf8");
@@ -255,8 +288,14 @@ export async function init(opts: InitOptions): Promise<InitResult> {
255288
return result;
256289
}
257290

258-
if (opts.refreshDocs && exists && !opts.force) {
259-
// Refresh-only path: scaffold the agent-context, leave everything else alone.
291+
if (opts.refreshDocs && exists) {
292+
// Refresh-only path: (re)write the agent-context docs and NOTHING else — never
293+
// the project scaffold (metaobjects/, config.json, codegen/generators/,
294+
// metaobjects.config.ts). `--force` on this path means "overwrite hand-edited
295+
// docs in place instead of writing <path>.new" (handled in writeAgentContext),
296+
// NOT a full re-init — so refresh must short-circuit BEFORE the scaffold path
297+
// even when --force is set (issue #163). A refresh on a not-yet-initialized
298+
// repo (!exists) still falls through to a full init, matching prior behavior.
260299
await writeAgentContext(opts, result);
261300
return result;
262301
}

server/typescript/packages/cli/test/unit/init-refresh-docs.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,53 @@ describe("init --refresh-docs", () => {
7171
});
7272
});
7373

74+
describe("init --refresh-docs --force (issue #163)", () => {
75+
test("refreshes docs only — does NOT re-scaffold the project", async () => {
76+
await init({ cwd });
77+
// Remove scaffold-only artifacts; a refresh must not recreate them.
78+
rmSync(join(cwd, "metaobjects.config.ts"), { force: true });
79+
rmSync(join(cwd, "metaobjects"), { recursive: true, force: true });
80+
81+
await init({ cwd, refreshDocs: true, force: true });
82+
83+
// Docs refreshed …
84+
expect(existsSync(join(cwd, ".metaobjects", "AGENTS.md"))).toBe(true);
85+
// … but the project scaffold is NOT re-created by a docs refresh.
86+
expect(existsSync(join(cwd, "metaobjects.config.ts"))).toBe(false);
87+
expect(existsSync(join(cwd, "metaobjects"))).toBe(false);
88+
});
89+
90+
test("--force overwrites a hand-edited doc in place (no .new)", async () => {
91+
await init({ cwd });
92+
writeFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "stale content", "utf8");
93+
94+
await init({ cwd, refreshDocs: true, force: true });
95+
96+
expect(existsSync(join(cwd, ".metaobjects", "AGENTS.md.new"))).toBe(false);
97+
expect(readFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "utf8")).not.toBe("stale content");
98+
});
99+
});
100+
101+
describe("init --refresh-docs preserves the persisted stack (issue #163)", () => {
102+
test("refresh reuses the manifest stack instead of re-detecting from the root", async () => {
103+
// A multi-package monorepo stack, as an explicit init would record it. The tmp
104+
// cwd has no package.json / build files, so root-probe detection would yield an
105+
// empty stack — the refresh must NOT regress to that.
106+
await init({ cwd, servers: ["java", "kotlin"], clients: ["react", "tanstack"] });
107+
const before = readFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "utf8");
108+
expect(before).toContain("java, kotlin server");
109+
expect(before).toContain("react, tanstack client");
110+
111+
await init({ cwd, refreshDocs: true });
112+
113+
const after = readFileSync(join(cwd, ".metaobjects", "AGENTS.md"), "utf8");
114+
expect(after).toContain("java, kotlin server");
115+
expect(after).toContain("react, tanstack client");
116+
// Same stack + unmodified doc → refreshed in place, never a spurious .new.
117+
expect(existsSync(join(cwd, ".metaobjects", "AGENTS.md.new"))).toBe(false);
118+
});
119+
});
120+
74121
describe("metaobjects.config.ts wiring still scaffolded", () => {
75122
test("init writes metaobjects.config.ts with defineConfig wiring", async () => {
76123
await init({ cwd });

0 commit comments

Comments
 (0)