@ainecto/mcp 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,18 +10,18 @@ var FileTokenStore = class {
10
10
  constructor(rootDir = join(homedir(), ".ainecto")) {
11
11
  this.filePath = join(rootDir, "tokens.json");
12
12
  }
13
- async load(endpoint) {
13
+ async load(endpoint, role) {
14
14
  const file = await this.readFile();
15
- return file[tokenKey(endpoint)];
15
+ return file[tokenKey(endpoint, role)];
16
16
  }
17
- async save(endpoint, token) {
17
+ async save(endpoint, token, role) {
18
18
  const file = await this.readFile();
19
- file[tokenKey(endpoint)] = { ...token, endpoint, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
19
+ file[tokenKey(endpoint, role)] = { ...token, endpoint, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
20
20
  await this.writeFile(file);
21
21
  }
22
- async delete(endpoint) {
22
+ async delete(endpoint, role) {
23
23
  const file = await this.readFile();
24
- delete file[tokenKey(endpoint)];
24
+ delete file[tokenKey(endpoint, role)];
25
25
  await this.writeFile(file);
26
26
  }
27
27
  async readFile() {
@@ -56,8 +56,9 @@ var FileTokenStore = class {
56
56
  await chmodIfPermissive(this.filePath, 384);
57
57
  }
58
58
  };
59
- function tokenKey(endpoint) {
60
- return createHash("sha256").update(endpoint).digest("hex");
59
+ function tokenKey(endpoint, role) {
60
+ const material = role && role.trim() ? `${endpoint}#role=${role.trim().toLowerCase()}` : endpoint;
61
+ return createHash("sha256").update(material).digest("hex");
61
62
  }
62
63
  function isNodeError(error, code) {
63
64
  return error instanceof Error && "code" in error && error.code === code;
@@ -84,9 +85,13 @@ import { URLSearchParams } from "url";
84
85
 
85
86
  // src/core/config/endpoints.ts
86
87
  var DEFAULT_ENDPOINTS = {
87
- prod: "https://ainecto.com/mcp",
88
- dev: "https://dev.ainecto.com/mcp"
88
+ prod: "https://ai-erd.com/mcp",
89
+ dev: "https://dev.ai-erd.com/mcp"
89
90
  };
91
+ var LEGACY_DEFAULT_ENDPOINTS = [
92
+ "https://ainecto.com/mcp",
93
+ "https://dev.ainecto.com/mcp"
94
+ ];
90
95
  function resolveEndpoint(input = {}) {
91
96
  const envVars = input.envVars ?? process.env;
92
97
  const env = input.env ?? "prod";
@@ -104,9 +109,12 @@ function normalizeEndpoint(endpoint) {
104
109
  url.hash = "";
105
110
  return url.toString().replace(/\/$/, "");
106
111
  }
112
+ function isDefaultEndpointFor(env, endpoint) {
113
+ return normalizeEndpoint(endpoint) === DEFAULT_ENDPOINTS[env];
114
+ }
107
115
  function isDefaultEndpoint(endpoint) {
108
116
  const normalized = normalizeEndpoint(endpoint);
109
- return Object.values(DEFAULT_ENDPOINTS).includes(normalized);
117
+ return Object.values(DEFAULT_ENDPOINTS).includes(normalized) || LEGACY_DEFAULT_ENDPOINTS.includes(normalized);
110
118
  }
111
119
  function assertTrustedEndpointUrl(value, label = "URL") {
112
120
  const url = typeof value === "string" ? new URL(value) : value;
@@ -169,6 +177,31 @@ function isRecord(value) {
169
177
  return typeof value === "object" && value !== null && !Array.isArray(value);
170
178
  }
171
179
 
180
+ // src/core/harness/role.ts
181
+ var HARNESS_ROLES = ["design", "development", "test", "validation"];
182
+ var ROLE_SCOPE_PREFIX = "ai-erd:role:";
183
+ var ROLE_HEADER = "X-AI-ERD-Role";
184
+ var ROLE_ENV_VAR = "AI_ERD_ROLE";
185
+ function resolveRole(input = {}) {
186
+ if (input.role !== void 0) {
187
+ return { role: assertRole(input.role), source: "flag" };
188
+ }
189
+ const fromEnv = (input.envVars ?? process.env)[ROLE_ENV_VAR];
190
+ if (fromEnv && fromEnv.trim()) {
191
+ return { role: assertRole(fromEnv), source: "env" };
192
+ }
193
+ return { source: "none" };
194
+ }
195
+ function assertRole(value) {
196
+ const normalized = value.trim().toLowerCase();
197
+ if (HARNESS_ROLES.includes(normalized)) {
198
+ return normalized;
199
+ }
200
+ throw new Error(
201
+ `Invalid role "${value}". Expected one of: ${HARNESS_ROLES.join(", ")}.`
202
+ );
203
+ }
204
+
172
205
  // src/core/auth/oauth.ts
173
206
  var OAuthClient = class {
174
207
  constructor(options) {
@@ -187,7 +220,7 @@ var OAuthClient = class {
187
220
  assertEnvTokenEndpointAllowed(this.options.endpoint, this.envVars);
188
221
  return envToken;
189
222
  }
190
- const stored = await this.options.tokenStore.load(this.options.endpoint);
223
+ const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
191
224
  if (!stored) {
192
225
  return void 0;
193
226
  }
@@ -197,7 +230,7 @@ var OAuthClient = class {
197
230
  return this.refreshStoredToken(stored);
198
231
  }
199
232
  async refreshAfterUnauthorized() {
200
- const stored = await this.options.tokenStore.load(this.options.endpoint);
233
+ const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
201
234
  if (!stored?.refreshToken) {
202
235
  return void 0;
203
236
  }
@@ -218,6 +251,9 @@ var OAuthClient = class {
218
251
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
219
252
  authorizeUrl.searchParams.set("resource", this.options.endpoint);
220
253
  authorizeUrl.searchParams.set("state", state);
254
+ if (this.options.role) {
255
+ authorizeUrl.searchParams.set("scope", `mcp ${ROLE_SCOPE_PREFIX}${this.options.role}`);
256
+ }
221
257
  assertTrustedEndpointUrl(authorizeUrl, "OAuth authorization URL");
222
258
  await this.openBrowserImpl(authorizeUrl.toString());
223
259
  const code = await loopback.waitForCode();
@@ -231,14 +267,14 @@ var OAuthClient = class {
231
267
  ...registration.clientSecret ? { client_secret: registration.clientSecret } : {}
232
268
  }, this.fetchImpl);
233
269
  const stored = toStoredToken(this.options.endpoint, token, metadata, registration);
234
- await this.options.tokenStore.save(this.options.endpoint, stored);
270
+ await this.options.tokenStore.save(this.options.endpoint, stored, this.options.role);
235
271
  return stored;
236
272
  } finally {
237
273
  await loopback.close();
238
274
  }
239
275
  }
240
276
  async logout() {
241
- await this.options.tokenStore.delete(this.options.endpoint);
277
+ await this.options.tokenStore.delete(this.options.endpoint, this.options.role);
242
278
  }
243
279
  async refreshStoredToken(stored) {
244
280
  if (!stored.refreshToken) {
@@ -253,7 +289,7 @@ var OAuthClient = class {
253
289
  resource: this.options.endpoint
254
290
  }, this.fetchImpl);
255
291
  const next = toStoredToken(this.options.endpoint, token, metadata, stored);
256
- await this.options.tokenStore.save(this.options.endpoint, next);
292
+ await this.options.tokenStore.save(this.options.endpoint, next, this.options.role);
257
293
  return next.accessToken;
258
294
  }
259
295
  };
@@ -360,8 +396,8 @@ function toStoredToken(endpoint, token, metadata, previous) {
360
396
  async function createLoopbackReceiver(expectedState) {
361
397
  let resolveCode;
362
398
  let rejectCode;
363
- const codePromise = new Promise((resolve, reject) => {
364
- resolveCode = resolve;
399
+ const codePromise = new Promise((resolve2, reject) => {
400
+ resolveCode = resolve2;
365
401
  rejectCode = reject;
366
402
  });
367
403
  codePromise.catch(() => void 0);
@@ -392,7 +428,7 @@ async function createLoopbackReceiver(expectedState) {
392
428
  res.end("Ainecto CLI authorization complete. You can close this tab.");
393
429
  resolveCode(code);
394
430
  });
395
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
431
+ await new Promise((resolve2) => server.listen(0, "127.0.0.1", resolve2));
396
432
  const address = server.address();
397
433
  if (!address || typeof address === "string") {
398
434
  throw new Error("Loopback OAuth server did not bind to a TCP port.");
@@ -400,7 +436,7 @@ async function createLoopbackReceiver(expectedState) {
400
436
  return {
401
437
  redirectUri: `http://127.0.0.1:${address.port}/callback`,
402
438
  waitForCode: () => codePromise,
403
- close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
439
+ close: () => new Promise((resolve2, reject) => server.close((error) => error ? reject(error) : resolve2()))
404
440
  };
405
441
  }
406
442
  function parseStringArray(value) {
@@ -585,6 +621,9 @@ var McpRpcClient = class {
585
621
  if (token) {
586
622
  headers.authorization = `Bearer ${token}`;
587
623
  }
624
+ if (this.options.role) {
625
+ headers[ROLE_HEADER.toLowerCase()] = this.options.role;
626
+ }
588
627
  return this.fetchImpl(this.options.endpoint, {
589
628
  method: "POST",
590
629
  headers,
@@ -592,6 +631,9 @@ var McpRpcClient = class {
592
631
  });
593
632
  }
594
633
  };
634
+ function isToolError(result) {
635
+ return isRecord2(result) && result.isError === true;
636
+ }
595
637
  function isJsonRpcResponse(value) {
596
638
  return isRecord2(value) && value.jsonrpc === "2.0" && ("result" in value || "error" in value);
597
639
  }
@@ -880,8 +922,12 @@ function isRecord3(value) {
880
922
  // src/adapters/mcp/connector.ts
881
923
  async function runConnector(options) {
882
924
  const tokenStore = new FileTokenStore();
883
- const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore });
884
- const client = new McpRpcClient({ endpoint: options.endpoint, tokenProvider: auth });
925
+ const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore, role: options.role });
926
+ const client = new McpRpcClient({
927
+ endpoint: options.endpoint,
928
+ tokenProvider: auth,
929
+ role: options.role
930
+ });
885
931
  await runStdioBridge({
886
932
  input: options.input,
887
933
  output: options.output,
@@ -1280,7 +1326,7 @@ async function executeGeneratedCommand(options) {
1280
1326
  warnings,
1281
1327
  presentation: route.presentation
1282
1328
  }));
1283
- return 0;
1329
+ return isToolError(result) ? 1 : 0;
1284
1330
  }
1285
1331
  function renderGeneratedResult(result, options) {
1286
1332
  if (!options.json && options.presentation?.outputHint === "table") {
@@ -1697,166 +1743,1965 @@ function renderAttachmentUploadResult(result, json) {
1697
1743
  return renderSuccess(lines.join("\n"), { json: false });
1698
1744
  }
1699
1745
 
1700
- // src/adapters/cli/ainectoCli.ts
1701
- async function runAinectoCli(argv, io) {
1702
- let parsed = { json: argv.includes("--json"), help: false, positionals: [] };
1746
+ // src/adapters/cli/initCommand.ts
1747
+ import { chmod as chmod2, lstat, mkdir as mkdir2, readFile as readFile4, realpath, rename as rename2, rm as rm2, rmdir, stat as stat3, writeFile as writeFile2 } from "fs/promises";
1748
+ import { dirname as dirname2, join as join2, relative, resolve, sep } from "path";
1749
+
1750
+ // src/core/harness/agentTargets.ts
1751
+ var SERVER_NAME = "ai-erd";
1752
+ var PACKAGE_NAME = "@ai-erd/mcp";
1753
+ var AGENT_TARGETS = [
1754
+ { id: "claude-code", label: "Claude Code", path: ".mcp.json" },
1755
+ { id: "cursor", label: "Cursor", path: ".cursor/mcp.json" }
1756
+ ];
1757
+ function buildServerEntry(input) {
1758
+ const args = ["-y", PACKAGE_NAME, "--role", input.role];
1759
+ if (input.env && input.env !== "prod") {
1760
+ args.push("--env", input.env);
1761
+ }
1762
+ if (input.endpoint && !isDefaultEndpointFor(input.env ?? "prod", input.endpoint)) {
1763
+ args.push("--endpoint", input.endpoint);
1764
+ }
1765
+ return { command: "npx", args };
1766
+ }
1767
+ function readRoleFromEntry(entry) {
1768
+ if (!isRecord7(entry) || !Array.isArray(entry.args)) {
1769
+ return void 0;
1770
+ }
1771
+ const index = entry.args.indexOf("--role");
1772
+ const value = index >= 0 ? entry.args[index + 1] : void 0;
1773
+ return typeof value === "string" && HARNESS_ROLES.includes(value) ? value : void 0;
1774
+ }
1775
+ function readServerEntry(existing) {
1776
+ if (existing === void 0) {
1777
+ return void 0;
1778
+ }
1703
1779
  try {
1704
- parsed = parseGlobalArgs(argv);
1705
- if (parsed.help || parsed.positionals.length === 0) {
1706
- io.stdout.write(helpText());
1707
- return 0;
1708
- }
1709
- const resolved = resolveEndpoint({ env: parsed.env, endpoint: parsed.endpoint });
1710
- const tokenStore = new FileTokenStore();
1711
- const auth = new OAuthClient({ endpoint: resolved.endpoint, tokenStore });
1712
- const client = new McpRpcClient({ endpoint: resolved.endpoint, tokenProvider: auth });
1713
- const [domain, action, ...rest] = parsed.positionals;
1714
- if (domain === "mcp") {
1715
- await runConnector({
1716
- endpoint: resolved.endpoint,
1717
- input: io.stdin,
1718
- output: io.stdout,
1719
- errorOutput: io.stderr
1720
- });
1721
- return 0;
1722
- }
1723
- if (domain === "auth") {
1724
- return handleAuth(action, auth, resolved.endpoint, parsed.json, io);
1725
- }
1726
- if (isAttachmentsUploadCommand(domain, action)) {
1727
- return await executeAttachmentsUploadCommand({
1728
- argv: rest,
1729
- client,
1730
- json: parsed.json,
1731
- io
1732
- });
1733
- }
1734
- const generatedResult = await executeGeneratedCommand({
1735
- env: parsed.env ?? "prod",
1736
- argv: parsed.positionals,
1737
- client,
1738
- json: parsed.json,
1739
- io
1740
- });
1741
- if (generatedResult !== 2) {
1742
- return generatedResult;
1743
- }
1744
- if (domain === "tools") {
1745
- return handleTools(action, rest, parsed, client, io);
1746
- }
1747
- throw new Error(`Unknown command "${domain}".`);
1780
+ const parsed = JSON.parse(existing);
1781
+ return parsed?.mcpServers?.[SERVER_NAME];
1782
+ } catch {
1783
+ return void 0;
1784
+ }
1785
+ }
1786
+ function mergeServerEntry(existing, entry) {
1787
+ const root = parseConfigObject(existing);
1788
+ const servers = readServers(root);
1789
+ const replaced = Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
1790
+ const replacedEntry = replaced ? servers[SERVER_NAME] : void 0;
1791
+ servers[SERVER_NAME] = entry;
1792
+ return { content: stringify({ ...root, mcpServers: servers }), replaced, replacedEntry };
1793
+ }
1794
+ function removeServerEntry(existing, restore) {
1795
+ if (existing === void 0) {
1796
+ return { removed: false, emptied: false };
1797
+ }
1798
+ const root = parseConfigObject(existing);
1799
+ const servers = readServers(root);
1800
+ const removed = Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
1801
+ if (restore !== void 0) {
1802
+ servers[SERVER_NAME] = restore.value;
1803
+ return { content: stringify({ ...root, mcpServers: servers }), removed, emptied: false };
1804
+ }
1805
+ delete servers[SERVER_NAME];
1806
+ const otherKeys = Object.keys(root).filter((key) => key !== "mcpServers");
1807
+ const emptied = Object.keys(servers).length === 0 && otherKeys.length === 0;
1808
+ return { content: stringify({ ...root, mcpServers: servers }), removed, emptied };
1809
+ }
1810
+ function codexAddCommand(input) {
1811
+ const entry = buildServerEntry(input);
1812
+ return `codex mcp add ${SERVER_NAME} -- ${entry.command} ${entry.args.join(" ")}`;
1813
+ }
1814
+ function codexProfileSetup(input) {
1815
+ const entry = buildServerEntry(input);
1816
+ return [
1817
+ `Per-session roles in Codex use profiles. Put this in $CODEX_HOME/${input.role}.config.toml:`,
1818
+ "",
1819
+ ` [mcp_servers.${SERVER_NAME}]`,
1820
+ ` command = "${entry.command}"`,
1821
+ ` args = ${JSON.stringify(entry.args)}`,
1822
+ "",
1823
+ `Then start that session with: codex -p ${input.role}`,
1824
+ "A profile entry with the same server name replaces the global one, so the session still sees exactly one."
1825
+ ];
1826
+ }
1827
+ function codexRemoveCommand() {
1828
+ return `codex mcp remove ${SERVER_NAME}`;
1829
+ }
1830
+ function readServers(root) {
1831
+ const servers = root.mcpServers;
1832
+ if (servers === void 0) {
1833
+ return {};
1834
+ }
1835
+ if (!isRecord7(servers)) {
1836
+ throw new Error(
1837
+ 'Existing MCP config has an "mcpServers" value that is not an object, so it was left untouched. Fix it by hand and run init again.'
1838
+ );
1839
+ }
1840
+ return { ...servers };
1841
+ }
1842
+ function parseConfigObject(existing) {
1843
+ if (existing === void 0 || !existing.trim()) {
1844
+ return {};
1845
+ }
1846
+ let parsed;
1847
+ try {
1848
+ parsed = JSON.parse(existing);
1748
1849
  } catch (error) {
1749
- io.stderr.write(renderError(error, { json: parsed.json }));
1750
- return 1;
1850
+ throw new Error(
1851
+ `Existing MCP config is not valid JSON, so it was left untouched. Fix it by hand and run init again. (${error instanceof Error ? error.message : String(error)})`
1852
+ );
1751
1853
  }
1854
+ if (!isRecord7(parsed)) {
1855
+ throw new Error("Existing MCP config must be a JSON object; it was left untouched.");
1856
+ }
1857
+ return parsed;
1752
1858
  }
1753
- async function handleAuth(action, auth, endpoint, json, io) {
1754
- if (action === "login") {
1755
- await auth.login();
1756
- io.stdout.write(renderSuccess({ endpoint, authenticated: true }, { json }));
1757
- return 0;
1859
+ function stringify(value) {
1860
+ return `${JSON.stringify(value, null, 2)}
1861
+ `;
1862
+ }
1863
+ function isRecord7(value) {
1864
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1865
+ }
1866
+
1867
+ // src/core/harness/harnessDoc.ts
1868
+ var MARKER_BEGIN = "<!-- ai-erd:begin -->";
1869
+ var MARKER_END = "<!-- ai-erd:end -->";
1870
+ var HARNESS_DOC_PATH = ".ai-erd/HARNESS.md";
1871
+ var HARNESS_CONFIG_PATH = ".ai-erd/config.json";
1872
+ var HARNESS_RECORD_PATH = ".ai-erd/init-record.json";
1873
+ function renderHarnessDoc(input, template) {
1874
+ return fillTemplate(template ?? DEFAULT_HARNESS_DOC, {
1875
+ projectName: input.projectName,
1876
+ projectUuid: input.projectUuid,
1877
+ endpoint: input.endpoint
1878
+ });
1879
+ }
1880
+ function fillTemplate(template, values) {
1881
+ return template.replace(/\{\{([a-zA-Z0-9_]+)}}/g, (whole, name) => Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole);
1882
+ }
1883
+ var DOC_KEY = "HARNESS.DOC";
1884
+ var AGENT_NOTE_KEY = "HARNESS.AGENT_NOTE";
1885
+ var REQUIRED_VARIABLES = {
1886
+ [DOC_KEY]: ["projectName", "projectUuid", "endpoint"],
1887
+ [AGENT_NOTE_KEY]: ["harnessDocPath", "serverName"]
1888
+ };
1889
+ function documentContractProblem(key, body) {
1890
+ for (const name of REQUIRED_VARIABLES[key] ?? []) {
1891
+ if (!body.includes(`{{${name}}}`)) {
1892
+ return `${key} is missing the required {{${name}}} placeholder`;
1893
+ }
1758
1894
  }
1759
- if (action === "status") {
1760
- const token = await auth.getAccessToken();
1761
- io.stdout.write(renderSuccess({ endpoint, authenticated: Boolean(token) }, { json }));
1762
- return 0;
1895
+ if (key === AGENT_NOTE_KEY) {
1896
+ return managedBlockProblem(key, body);
1763
1897
  }
1764
- if (action === "logout") {
1765
- await auth.logout();
1766
- io.stdout.write(renderSuccess({ endpoint, authenticated: false }, { json }));
1767
- return 0;
1898
+ return void 0;
1899
+ }
1900
+ var DEFAULT_HARNESS_DOC = `# AI-ERD Harness
1901
+
1902
+ This repository is wired to an AI-ERD project. The design source of truth lives in AI-ERD,
1903
+ not in this repository's files.
1904
+
1905
+ - Project: **{{projectName}}** (\`{{projectUuid}}\`)
1906
+ - MCP endpoint: {{endpoint}}
1907
+
1908
+ ## One session, one role
1909
+
1910
+ A session picks exactly one role and keeps it for the whole session. The role comes from the
1911
+ MCP server configuration \u2014 not from this file, and not from anything an agent can change at
1912
+ runtime. The server exposes only the tools that role is allowed to use, so a tool that is out
1913
+ of role is not merely discouraged: it is absent.
1914
+
1915
+ | Role | Owns | Must not |
1916
+ | --- | --- | --- |
1917
+ | Design | Requirements, ERD, domain and module boundaries, dependency direction, tasks | Write product or test code |
1918
+ | Development | Product code, following the approved design | Change requirements, ERD, architecture, or data ownership |
1919
+ | Test | Test scenarios and test code, running them and reading results | Change product code to make a test pass; change the design |
1920
+ | Validation | Judging the result against the design and the rules | Change anything; it reports PASS or FAIL with evidence |
1921
+
1922
+ Every role can **read** the whole source of truth. Reading the design is how the other roles
1923
+ do their job. A session that cannot do something reports it in the session, to you.
1924
+
1925
+ ## When you are blocked
1926
+
1927
+ If the work needs something your role cannot do, **stop and say so**. Do not look for a way
1928
+ around it: not by editing files the tools would have written, not by asking for the role to be
1929
+ changed mid-session, not by deferring the decision into the implementation.
1930
+
1931
+ The work needs a design change.
1932
+ This is a Development Session, so I cannot make it.
1933
+ Settle it in a Design Session, then continue here.
1934
+
1935
+ Validation reports \`PASS\`, or \`FAIL\` with the rule violated, where it happened, the evidence,
1936
+ and which session should pick it up next.
1937
+
1938
+ ## Not every change needs four sessions
1939
+
1940
+ A small change that fits the design already recorded in AI-ERD starts at Development. The rule
1941
+ is not "always run four steps" \u2014 it is that when a step is needed, it does not happen inside
1942
+ another role's session.
1943
+
1944
+ ## Switching roles
1945
+
1946
+ Change the role in the MCP server configuration and start a new session:
1947
+
1948
+ ${AGENT_TARGETS.map((target) => ` ${target.label.padEnd(12)} ${target.path}`).join("\n")}
1949
+ Codex global config \u2014 use a profile: codex -p <role>, or set ${ROLE_ENV_VAR}
1950
+
1951
+ Or run \`ai-erd init --role <design|development|test|validation>\` to rewrite them.
1952
+
1953
+ ---
1954
+ Generated by \`ai-erd init\`. Remove everything it wrote with \`ai-erd init --undo\`.
1955
+ `;
1956
+ function renderAgentNote(template) {
1957
+ return fillTemplate(template ?? DEFAULT_AGENT_NOTE, {
1958
+ harnessDocPath: HARNESS_DOC_PATH,
1959
+ serverName: SERVER_NAME
1960
+ });
1961
+ }
1962
+ var DEFAULT_AGENT_NOTE = `${MARKER_BEGIN}
1963
+ ## AI-ERD Harness
1964
+
1965
+ This repository uses AI-ERD as its design source of truth, and each session has one role
1966
+ (Design / Development / Test / Validation). Read \`{{harnessDocPath}}\` before doing work that
1967
+ touches requirements, the ERD, or architecture.
1968
+
1969
+ The role of this session comes from the \`{{serverName}}\` MCP server configuration, and from the
1970
+ access token when you signed in with a role; the server states it in its \`initialize\`
1971
+ instructions and exposes only that role's tools. If the work needs something outside the role,
1972
+ stop and report which session is required.
1973
+ ${MARKER_END}`;
1974
+ function managedBlockProblem(key, body) {
1975
+ const trimmed = body.trim();
1976
+ if (!trimmed.startsWith(MARKER_BEGIN)) {
1977
+ return `${key} must begin with ${MARKER_BEGIN} \u2014 text outside the block is never removed by --undo`;
1978
+ }
1979
+ if (!trimmed.endsWith(MARKER_END)) {
1980
+ return `${key} must end with ${MARKER_END} \u2014 text outside the block is never removed by --undo`;
1981
+ }
1982
+ const inner = trimmed.slice(MARKER_BEGIN.length, trimmed.length - MARKER_END.length);
1983
+ if (inner.includes(MARKER_BEGIN) || inner.includes(MARKER_END)) {
1984
+ return `${key} must contain exactly one ${MARKER_BEGIN} and one ${MARKER_END}`;
1985
+ }
1986
+ if (!inner.startsWith("\n")) {
1987
+ return `${key} must put ${MARKER_BEGIN} alone on its own line`;
1988
+ }
1989
+ if (!inner.endsWith("\n")) {
1990
+ return `${key} must put ${MARKER_END} alone on its own line`;
1991
+ }
1992
+ if (hasUnclosedFence(inner)) {
1993
+ return `${key} contains an unclosed code fence \u2014 the closing marker would fall inside it`;
1994
+ }
1995
+ return void 0;
1996
+ }
1997
+ function hasUnclosedFence(content) {
1998
+ let open;
1999
+ for (const line of content.split("\n")) {
2000
+ const fence = fenceOf(line);
2001
+ if (!fence) continue;
2002
+ if (!open) open = { char: fence.char, length: fence.length };
2003
+ else if (fence.char === open.char && fence.length >= open.length && fence.info === "") open = void 0;
2004
+ }
2005
+ return open !== void 0;
2006
+ }
2007
+ function canonicalManagedBlock(block) {
2008
+ return block.trim();
2009
+ }
2010
+ function upsertMarkerBlock(existing, block) {
2011
+ const current = existing ?? "";
2012
+ assertNoDanglingMarker(current);
2013
+ const normalized = canonicalManagedBlock(block);
2014
+ const range = markerRange(current);
2015
+ const composed = range ? `${current.slice(0, range.start)}${normalized}${current.slice(range.end)}` : `${current.trim() ? `${current.replace(/\s*$/, "")}
2016
+
2017
+ ` : ""}${normalized}
2018
+ `;
2019
+ assertComposedBlockIsOurs(composed, current);
2020
+ return composed;
2021
+ }
2022
+ function assertComposedBlockIsOurs(composed, existing) {
2023
+ if (ownedMarkerCount(composed, MARKER_BEGIN) === 1 && ownedMarkerCount(composed, MARKER_END) === 1) {
2024
+ return;
1768
2025
  }
1769
- throw new Error("Expected auth login, auth status, or auth logout.");
2026
+ const reason = hasUnclosedFence(existing) ? "this file ends inside an unclosed code fence, so the block would sit inside it" : "the block could not be found again once merged into this file";
2027
+ throw new Error(
2028
+ `ai-erd could not place its block where --undo would find it again \u2014 ${reason}. The file was left untouched; fix that and run again.`
2029
+ );
1770
2030
  }
1771
- async function handleTools(action, rest, parsed, client, io) {
1772
- if (action === "list") {
1773
- const tools = await client.toolsList();
1774
- io.stdout.write(renderSuccess(tools, { json: parsed.json }));
1775
- return 0;
2031
+ function assertNoDanglingMarker(content) {
2032
+ const begins = ownedMarkerCount(content, MARKER_BEGIN);
2033
+ const ends = ownedMarkerCount(content, MARKER_END);
2034
+ if (begins !== ends || begins > 1) {
2035
+ throw new Error(
2036
+ `ai-erd markers are unbalanced (${begins} begin, ${ends} end). The file was left untouched \u2014 remove the stray ${MARKER_BEGIN} / ${MARKER_END} by hand and run again.`
2037
+ );
1776
2038
  }
1777
- if (action === "call") {
1778
- const toolName = rest[0];
1779
- if (!toolName) {
1780
- throw new Error("Expected MCP tool name after tools call.");
2039
+ if (begins === 0) {
2040
+ return;
2041
+ }
2042
+ if (ownedMarkerIndex(content, MARKER_END, 0) < ownedMarkerIndex(content, MARKER_BEGIN, 0)) {
2043
+ throw new Error(
2044
+ `ai-erd markers are out of order (${MARKER_END} appears before ${MARKER_BEGIN}). The file was left untouched \u2014 fix the markers by hand and run again.`
2045
+ );
2046
+ }
2047
+ }
2048
+ function ownedMarkerCount(content, marker) {
2049
+ let count = 0;
2050
+ for (let at = ownedMarkerIndex(content, marker, 0); at >= 0; at = ownedMarkerIndex(content, marker, at + 1)) {
2051
+ count += 1;
2052
+ }
2053
+ return count;
2054
+ }
2055
+ function isInsideFencedBlock(content, index) {
2056
+ let open;
2057
+ let offset = 0;
2058
+ for (const line of content.split("\n")) {
2059
+ const nextOffset = offset + line.length + 1;
2060
+ if (index < nextOffset) {
2061
+ return open !== void 0;
2062
+ }
2063
+ const fence = fenceOf(line);
2064
+ if (fence) {
2065
+ if (!open) {
2066
+ open = { char: fence.char, length: fence.length };
2067
+ } else if (fence.char === open.char && fence.length >= open.length && fence.info === "") {
2068
+ open = void 0;
2069
+ }
1781
2070
  }
1782
- const toolArgs = rest.slice(1);
1783
- const payloadOptions = parsePayloadArgs(toolArgs);
1784
- const payload = await parsePayloadSource({
1785
- file: payloadOptions.file,
1786
- inline: payloadOptions.inline,
1787
- stdin: io.stdin
1788
- });
1789
- const result = await client.toolsCall(toolName, payload.value);
1790
- io.stdout.write(renderSuccess(result, { json: parsed.json, warnings: payload.warnings }));
1791
- return 0;
2071
+ offset = nextOffset;
1792
2072
  }
1793
- if (action === "catalog") {
1794
- const catalogEnv = parsed.env ?? "prod";
1795
- io.stdout.write(renderSuccess(getGeneratedTools(parsed.env ?? "prod"), {
1796
- json: parsed.json,
1797
- warnings: [catalogEnv === "dev" ? "Local dev catalog is a checked-in live sync snapshot; rerun sync:tools --env dev to refresh it." : "Local prod catalog is seed fixture only until authenticated prod live sync updates generated catalogs."]
1798
- }));
1799
- return 0;
2073
+ return open !== void 0;
2074
+ }
2075
+ function fenceOf(line) {
2076
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
2077
+ if (!match) {
2078
+ return void 0;
1800
2079
  }
1801
- throw new Error("Expected tools list, tools call <mcpName>, or tools catalog.");
2080
+ const run = match[1];
2081
+ const info = match[2].trim();
2082
+ if (run.startsWith("`") && info.includes("`")) {
2083
+ return void 0;
2084
+ }
2085
+ return { char: run[0], length: run.length, info };
1802
2086
  }
1803
- function parseGlobalArgs(argv) {
1804
- const result = { json: false, help: false, positionals: [] };
1805
- for (let index = 0; index < argv.length; index += 1) {
1806
- const arg = argv[index];
1807
- if (!arg) {
2087
+ function removeMarkerBlock(existing) {
2088
+ const current = existing ?? "";
2089
+ assertNoDanglingMarker(current);
2090
+ const range = markerRange(current);
2091
+ if (!range) {
2092
+ return { content: current, removed: false, emptied: !current.trim() };
2093
+ }
2094
+ const before = current.slice(0, range.start).replace(/\n*$/, "");
2095
+ const after = current.slice(range.end).replace(/^\n*/, "");
2096
+ const stripped = before && after ? `${before}
2097
+
2098
+ ${after}` : before ? `${before}
2099
+ ` : after;
2100
+ return { content: stripped.trim() ? stripped : "", removed: true, emptied: !stripped.trim() };
2101
+ }
2102
+ function replaceMarkerBlockWith(content, block) {
2103
+ const range = markerRange(content);
2104
+ return range ? `${content.slice(0, range.start)}${block}${content.slice(range.end)}` : content;
2105
+ }
2106
+ var UNPAIRED_BEGIN = "(unpaired ai-erd begin)";
2107
+ var UNPAIRED_END = "(unpaired ai-erd end)";
2108
+ function markerEvidence(content) {
2109
+ if (!content) {
2110
+ return [];
2111
+ }
2112
+ const owned = markerRange(content);
2113
+ const rest = owned === void 0 ? content : content.slice(0, owned.start) + content.slice(owned.end);
2114
+ const evidence = [];
2115
+ let cursor = 0;
2116
+ let begins = 0;
2117
+ let ends = 0;
2118
+ while (true) {
2119
+ const begin = rest.indexOf(MARKER_BEGIN, cursor);
2120
+ const close = rest.indexOf(MARKER_END, cursor);
2121
+ if (begin < 0 && close < 0) {
2122
+ break;
2123
+ }
2124
+ if (begin < 0 || close >= 0 && close < begin) {
2125
+ ends += 1;
2126
+ cursor = close + MARKER_END.length;
1808
2127
  continue;
1809
2128
  }
1810
- if (arg === "--env") {
1811
- result.env = assertEnv(requireValue3(arg, argv[++index]));
1812
- } else if (arg === "--endpoint") {
1813
- result.endpoint = requireValue3(arg, argv[++index]);
1814
- } else if (arg === "--json") {
1815
- result.json = true;
1816
- } else if (arg === "--help" || arg === "-h") {
1817
- result.help = true;
1818
- } else {
1819
- result.positionals.push(arg);
2129
+ const pairEnd = rest.indexOf(MARKER_END, begin + MARKER_BEGIN.length);
2130
+ if (pairEnd < 0) {
2131
+ begins += 1;
2132
+ cursor = begin + MARKER_BEGIN.length;
2133
+ continue;
1820
2134
  }
2135
+ evidence.push(blockShape(rest.slice(begin, pairEnd + MARKER_END.length)));
2136
+ cursor = pairEnd + MARKER_END.length;
1821
2137
  }
1822
- return result;
2138
+ for (let i = 0; i < begins; i += 1) evidence.push(UNPAIRED_BEGIN);
2139
+ for (let i = 0; i < ends; i += 1) evidence.push(UNPAIRED_END);
2140
+ return evidence;
1823
2141
  }
1824
- function parsePayloadArgs(argv) {
1825
- const result = {};
1826
- const inline = [];
1827
- for (let index = 0; index < argv.length; index += 1) {
1828
- const arg = argv[index];
1829
- if (!arg) {
2142
+ function blockShape(block) {
2143
+ return block.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
2144
+ }
2145
+ function existingMarkerBlock(content) {
2146
+ if (!content) {
2147
+ return void 0;
2148
+ }
2149
+ const range = markerRange(content);
2150
+ return range ? content.slice(range.start, range.end) : void 0;
2151
+ }
2152
+ function markerRange(content) {
2153
+ const start = ownedMarkerIndex(content, MARKER_BEGIN, 0);
2154
+ if (start < 0) {
2155
+ return void 0;
2156
+ }
2157
+ const endMarker = ownedMarkerIndex(content, MARKER_END, start);
2158
+ if (endMarker < 0) {
2159
+ return void 0;
2160
+ }
2161
+ return { start, end: endMarker + MARKER_END.length };
2162
+ }
2163
+ function ownedMarkerIndex(content, marker, from) {
2164
+ for (let at = content.indexOf(marker, from); at >= 0; at = content.indexOf(marker, at + 1)) {
2165
+ const lineStart = content.lastIndexOf("\n", at - 1) + 1;
2166
+ if (lineStart !== at) {
1830
2167
  continue;
1831
2168
  }
1832
- if (arg === "--file" || arg === "-f") {
1833
- result.file = requireValue3(arg, argv[++index]);
1834
- } else {
1835
- inline.push(arg);
2169
+ const lineEnd = content.indexOf("\n", at);
2170
+ const rest = (lineEnd < 0 ? content.slice(at) : content.slice(at, lineEnd)).slice(marker.length);
2171
+ if (rest.trim() !== "") {
2172
+ continue;
2173
+ }
2174
+ if (isInsideFencedBlock(content, at)) {
2175
+ continue;
1836
2176
  }
2177
+ return at;
1837
2178
  }
1838
- if (inline.length > 0) {
1839
- result.inline = inline.join(" ");
2179
+ return -1;
2180
+ }
2181
+
2182
+ // src/core/harness/initRecord.ts
2183
+ var RECORD_KIND = "ai-erd/init-record";
2184
+ var RECORD_VERSION = 2;
2185
+ var ABSENT = { at: "absent" };
2186
+ function emptyRecord() {
2187
+ return { version: RECORD_VERSION, files: {}, createdDirectories: [] };
2188
+ }
2189
+ function readRecord(content) {
2190
+ if (content === void 0) {
2191
+ return { state: "absent" };
1840
2192
  }
1841
- return result;
2193
+ let parsed;
2194
+ try {
2195
+ parsed = JSON.parse(content);
2196
+ } catch {
2197
+ return { state: "corrupt", reason: `${HARNESS_RECORD_PATH} is not valid JSON` };
2198
+ }
2199
+ if (!isObject(parsed)) {
2200
+ return { state: "corrupt", reason: `${HARNESS_RECORD_PATH} is not a JSON object` };
2201
+ }
2202
+ if (parsed.kind !== RECORD_KIND) {
2203
+ return {
2204
+ state: "corrupt",
2205
+ reason: `${HARNESS_RECORD_PATH} exists but is not an ai-erd record (kind=${JSON.stringify(parsed.kind)})`
2206
+ };
2207
+ }
2208
+ if (parsed.version !== RECORD_VERSION) {
2209
+ return {
2210
+ state: "legacy",
2211
+ reason: `${HARNESS_RECORD_PATH} was written by another version of this CLI (record version ${JSON.stringify(parsed.version)}, this CLI reads ${RECORD_VERSION})`
2212
+ };
2213
+ }
2214
+ const files = parsed.files;
2215
+ if (!isObject(files)) {
2216
+ return { state: "corrupt", reason: `${HARNESS_RECORD_PATH} has no "files" object` };
2217
+ }
2218
+ const out = {};
2219
+ for (const [path, raw] of Object.entries(files)) {
2220
+ const read = readContract(raw);
2221
+ if ("problem" in read) {
2222
+ return {
2223
+ state: "corrupt",
2224
+ reason: `${HARNESS_RECORD_PATH} has an unusable entry for ${path} \u2014 it ${read.problem}`
2225
+ };
2226
+ }
2227
+ out[path] = read.contract;
2228
+ }
2229
+ const directories = parsed.createdDirectories;
2230
+ if (directories !== void 0 && !isStringArray(directories)) {
2231
+ return { state: "corrupt", reason: `${HARNESS_RECORD_PATH} has a malformed createdDirectories` };
2232
+ }
2233
+ return {
2234
+ state: "valid",
2235
+ record: {
2236
+ version: RECORD_VERSION,
2237
+ files: out,
2238
+ createdDirectories: directories === void 0 ? [] : [...directories]
2239
+ }
2240
+ };
1842
2241
  }
1843
- function requireValue3(flag, value) {
1844
- if (!value || value.startsWith("--")) {
1845
- throw new Error(`${flag} requires a value.`);
2242
+ var LEGACY_MANAGED_KEYS = [
2243
+ "created",
2244
+ "originals",
2245
+ "lastWritten",
2246
+ "lastWrittenFragment",
2247
+ "lastWrittenBody",
2248
+ "replacedEntries",
2249
+ "replacedBlocks",
2250
+ "blockAdded",
2251
+ "foreignMarkers"
2252
+ ];
2253
+ function legacyManagedRecord(configContent) {
2254
+ if (configContent === void 0) {
2255
+ return false;
1846
2256
  }
1847
- return value;
2257
+ let parsed;
2258
+ try {
2259
+ parsed = JSON.parse(configContent);
2260
+ } catch {
2261
+ return false;
2262
+ }
2263
+ if (!isObject(parsed) || !isObject(parsed.managed)) {
2264
+ return false;
2265
+ }
2266
+ const managed = parsed.managed;
2267
+ const hasLegacyShape = LEGACY_MANAGED_KEYS.some(
2268
+ (key) => Object.prototype.hasOwnProperty.call(managed, key)
2269
+ );
2270
+ if (!hasLegacyShape) {
2271
+ return false;
2272
+ }
2273
+ if (typeof parsed.generatedBy === "string") {
2274
+ return true;
2275
+ }
2276
+ const lastWritten = managed.lastWritten;
2277
+ return isObject(lastWritten) && Object.prototype.hasOwnProperty.call(lastWritten, HARNESS_CONFIG_PATH);
1848
2278
  }
1849
- function helpText() {
1850
- return [
1851
- "Ainecto CLI",
1852
- "",
1853
- "Usage:",
1854
- " ainecto auth login|status|logout [--env prod|dev] [--endpoint URL] [--json]",
1855
- " ainecto tools list [--env prod|dev] [--endpoint URL] [--json]",
1856
- " ainecto tools call <mcpName> [-f payload.json|@-] [inline-json] [--json]",
1857
- " ainecto attachments upload --document-uuid <uuid> <file...> [--json]",
1858
- " ainecto <generated-command> [flags] [-f payload.json] [--yes] [--json]",
1859
- " ainecto mcp [--env prod|dev] [--endpoint URL]",
2279
+ function legacyManagedNotice(configPath) {
2280
+ return `${configPath} holds an init record written by an older version of this CLI (a "managed" field). This version keeps its record in ${HARNESS_RECORD_PATH} and cannot read that one, so your saved originals are still in there and nothing was changed. Undo with the version that wrote it (for example: npx @ai-erd/mcp@0.2 ai-erd init --undo), then run this version again.`;
2281
+ }
2282
+ function serializeRecord(record) {
2283
+ return `${JSON.stringify({
2284
+ kind: RECORD_KIND,
2285
+ version: RECORD_VERSION,
2286
+ files: record.files,
2287
+ createdDirectories: record.createdDirectories
2288
+ }, null, 2)}
2289
+ `;
2290
+ }
2291
+ function readContract(raw) {
2292
+ if (!isObject(raw)) {
2293
+ return { problem: "is not a JSON object" };
2294
+ }
2295
+ const scope = raw.scope;
2296
+ if (scope !== "whole" && scope !== "entry" && scope !== "block" && scope !== "fields") {
2297
+ return { problem: `has an unknown scope (${JSON.stringify(raw.scope)})` };
2298
+ }
2299
+ if (typeof raw.createdByUs !== "boolean") {
2300
+ return { problem: "does not say whether ai-erd created the file" };
2301
+ }
2302
+ const original = readOwned(raw.original);
2303
+ const written = readOwned(raw.written);
2304
+ if (original === void 0) {
2305
+ return { problem: "has no readable record of what was there before" };
2306
+ }
2307
+ if (written === void 0) {
2308
+ return { problem: "has no readable record of what ai-erd wrote" };
2309
+ }
2310
+ if (raw.fileFingerprint !== void 0 && typeof raw.fileFingerprint !== "string") {
2311
+ return { problem: "has a malformed fileFingerprint" };
2312
+ }
2313
+ if (raw.writtenFields !== void 0 && !isObject(raw.writtenFields)) {
2314
+ return { problem: "has a malformed writtenFields" };
2315
+ }
2316
+ let originalFields;
2317
+ if (raw.originalFields !== void 0) {
2318
+ if (!isObject(raw.originalFields)) {
2319
+ return { problem: "has a malformed originalFields" };
2320
+ }
2321
+ originalFields = {};
2322
+ for (const [field, value] of Object.entries(raw.originalFields)) {
2323
+ const owned = readOwned(value);
2324
+ if (owned === void 0) {
2325
+ return { problem: `has no readable original for the field ${field}` };
2326
+ }
2327
+ originalFields[field] = owned;
2328
+ }
2329
+ }
2330
+ if (raw.foreignBlocks !== void 0 && !isStringArray(raw.foreignBlocks)) {
2331
+ return { problem: "has a malformed foreignBlocks" };
2332
+ }
2333
+ if (scope === "block" && raw.foreignBlocks === void 0) {
2334
+ return { problem: "owns a marker block but does not say what marker blocks were already in the file" };
2335
+ }
2336
+ if (scope === "fields") {
2337
+ const problem = fieldsContractProblem({
2338
+ written,
2339
+ writtenFields: isObject(raw.writtenFields) ? raw.writtenFields : void 0,
2340
+ originalFields
2341
+ });
2342
+ if (problem !== void 0) {
2343
+ return { problem };
2344
+ }
2345
+ }
2346
+ const contract = {
2347
+ scope,
2348
+ createdByUs: raw.createdByUs,
2349
+ original,
2350
+ written
2351
+ };
2352
+ if (typeof raw.fileFingerprint === "string") contract.fileFingerprint = raw.fileFingerprint;
2353
+ if (isObject(raw.writtenFields)) contract.writtenFields = { ...raw.writtenFields };
2354
+ if (originalFields !== void 0) contract.originalFields = originalFields;
2355
+ if (isStringArray(raw.foreignBlocks)) contract.foreignBlocks = [...raw.foreignBlocks];
2356
+ return { contract };
2357
+ }
2358
+ function fieldsContractProblem(contract, ownedFields) {
2359
+ const { written, writtenFields, originalFields } = contract;
2360
+ if (writtenFields === void 0) {
2361
+ return "owns only some fields of the file but does not say which ones it wrote (writtenFields)";
2362
+ }
2363
+ if (originalFields === void 0) {
2364
+ return "owns only some fields of the file but does not say what was in them before (originalFields)";
2365
+ }
2366
+ const writtenKeys = Object.keys(writtenFields).sort();
2367
+ const originalKeys = Object.keys(originalFields).sort();
2368
+ if (writtenKeys.length !== originalKeys.length || writtenKeys.some((key, at) => key !== originalKeys[at])) {
2369
+ return `lists different fields as written (${writtenKeys.join(", ") || "none"}) and as backed up (${originalKeys.join(", ") || "none"})`;
2370
+ }
2371
+ for (const key of writtenKeys) {
2372
+ const original = originalFields[key];
2373
+ if (original.at !== "absent" && original.at !== "json") {
2374
+ return `records the original of ${key} as ${original.at}, which is not something --undo can put back into a JSON field`;
2375
+ }
2376
+ }
2377
+ if (written.at !== "text") {
2378
+ return "owns only some fields of the file but does not hold the file text it wrote";
2379
+ }
2380
+ let body;
2381
+ try {
2382
+ body = JSON.parse(written.text);
2383
+ } catch {
2384
+ return "holds a written file body that is not valid JSON";
2385
+ }
2386
+ if (!isObject(body)) {
2387
+ return "holds a written file body that is not a JSON object";
2388
+ }
2389
+ for (const key of writtenKeys) {
2390
+ if (!Object.prototype.hasOwnProperty.call(body, key)) {
2391
+ return `says it wrote ${key}, but that field is not in the file body it recorded`;
2392
+ }
2393
+ if (stableStringify(body[key]) !== stableStringify(writtenFields[key])) {
2394
+ return `records a different value for ${key} than the file body it wrote`;
2395
+ }
2396
+ }
2397
+ if (ownedFields !== void 0) {
2398
+ for (const key of ownedFields) {
2399
+ if (Object.prototype.hasOwnProperty.call(body, key) && !Object.prototype.hasOwnProperty.call(writtenFields, key)) {
2400
+ return `wrote ${key} into the file but does not record what it put there`;
2401
+ }
2402
+ }
2403
+ }
2404
+ return void 0;
2405
+ }
2406
+ function readOwned(raw) {
2407
+ if (!isObject(raw)) {
2408
+ return void 0;
2409
+ }
2410
+ if (raw.at === "absent") {
2411
+ return ABSENT;
2412
+ }
2413
+ if (raw.at === "json") {
2414
+ return Object.prototype.hasOwnProperty.call(raw, "value") ? { at: "json", value: raw.value } : void 0;
2415
+ }
2416
+ if (raw.at === "text") {
2417
+ return typeof raw.text === "string" ? { at: "text", text: raw.text } : void 0;
2418
+ }
2419
+ return void 0;
2420
+ }
2421
+ function stableStringify(value) {
2422
+ if (Array.isArray(value)) {
2423
+ return `[${value.map(stableStringify).join(",")}]`;
2424
+ }
2425
+ if (value && typeof value === "object") {
2426
+ const entries = Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
2427
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
2428
+ }
2429
+ return JSON.stringify(value) ?? "null";
2430
+ }
2431
+ function isObject(value) {
2432
+ return !!value && typeof value === "object" && !Array.isArray(value);
2433
+ }
2434
+ function isStringArray(value) {
2435
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
2436
+ }
2437
+
2438
+ // src/core/harness/initPlan.ts
2439
+ import { createHash as createHash3 } from "crypto";
2440
+ var NOTE_TARGETS = [
2441
+ { path: "AGENTS.md", createIfMissing: true },
2442
+ { path: "CLAUDE.md", createIfMissing: false }
2443
+ ];
2444
+ var MANAGED_PATHS = [
2445
+ ...AGENT_TARGETS.map((target) => target.path),
2446
+ ...NOTE_TARGETS.map((target) => target.path),
2447
+ HARNESS_DOC_PATH,
2448
+ HARNESS_CONFIG_PATH,
2449
+ HARNESS_RECORD_PATH
2450
+ ];
2451
+ function ownedJson(value, present) {
2452
+ return present ? { at: "json", value } : ABSENT;
2453
+ }
2454
+ function ownedText(text) {
2455
+ return text === void 0 ? ABSENT : { at: "text", text };
2456
+ }
2457
+ function sameOwned(left, right) {
2458
+ if (left.at !== right.at) {
2459
+ return false;
2460
+ }
2461
+ if (left.at === "absent") {
2462
+ return true;
2463
+ }
2464
+ if (left.at === "text" && right.at === "text") {
2465
+ return left.text === right.text;
2466
+ }
2467
+ return left.at === "json" && right.at === "json" && stableStringify(left.value) === stableStringify(right.value);
2468
+ }
2469
+ function contentFingerprint(content) {
2470
+ return createHash3("sha256").update(content).digest("hex");
2471
+ }
2472
+ function serialize(body) {
2473
+ return `${JSON.stringify(body, null, 2)}
2474
+ `;
2475
+ }
2476
+ function fingerprintFor(path, content) {
2477
+ if (!path.endsWith(".json")) {
2478
+ return contentFingerprint(content);
2479
+ }
2480
+ try {
2481
+ return contentFingerprint(stableStringify(JSON.parse(content)));
2482
+ } catch {
2483
+ return contentFingerprint(content);
2484
+ }
2485
+ }
2486
+ function parseJsonObject(content) {
2487
+ try {
2488
+ const parsed = JSON.parse(content);
2489
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
2490
+ } catch {
2491
+ return void 0;
2492
+ }
2493
+ }
2494
+ function configBodyOf(values) {
2495
+ return {
2496
+ version: 1,
2497
+ project: { uuid: values.projectUuid, name: values.projectName },
2498
+ endpoint: values.endpoint,
2499
+ generatedBy: values.cliVersion,
2500
+ promptVersions: values.versions
2501
+ };
2502
+ }
2503
+ var OUR_CONFIG_FIELDS = Object.keys(configBodyOf({
2504
+ projectUuid: "",
2505
+ projectName: "",
2506
+ endpoint: "",
2507
+ cliVersion: "",
2508
+ versions: {}
2509
+ }));
2510
+ var hasField = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
2511
+ function initRefusal(files, previous) {
2512
+ const state = previous ?? readRecord(files.get(HARNESS_RECORD_PATH));
2513
+ if (state.state === "corrupt" || state.state === "legacy") {
2514
+ return `${state.reason}. Nothing was changed \u2014 move that file aside (or restore it) and run again.`;
2515
+ }
2516
+ if (state.state === "absent" && legacyManagedRecord(files.get(HARNESS_CONFIG_PATH))) {
2517
+ return legacyManagedNotice(HARNESS_CONFIG_PATH);
2518
+ }
2519
+ const carried = state.state === "valid" ? state.record : emptyRecord();
2520
+ for (const [path, contract] of Object.entries(carried.files)) {
2521
+ if (contract.scope !== "fields") {
2522
+ continue;
2523
+ }
2524
+ const problem = fieldsContractProblem(contract, OUR_CONFIG_FIELDS);
2525
+ if (problem !== void 0) {
2526
+ return `${HARNESS_RECORD_PATH}'s record for ${path} ${problem}. Nothing was changed \u2014 restore that file (or move it aside) and run again.`;
2527
+ }
2528
+ }
2529
+ return void 0;
2530
+ }
2531
+ function planInit(input) {
2532
+ const previous = input.previous ?? readRecord(input.files.get(HARNESS_RECORD_PATH));
2533
+ const refusal = initRefusal(input.files, previous);
2534
+ if (refusal !== void 0) {
2535
+ return {
2536
+ writes: [],
2537
+ deletes: [],
2538
+ notes: [],
2539
+ codexCommand: codexAddCommand({ role: input.role, env: input.env, endpoint: input.endpoint }),
2540
+ refusal
2541
+ };
2542
+ }
2543
+ const carriedRecord = previous.state === "valid" ? previous.record : emptyRecord();
2544
+ const entry = buildServerEntry({ role: input.role, env: input.env, endpoint: input.endpoint });
2545
+ const writes = [];
2546
+ const notes = [];
2547
+ const record = {
2548
+ version: carriedRecord.version,
2549
+ files: { ...carriedRecord.files },
2550
+ createdDirectories: [...carriedRecord.createdDirectories]
2551
+ };
2552
+ const priorOf = (path, current) => {
2553
+ const prior = record.files[path];
2554
+ if (prior === void 0) {
2555
+ return void 0;
2556
+ }
2557
+ if (sameOwned(prior.written, current) || sameOwned(prior.original, current)) {
2558
+ return prior;
2559
+ }
2560
+ delete record.files[path];
2561
+ notes.push(`${path}: it changed outside ai-erd, so the older backup was dropped and this is the new baseline.`);
2562
+ return void 0;
2563
+ };
2564
+ for (const target of AGENT_TARGETS) {
2565
+ const existing = input.files.get(target.path);
2566
+ const currentEntry = existing === void 0 ? void 0 : readServerEntry(existing);
2567
+ const prior = priorOf(target.path, ownedJson(currentEntry, currentEntry !== void 0));
2568
+ const merged = mergeServerEntry(existing, entry);
2569
+ if (existing === merged.content) {
2570
+ continue;
2571
+ }
2572
+ const before = previousRole(existing);
2573
+ if (merged.replaced && before && before !== input.role) {
2574
+ notes.push(`${target.path}: role ${before} \u2192 ${input.role}`);
2575
+ }
2576
+ const original = prior?.original ?? ownedJson(merged.replacedEntry, merged.replaced);
2577
+ if (prior === void 0 && original.at !== "absent") {
2578
+ notes.push(`${target.path}: your existing "${SERVER_NAME}" entry was saved and will be restored by --undo`);
2579
+ }
2580
+ record.files[target.path] = {
2581
+ scope: "entry",
2582
+ createdByUs: prior?.createdByUs ?? existing === void 0,
2583
+ original,
2584
+ written: ownedJson(readServerEntry(merged.content), true),
2585
+ fileFingerprint: fingerprintFor(target.path, merged.content)
2586
+ };
2587
+ writes.push({ path: target.path, content: merged.content, existed: existing !== void 0 });
2588
+ }
2589
+ const existingDoc = input.files.get(HARNESS_DOC_PATH);
2590
+ const priorDoc = priorOf(HARNESS_DOC_PATH, ownedText(existingDoc));
2591
+ const docContent = renderHarnessDoc({
2592
+ projectName: input.projectName,
2593
+ projectUuid: input.projectUuid,
2594
+ endpoint: input.endpoint
2595
+ }, input.documents?.doc);
2596
+ const docOriginal = priorDoc?.original ?? ownedText(existingDoc);
2597
+ if (priorDoc === void 0 && docOriginal.at !== "absent") {
2598
+ notes.push(`${HARNESS_DOC_PATH} already existed; its original was saved and --undo will restore it.`);
2599
+ }
2600
+ record.files[HARNESS_DOC_PATH] = {
2601
+ scope: "whole",
2602
+ createdByUs: priorDoc?.createdByUs ?? existingDoc === void 0,
2603
+ original: docOriginal,
2604
+ written: ownedText(docContent),
2605
+ fileFingerprint: fingerprintFor(HARNESS_DOC_PATH, docContent)
2606
+ };
2607
+ if (docContent !== existingDoc) {
2608
+ writes.push({ path: HARNESS_DOC_PATH, content: docContent, existed: existingDoc !== void 0 });
2609
+ }
2610
+ const note = renderAgentNote(input.documents?.agentNote);
2611
+ for (const target of NOTE_TARGETS) {
2612
+ const existing = input.files.get(target.path);
2613
+ if (existing === void 0 && !target.createIfMissing) {
2614
+ notes.push(`${target.path} not found \u2014 skipped; AGENTS.md already reaches this agent.`);
2615
+ continue;
2616
+ }
2617
+ const blockNow = existing === void 0 ? void 0 : existingMarkerBlock(existing);
2618
+ const carried = record.files[target.path];
2619
+ let content;
2620
+ try {
2621
+ content = upsertMarkerBlock(existing, note);
2622
+ } catch (error) {
2623
+ notes.push(`${target.path}: ${error instanceof Error ? error.message : String(error)}`);
2624
+ continue;
2625
+ }
2626
+ if (carried?.scope === "block" && unexplainedMarkers(existing, carried) > 0) {
2627
+ notes.push(
2628
+ `${target.path}: this file has an ai-erd marker block that is not where ai-erd can find it again (inside a code fence, a quote, or edited). Left untouched and the earlier backup kept \u2014 move that block back to the top level, or remove it, then run again.`
2629
+ );
2630
+ continue;
2631
+ }
2632
+ const prior = blockNow === void 0 ? carried : priorOf(target.path, ownedText(blockNow));
2633
+ if (content === existing) {
2634
+ continue;
2635
+ }
2636
+ const original = prior?.original ?? ownedText(blockNow);
2637
+ if (prior === void 0 && original.at !== "absent") {
2638
+ notes.push(`${target.path}: an existing ai-erd block was saved and will be restored by --undo`);
2639
+ }
2640
+ record.files[target.path] = {
2641
+ scope: "block",
2642
+ createdByUs: prior?.createdByUs ?? existing === void 0,
2643
+ original,
2644
+ written: ownedText(existingMarkerBlock(content)),
2645
+ fileFingerprint: fingerprintFor(target.path, content),
2646
+ // init «전»에 이 파일에 있던 관리 블록들의 신원. 되돌리기가 「남은 마커를 설명할 수 있나」를
2647
+ // 이것으로 묻는다. ★재init 은 이어받는다 — 다시 재면 우리가 쓴 블록이 «원래 있던 것»이 된다.
2648
+ // ★{@code carried} 를 먼저 본다 — {@code priorOf} 가 새 기준을 세웠다는 것이 「남의 예제
2649
+ // 목록을 다시 모아도 된다」는 허가가 아니다. 다시 모으면 «우리가 옮겨 둔 사본»이
2650
+ // 「원래 있던 예제」로 등록되고, 그다음 되돌리기가 그것을 남긴 채 끝났다(3차 리뷰 B2).
2651
+ foreignBlocks: carried?.foreignBlocks ?? prior?.foreignBlocks ?? markerEvidence(existing)
2652
+ };
2653
+ writes.push({ path: target.path, content, existed: existing !== void 0 });
2654
+ }
2655
+ const existingConfig = input.files.get(HARNESS_CONFIG_PATH);
2656
+ const ourFields = configBodyOf({
2657
+ projectUuid: input.projectUuid,
2658
+ projectName: input.projectName,
2659
+ endpoint: input.endpoint,
2660
+ cliVersion: input.cliVersion,
2661
+ // ★어느 판의 규칙으로 썼나. 이게 없으면 「무엇이 적혀 있었나」를 나중에 못 되짚는다.
2662
+ versions: input.documents?.versions ?? {}
2663
+ });
2664
+ const theirConfig = existingConfig === void 0 ? void 0 : parseJsonObject(existingConfig);
2665
+ if (existingConfig !== void 0 && theirConfig === void 0) {
2666
+ notes.push(`${HARNESS_CONFIG_PATH} is not a JSON object \u2014 left untouched.`);
2667
+ } else {
2668
+ const priorConfig = record.files[HARNESS_CONFIG_PATH];
2669
+ const originalFields = {};
2670
+ for (const field of OUR_CONFIG_FIELDS) {
2671
+ const presentNow = theirConfig !== void 0 && hasField(theirConfig, field);
2672
+ const currentValue = presentNow ? theirConfig[field] : void 0;
2673
+ const priorOriginal = priorConfig?.originalFields?.[field];
2674
+ const priorWritten = priorConfig?.writtenFields;
2675
+ const untouchedByUser = priorWritten !== void 0 && hasField(priorWritten, field) && stableStringify(currentValue) === stableStringify(priorWritten[field]);
2676
+ const next = untouchedByUser && priorOriginal !== void 0 ? priorOriginal : ownedJson(currentValue, presentNow);
2677
+ originalFields[field] = next;
2678
+ if (priorOriginal !== void 0 && !sameOwned(next, priorOriginal)) {
2679
+ notes.push(`${HARNESS_CONFIG_PATH}: you changed ${field} \u2014 ai-erd now treats your value as the one to put back.`);
2680
+ }
2681
+ }
2682
+ const mergedConfig = serialize({ ...theirConfig ?? {}, ...ourFields });
2683
+ record.files[HARNESS_CONFIG_PATH] = {
2684
+ scope: "fields",
2685
+ createdByUs: priorConfig?.createdByUs ?? existingConfig === void 0,
2686
+ original: ABSENT,
2687
+ written: ownedText(mergedConfig),
2688
+ fileFingerprint: fingerprintFor(HARNESS_CONFIG_PATH, mergedConfig),
2689
+ writtenFields: { ...ourFields },
2690
+ originalFields
2691
+ };
2692
+ if (mergedConfig !== existingConfig) {
2693
+ writes.push({
2694
+ path: HARNESS_CONFIG_PATH,
2695
+ content: mergedConfig,
2696
+ existed: existingConfig !== void 0
2697
+ });
2698
+ }
2699
+ }
2700
+ const existingRecord = input.files.get(HARNESS_RECORD_PATH);
2701
+ const recordContent = serializeRecord(record);
2702
+ if (recordContent !== existingRecord) {
2703
+ writes.push({
2704
+ path: HARNESS_RECORD_PATH,
2705
+ content: recordContent,
2706
+ existed: existingRecord !== void 0
2707
+ });
2708
+ }
2709
+ return {
2710
+ writes,
2711
+ deletes: [],
2712
+ codexCommand: codexAddCommand({ role: input.role, env: input.env, endpoint: input.endpoint }),
2713
+ codexProfile: codexProfileSetup({ role: input.role, env: input.env, endpoint: input.endpoint }),
2714
+ notes,
2715
+ record
2716
+ };
2717
+ }
2718
+ function planUndo(input) {
2719
+ const state = input.record ?? readRecord(input.files.get(HARNESS_RECORD_PATH));
2720
+ const writes = [];
2721
+ const deletes = [];
2722
+ const notes = [];
2723
+ const tail = () => {
2724
+ notes.push(`Codex keeps its own global config \u2014 remove it with: ${codexRemoveCommand()}`);
2725
+ notes.push(`If you set ${ROLE_ENV_VAR} anywhere, unset it too.`);
2726
+ };
2727
+ if (state.state === "absent") {
2728
+ notes.push(legacyManagedRecord(input.files.get(HARNESS_CONFIG_PATH)) ? legacyManagedNotice(HARNESS_CONFIG_PATH) : `No record of a previous init in ${HARNESS_RECORD_PATH} \u2014 no local file was changed.`);
2729
+ tail();
2730
+ return { writes, deletes, codexCommand: codexRemoveCommand(), notes };
2731
+ }
2732
+ if (state.state !== "valid") {
2733
+ notes.push(
2734
+ `${state.reason}. Nothing was changed \u2014 that file is kept because it may hold backups --undo still needs. Restore it, or finish the undo with the CLI version that wrote it.`
2735
+ );
2736
+ tail();
2737
+ return { writes, deletes, codexCommand: codexRemoveCommand(), notes };
2738
+ }
2739
+ const record = state.record;
2740
+ const remaining = { ...record.files };
2741
+ for (const [path, contract] of Object.entries(record.files)) {
2742
+ const outcome = undoOne(path, contract, input.files.get(path));
2743
+ if (outcome.kind !== "unresolved" && outcome.kind !== "keep" && outcome.note !== void 0) {
2744
+ notes.push(outcome.note);
2745
+ } else if (outcome.kind === "unresolved" || outcome.kind === "keep") {
2746
+ notes.push(outcome.note);
2747
+ }
2748
+ if (outcome.kind === "unresolved") {
2749
+ continue;
2750
+ }
2751
+ delete remaining[path];
2752
+ if (outcome.kind === "write") {
2753
+ writes.push({ path, content: outcome.content, existed: true });
2754
+ } else if (outcome.kind === "delete") {
2755
+ deletes.push(path);
2756
+ }
2757
+ }
2758
+ tail();
2759
+ if (input.files.get(HARNESS_RECORD_PATH) !== void 0) {
2760
+ if (Object.keys(remaining).length > 0) {
2761
+ const reduced = { ...record, files: remaining };
2762
+ writes.push({ path: HARNESS_RECORD_PATH, content: serializeRecord(reduced), existed: true });
2763
+ notes.push(
2764
+ `${HARNESS_RECORD_PATH}: kept, with the finished items removed \u2014 it still holds the backups --undo needs. Run --undo again once the items above are resolved.`
2765
+ );
2766
+ } else {
2767
+ deletes.push(HARNESS_RECORD_PATH);
2768
+ }
2769
+ }
2770
+ const removableDirectories = parentsOf(deletes).filter((directory) => record.createdDirectories.includes(directory));
2771
+ return { writes, deletes, codexCommand: codexRemoveCommand(), notes, removableDirectories };
2772
+ }
2773
+ function unexplainedMarkers(existing, contract) {
2774
+ const recorded = [...contract.foreignBlocks ?? []];
2775
+ let unexplained = 0;
2776
+ for (const print of markerEvidence(existing)) {
2777
+ const at = recorded.indexOf(print);
2778
+ if (at >= 0) {
2779
+ recorded.splice(at, 1);
2780
+ } else {
2781
+ unexplained += 1;
2782
+ }
2783
+ }
2784
+ return unexplained;
2785
+ }
2786
+ function undoOne(path, contract, existing) {
2787
+ if (existing === void 0) {
2788
+ if (contract.original.at !== "absent" || contract.scope === "fields") {
2789
+ return {
2790
+ kind: "unresolved",
2791
+ note: `${path} is gone, so what was there before init could not be put back \u2014 the backup is kept. Restore the file and run --undo again.`
2792
+ };
2793
+ }
2794
+ return { kind: "done" };
2795
+ }
2796
+ switch (contract.scope) {
2797
+ case "entry":
2798
+ return undoEntry(path, contract, existing);
2799
+ case "block":
2800
+ return undoBlock(path, contract, existing);
2801
+ case "fields":
2802
+ return undoFields(path, contract, existing);
2803
+ case "whole":
2804
+ return undoWhole(path, contract, existing);
2805
+ }
2806
+ }
2807
+ function undoEntry(path, contract, existing) {
2808
+ const current = readServerEntry(existing);
2809
+ const currentOwned = ownedJson(current, current !== void 0);
2810
+ if (sameOwned(currentOwned, contract.original)) {
2811
+ return {
2812
+ kind: "done",
2813
+ note: contract.original.at === "absent" ? `${path}: the "${SERVER_NAME}" entry ai-erd added is already gone.` : `${path}: your original "${SERVER_NAME}" entry is already back in place.`
2814
+ };
2815
+ }
2816
+ const goneButWeHoldTheOriginal = current === void 0 && contract.original.at !== "absent";
2817
+ if (!goneButWeHoldTheOriginal && !sameOwned(currentOwned, contract.written)) {
2818
+ return {
2819
+ kind: "unresolved",
2820
+ note: `${path}: the "${SERVER_NAME}" entry was edited after init \u2014 left in place. Remove it by hand if you meant to, then run --undo again.`
2821
+ };
2822
+ }
2823
+ const restore = contract.original.at === "json" ? { value: contract.original.value } : void 0;
2824
+ const result = removeServerEntry(existing, restore);
2825
+ if (result.emptied && contract.createdByUs) {
2826
+ return { kind: "delete" };
2827
+ }
2828
+ if (result.content !== void 0 && result.content !== existing) {
2829
+ return {
2830
+ kind: "write",
2831
+ content: result.content,
2832
+ note: restore === void 0 ? void 0 : `${path}: restored your original "${SERVER_NAME}" entry.`
2833
+ };
2834
+ }
2835
+ return { kind: "done" };
2836
+ }
2837
+ function undoBlock(path, contract, existing) {
2838
+ const current = existingMarkerBlock(existing);
2839
+ const currentOwned = ownedText(current);
2840
+ if (unexplainedMarkers(existing, contract) > 0) {
2841
+ return {
2842
+ kind: "unresolved",
2843
+ note: `${path}: this file has an ai-erd marker block that --undo cannot account for (inside a code fence, a quote, or edited). Remove it by hand, then run --undo again.`
2844
+ };
2845
+ }
2846
+ if (sameOwned(currentOwned, contract.original)) {
2847
+ return {
2848
+ kind: "done",
2849
+ note: contract.original.at === "absent" ? `${path}: the ai-erd block is already gone.` : `${path}: the ai-erd block that was there before is already back in place.`
2850
+ };
2851
+ }
2852
+ if (current === void 0 && contract.original.at === "text") {
2853
+ const body = existing.trim() ? `${existing.replace(/\s*$/, "")}
2854
+
2855
+ ${contract.original.text}
2856
+ ` : `${contract.original.text}
2857
+ `;
2858
+ return { kind: "write", content: body, note: `${path}: restored the ai-erd block that was there before.` };
2859
+ }
2860
+ if (!sameOwned(currentOwned, contract.written)) {
2861
+ return {
2862
+ kind: "unresolved",
2863
+ note: `${path}: the ai-erd block was edited after init \u2014 left in place. Remove the block by hand if you meant to, then run --undo again.`
2864
+ };
2865
+ }
2866
+ let result;
2867
+ try {
2868
+ result = contract.original.at === "text" ? { content: replaceMarkerBlockWith(existing, contract.original.text), removed: true, emptied: false } : removeMarkerBlock(existing);
2869
+ } catch (error) {
2870
+ return { kind: "unresolved", note: `${path}: ${error instanceof Error ? error.message : String(error)}` };
2871
+ }
2872
+ if (!result.removed) {
2873
+ return {
2874
+ kind: "unresolved",
2875
+ note: `${path}: the ai-erd block could not be removed \u2014 it is still in the file. Remove it by hand, then run --undo again.`
2876
+ };
2877
+ }
2878
+ if (result.emptied && contract.createdByUs) {
2879
+ return { kind: "delete" };
2880
+ }
2881
+ return {
2882
+ kind: "write",
2883
+ content: result.content,
2884
+ note: contract.original.at === "text" ? `${path}: restored the ai-erd block that was there before.` : void 0
2885
+ };
2886
+ }
2887
+ function undoWhole(path, contract, existing) {
2888
+ if (sameOwned(ownedText(existing), contract.original)) {
2889
+ return { kind: "done", note: `${path} is already the file that existed before init.` };
2890
+ }
2891
+ if (!sameOwned(ownedText(existing), contract.written)) {
2892
+ if (contract.original.at === "text") {
2893
+ return {
2894
+ kind: "unresolved",
2895
+ note: `${path} was edited after init \u2014 left in place instead of restoring the pre-init file. The backup is kept; run --undo again once you have resolved it.`
2896
+ };
2897
+ }
2898
+ return { kind: "keep", note: `${path} was edited after init \u2014 left in place instead of deleting it.` };
2899
+ }
2900
+ if (contract.original.at === "text") {
2901
+ return {
2902
+ kind: "write",
2903
+ content: contract.original.text,
2904
+ note: `${path}: restored the file that existed before init.`
2905
+ };
2906
+ }
2907
+ return contract.createdByUs ? { kind: "delete" } : { kind: "done" };
2908
+ }
2909
+ function undoFields(path, contract, existing) {
2910
+ const parsed = parseJsonObject(existing);
2911
+ if (parsed === void 0) {
2912
+ return {
2913
+ kind: "unresolved",
2914
+ note: `${path} is not a JSON object any more, so ai-erd could not take its fields back out \u2014 the backup is kept. Fix the file and run --undo again.`
2915
+ };
2916
+ }
2917
+ const contractProblem = fieldsContractProblem(contract, OUR_CONFIG_FIELDS);
2918
+ if (contractProblem !== void 0) {
2919
+ return {
2920
+ kind: "unresolved",
2921
+ note: `${path}: ai-erd's record of the fields it wrote ${contractProblem}, so its fields were left in place \u2014 the backup is kept. Restore that record, or remove the fields by hand.`
2922
+ };
2923
+ }
2924
+ const written = contract.writtenFields;
2925
+ const originals = contract.originalFields;
2926
+ const next = {};
2927
+ const kept = [];
2928
+ for (const [field, value] of Object.entries(parsed)) {
2929
+ if (!OUR_CONFIG_FIELDS.includes(field)) {
2930
+ next[field] = value;
2931
+ continue;
2932
+ }
2933
+ if (!hasField(written, field)) {
2934
+ return {
2935
+ kind: "unresolved",
2936
+ note: `${path}: ai-erd has no record of what it wrote for ${field}, so its fields were left in place \u2014 the backup is kept. Restore that record, or remove the field by hand.`
2937
+ };
2938
+ }
2939
+ const stillOurs = stableStringify(value) === stableStringify(written[field]);
2940
+ if (!stillOurs) {
2941
+ next[field] = value;
2942
+ kept.push(field);
2943
+ continue;
2944
+ }
2945
+ const original = originals[field];
2946
+ if (original === void 0 || original.at !== "json" && original.at !== "absent") {
2947
+ return {
2948
+ kind: "unresolved",
2949
+ note: `${path}: ai-erd cannot tell what was in ${field} before it wrote there, so the file was left as it is \u2014 the backup is kept. Remove the field by hand if you meant to.`
2950
+ };
2951
+ }
2952
+ if (original.at === "json") {
2953
+ next[field] = original.value;
2954
+ }
2955
+ }
2956
+ const note = kept.length === 0 ? void 0 : `${path}: you changed ${kept.join(", ")} after init \u2014 left as you set it instead of putting the pre-init value back.`;
2957
+ if (Object.keys(next).length === 0 && contract.createdByUs) {
2958
+ return { kind: "delete", note };
2959
+ }
2960
+ const body = serialize(next);
2961
+ return body === existing ? { kind: "done", note } : {
2962
+ kind: "write",
2963
+ content: body,
2964
+ note: note ?? `${path}: removed what ai-erd wrote and kept the rest.`
2965
+ };
2966
+ }
2967
+ function withCreatedDirectories(current, directories) {
2968
+ const state = readRecord(current);
2969
+ if (state.state !== "valid") {
2970
+ return current;
2971
+ }
2972
+ return serializeRecord({ ...state.record, createdDirectories: [...directories] });
2973
+ }
2974
+ function parentsOf(paths) {
2975
+ const directories = /* @__PURE__ */ new Set();
2976
+ for (const path of paths) {
2977
+ const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
2978
+ if (parent) {
2979
+ directories.add(parent);
2980
+ }
2981
+ }
2982
+ return [...directories].sort((left, right) => right.length - left.length);
2983
+ }
2984
+ function detectRoles(files) {
2985
+ const found = /* @__PURE__ */ new Map();
2986
+ for (const target of AGENT_TARGETS) {
2987
+ const existing = files.get(target.path);
2988
+ if (existing === void 0) {
2989
+ continue;
2990
+ }
2991
+ found.set(target.path, previousRole(existing));
2992
+ }
2993
+ return found;
2994
+ }
2995
+ function previousRole(existing) {
2996
+ if (existing === void 0) {
2997
+ return void 0;
2998
+ }
2999
+ try {
3000
+ const parsed = JSON.parse(existing);
3001
+ return readRoleFromEntry(parsed.mcpServers?.[SERVER_NAME]);
3002
+ } catch {
3003
+ return void 0;
3004
+ }
3005
+ }
3006
+
3007
+ // src/core/harness/documentFetch.ts
3008
+ var DOC_KEY2 = "HARNESS.DOC";
3009
+ var AGENT_NOTE_KEY2 = "HARNESS.AGENT_NOTE";
3010
+ async function fetchHarnessDocuments(input) {
3011
+ const url = new URL(`${input.endpoint.replace(/\/$/, "")}/harness/documents`);
3012
+ assertTrustedEndpointUrl(url, "harness documents URL");
3013
+ if (input.client) {
3014
+ url.searchParams.set("client", input.client);
3015
+ }
3016
+ const fetchImpl = input.fetchImpl ?? fetch;
3017
+ try {
3018
+ const response = await fetchImpl(url.toString(), {
3019
+ headers: {
3020
+ accept: "application/json",
3021
+ ...input.accessToken ? { authorization: `Bearer ${input.accessToken}` } : {}
3022
+ }
3023
+ });
3024
+ if (!response.ok) {
3025
+ return { versions: {}, fallbacks: {}, unavailableReason: `server answered HTTP ${response.status}` };
3026
+ }
3027
+ const payload = await response.json();
3028
+ const documents = payload.documents ?? {};
3029
+ const versions = {};
3030
+ const fallbacks = {};
3031
+ const bodies = {};
3032
+ for (const key of [DOC_KEY2, AGENT_NOTE_KEY2]) {
3033
+ const entry = documents[key];
3034
+ const problem = usableBodyProblem(key, entry);
3035
+ if (problem) {
3036
+ fallbacks[key] = typeof entry?.reason === "string" && entry.reason ? entry.reason : problem;
3037
+ continue;
3038
+ }
3039
+ bodies[key] = entry.body;
3040
+ if (typeof entry?.version === "string") {
3041
+ versions[key] = entry.version;
3042
+ }
3043
+ }
3044
+ return { doc: bodies[DOC_KEY2], agentNote: bodies[AGENT_NOTE_KEY2], versions, fallbacks };
3045
+ } catch (error) {
3046
+ return {
3047
+ versions: {},
3048
+ fallbacks: {},
3049
+ unavailableReason: error instanceof Error ? error.message : String(error)
3050
+ };
3051
+ }
3052
+ }
3053
+ function usableBodyProblem(key, entry) {
3054
+ if (typeof entry?.body !== "string" || !entry.body.trim()) {
3055
+ return `server returned no body for ${key}`;
3056
+ }
3057
+ return documentContractProblem(key, entry.body);
3058
+ }
3059
+
3060
+ // src/adapters/cli/initCommand.ts
3061
+ async function executeInitCommand(options) {
3062
+ const args = parseInitArgs(options.argv);
3063
+ const root = resolve(options.cwd);
3064
+ const files = await readSnapshot(root);
3065
+ if (args.undo) {
3066
+ const plan2 = planUndo({ files });
3067
+ await applyPlan(root, plan2, args.dryRun, files, false);
3068
+ options.io.stdout.write(renderSuccess(
3069
+ {
3070
+ action: args.dryRun ? "undo (dry-run)" : "undo",
3071
+ updated: plan2.writes.map((write) => write.path),
3072
+ removed: plan2.deletes,
3073
+ notes: plan2.notes
3074
+ },
3075
+ { json: options.json }
3076
+ ));
3077
+ return 0;
3078
+ }
3079
+ const role = options.role ?? currentRole(files);
3080
+ if (!role) {
3081
+ throw new Error(
3082
+ `--role is required on first run. Pick one of: ${HARNESS_ROLES.join(", ")}.`
3083
+ );
3084
+ }
3085
+ const refusal = initRefusal(files);
3086
+ if (refusal !== void 0) {
3087
+ throw new Error(refusal);
3088
+ }
3089
+ const project = await resolveProject(options, args, files, root);
3090
+ if ("choices" in project) {
3091
+ options.io.stdout.write(renderSuccess(
3092
+ {
3093
+ message: "More than one project found. Re-run with --project <uuid>.",
3094
+ projects: project.choices
3095
+ },
3096
+ { json: options.json }
3097
+ ));
3098
+ return 1;
3099
+ }
3100
+ const documents = args.dryRun ? { versions: {}, fallbacks: {}, unavailableReason: "skipped in --dry-run" } : await fetchHarnessDocuments({
3101
+ endpoint: options.endpoint,
3102
+ accessToken: await options.accessToken?.(),
3103
+ client: "ai-erd-cli",
3104
+ fetchImpl: options.fetchImpl
3105
+ });
3106
+ const plan = planInit({
3107
+ role,
3108
+ env: options.env,
3109
+ projectName: project.name,
3110
+ projectUuid: project.uuid,
3111
+ endpoint: options.endpoint,
3112
+ cliVersion: options.cliVersion,
3113
+ files,
3114
+ documents
3115
+ });
3116
+ if (plan.refusal !== void 0) {
3117
+ throw new Error(plan.refusal);
3118
+ }
3119
+ await applyPlan(root, plan, args.dryRun, files);
3120
+ options.io.stdout.write(renderSuccess(
3121
+ {
3122
+ action: args.dryRun ? "init (dry-run)" : "init",
3123
+ role,
3124
+ project: { uuid: project.uuid, name: project.name },
3125
+ endpoint: options.endpoint,
3126
+ written: plan.writes.map((write) => `${write.existed ? "updated" : "created"} ${write.path}`),
3127
+ codex: `Codex keeps a single global config, so init does not touch it. Run: ${plan.codexCommand}`,
3128
+ codexPerSession: plan.codexProfile,
3129
+ codexEnvFallback: `Without a profile, start the session with ${ROLE_ENV_VAR}=${role}.`,
3130
+ // ⚠조용히 기본값으로 떨어지지 않는다 — 서버에서 규칙을 고쳐도 안 바뀌는 이유를
3131
+ // 사용자가 영영 못 찾게 된다. ★요청 자체의 실패와 «문서별» 되돌림을 둘 다 적는다
3132
+ // (2026-09-23 독립 재리뷰 I5: HTTP 200 으로 온 빈 문서가 조용히 패키지 판이 됐다).
3133
+ notes: [...plan.notes, ...fallbackNotes(documents)],
3134
+ next: [
3135
+ "Restart the agent so it picks up the new MCP server.",
3136
+ role === "design" ? "Ask it to change a table \u2014 a Design session may, and the change lands in AI-ERD." : `Ask it to change a table \u2014 a ${role} session will be told to stop.`,
3137
+ `Enforce this on the server too: ${loginCommand(options, role)}`,
3138
+ "Undo everything with: ai-erd init --undo"
3139
+ ]
3140
+ },
3141
+ { json: options.json }
3142
+ ));
3143
+ return 0;
3144
+ }
3145
+ async function readRepositoryRole(cwd) {
3146
+ const root = resolve(cwd);
3147
+ const files = /* @__PURE__ */ new Map();
3148
+ for (const target of AGENT_TARGETS) {
3149
+ files.set(target.path, await readManagedFile(root, target.path));
3150
+ }
3151
+ try {
3152
+ return currentRole(files);
3153
+ } catch {
3154
+ return void 0;
3155
+ }
3156
+ }
3157
+ function currentRole(files) {
3158
+ const found = [...detectRoles(files).values()].filter((role) => role !== void 0);
3159
+ const unique = [...new Set(found)];
3160
+ if (unique.length > 1) {
3161
+ throw new Error(
3162
+ `Agent configs disagree about the session role (${unique.join(", ")}). Re-run with an explicit --role to line them up.`
3163
+ );
3164
+ }
3165
+ return unique[0];
3166
+ }
3167
+ function fallbackNotes(documents) {
3168
+ const notes = [];
3169
+ if (documents.unavailableReason) {
3170
+ notes.push(`Harness rules came from this package, not the server (${documents.unavailableReason}).`);
3171
+ }
3172
+ for (const [key, reason] of Object.entries(documents.fallbacks)) {
3173
+ notes.push(`${key} came from this package, not the server (${reason}).`);
3174
+ }
3175
+ return notes;
3176
+ }
3177
+ async function resolveProject(options, args, files, root) {
3178
+ const projects = await listProjects(options);
3179
+ if (args.projectUuid) {
3180
+ const found = projects.find((project) => project.uuid === args.projectUuid);
3181
+ return found ?? { uuid: args.projectUuid, name: args.projectName ?? args.projectUuid };
3182
+ }
3183
+ const bound = boundProjectUuid(files.get(HARNESS_CONFIG_PATH));
3184
+ if (bound) {
3185
+ const found = projects.find((project) => project.uuid === bound);
3186
+ if (found) {
3187
+ return found;
3188
+ }
3189
+ }
3190
+ if (projects.length === 1) {
3191
+ return projects[0];
3192
+ }
3193
+ if (projects.length > 1) {
3194
+ return { choices: projects };
3195
+ }
3196
+ const name = args.projectName ?? basenameOf(root);
3197
+ if (!args.yes) {
3198
+ throw new Error(
3199
+ `No project found in your AI-ERD account. Re-run with --yes to create "${name}", or --project-name <name> --yes to pick the name.`
3200
+ );
3201
+ }
3202
+ if (args.dryRun) {
3203
+ throw new Error(
3204
+ `--dry-run cannot continue: there is no project to bind, and creating "${name}" would be a real change. Re-run without --dry-run, or pass --project <uuid>.`
3205
+ );
3206
+ }
3207
+ return createProject(options, name);
3208
+ }
3209
+ async function listProjects(options) {
3210
+ const result = await callOrExplainSignIn(options, () => options.client.toolsCall("list_projects", {}));
3211
+ return extractProjects(unwrapToolJson(result));
3212
+ }
3213
+ async function callOrExplainSignIn(options, run) {
3214
+ try {
3215
+ return await run();
3216
+ } catch (error) {
3217
+ if (!isUnauthorized(error)) {
3218
+ throw error;
3219
+ }
3220
+ throw new Error(
3221
+ `Not signed in for this role, so ${options.endpoint} refused the request (HTTP 401).
3222
+
3223
+ ${loginCommand(options, options.role)}
3224
+
3225
+ Then run init again. The role is carried by the access token, so each role signs in once.`
3226
+ );
3227
+ }
3228
+ }
3229
+ function loginCommand(options, role) {
3230
+ const target = isDefaultEndpointFor(options.env, options.endpoint) ? options.env === "prod" ? "" : ` --env ${options.env}` : ` --endpoint ${options.endpoint}`;
3231
+ return `ai-erd auth login --role ${role ?? "<design|development|test|validation>"}${target}`;
3232
+ }
3233
+ function isUnauthorized(error) {
3234
+ return error instanceof McpRpcError && error.code === "MCP_HTTP_ERROR" && error.details?.status === 401;
3235
+ }
3236
+ async function createProject(options, name) {
3237
+ const result = await callOrExplainSignIn(options, () => options.client.toolsCall("create_projects", { items: [{ name }] }));
3238
+ const created = extractProjects(unwrapToolJson(result));
3239
+ const first = created[0];
3240
+ if (!first) {
3241
+ throw new Error("Project creation did not return a project uuid.");
3242
+ }
3243
+ return first;
3244
+ }
3245
+ function unwrapToolJson(result) {
3246
+ if (!isRecord8(result) || !Array.isArray(result.content)) {
3247
+ return result;
3248
+ }
3249
+ const first = result.content.find((item) => isRecord8(item) && item.type === "text");
3250
+ if (!isRecord8(first) || typeof first.text !== "string") {
3251
+ return result;
3252
+ }
3253
+ try {
3254
+ return JSON.parse(first.text);
3255
+ } catch {
3256
+ return first.text;
3257
+ }
3258
+ }
3259
+ function extractProjects(value) {
3260
+ const candidates = Array.isArray(value) ? value : isRecord8(value) ? ["projects", "items", "created", "data", "hits"].flatMap((key) => {
3261
+ const nested = value[key];
3262
+ return Array.isArray(nested) ? nested : [];
3263
+ }) : [];
3264
+ const projects = [];
3265
+ for (const candidate of candidates) {
3266
+ if (isRecord8(candidate) && typeof candidate.uuid === "string") {
3267
+ projects.push({
3268
+ uuid: candidate.uuid,
3269
+ name: typeof candidate.name === "string" ? candidate.name : candidate.uuid
3270
+ });
3271
+ }
3272
+ }
3273
+ return projects;
3274
+ }
3275
+ function boundProjectUuid(config) {
3276
+ if (!config) {
3277
+ return void 0;
3278
+ }
3279
+ try {
3280
+ const parsed = JSON.parse(config);
3281
+ return typeof parsed.project?.uuid === "string" ? parsed.project.uuid : void 0;
3282
+ } catch {
3283
+ return void 0;
3284
+ }
3285
+ }
3286
+ async function readSnapshot(root) {
3287
+ const snapshot = /* @__PURE__ */ new Map();
3288
+ for (const path of MANAGED_PATHS) {
3289
+ snapshot.set(path, await readManagedFile(root, path));
3290
+ }
3291
+ return snapshot;
3292
+ }
3293
+ async function readManagedFile(root, path) {
3294
+ await assertInsideRepository(root, path);
3295
+ return readIfExists(join2(root, path));
3296
+ }
3297
+ async function readIfExists(absolutePath) {
3298
+ try {
3299
+ return await readFile4(absolutePath, "utf8");
3300
+ } catch (error) {
3301
+ if (isRecord8(error) && error.code === "ENOENT") {
3302
+ return void 0;
3303
+ }
3304
+ throw error;
3305
+ }
3306
+ }
3307
+ async function assertInsideRepository(root, relativePath) {
3308
+ const absolutePath = join2(root, relativePath);
3309
+ const realRoot = await realpath(root);
3310
+ let realParent;
3311
+ try {
3312
+ realParent = await realpath(dirname2(absolutePath));
3313
+ } catch (error) {
3314
+ if (isRecord8(error) && error.code === "ENOENT") {
3315
+ return;
3316
+ }
3317
+ throw error;
3318
+ }
3319
+ if (realParent !== realRoot && !realParent.startsWith(realRoot + sep)) {
3320
+ throw new Error(
3321
+ `${relativePath} resolves outside the repository (${realParent}); refusing to touch it.`
3322
+ );
3323
+ }
3324
+ try {
3325
+ if ((await lstat(absolutePath)).isSymbolicLink()) {
3326
+ throw new Error(`${relativePath} is a symbolic link; refusing to write through it.`);
3327
+ }
3328
+ } catch (error) {
3329
+ if (!isRecord8(error) || error.code !== "ENOENT") {
3330
+ throw error;
3331
+ }
3332
+ }
3333
+ }
3334
+ async function applyPlan(root, plan, dryRun, snapshot, recordFirst = true) {
3335
+ for (const write of plan.writes) {
3336
+ await assertInsideRepository(root, write.path);
3337
+ await assertUnchangedSinceSnapshot(root, write.path, snapshot);
3338
+ }
3339
+ for (const path of plan.deletes) {
3340
+ await assertInsideRepository(root, path);
3341
+ await assertUnchangedSinceSnapshot(root, path, snapshot);
3342
+ }
3343
+ if (dryRun) {
3344
+ return;
3345
+ }
3346
+ const applied = [];
3347
+ try {
3348
+ const isRecord9 = (path) => !recordFirst && path === HARNESS_RECORD_PATH;
3349
+ const recordWrites = plan.writes.filter((write) => isRecord9(write.path));
3350
+ const recordDeletes = plan.deletes.filter((path) => isRecord9(path));
3351
+ const createdDirectories = [...plan.record?.createdDirectories ?? []];
3352
+ const rememberDirectories = (directories) => {
3353
+ for (const directory of directories) {
3354
+ if (!createdDirectories.includes(directory)) {
3355
+ createdDirectories.push(directory);
3356
+ }
3357
+ }
3358
+ };
3359
+ const recordActualDirectories = async () => {
3360
+ const write = plan.writes.find((w) => w.path === HARNESS_RECORD_PATH);
3361
+ if (!recordFirst || !plan.record || !write || !applied.includes(HARNESS_RECORD_PATH)) {
3362
+ return;
3363
+ }
3364
+ await writeManagedFile(root, {
3365
+ ...write,
3366
+ content: withCreatedDirectories(write.content, createdDirectories)
3367
+ });
3368
+ };
3369
+ const recordDirectoriesOrExplain = async (failing) => {
3370
+ try {
3371
+ await recordActualDirectories();
3372
+ } catch (error) {
3373
+ if (!failing) {
3374
+ throw error;
3375
+ }
3376
+ }
3377
+ };
3378
+ const apply = async (writes, deletes) => {
3379
+ for (const write of writes) {
3380
+ await assertUnchangedSinceSnapshot(root, write.path, snapshot);
3381
+ await writeManagedFile(root, write, rememberDirectories);
3382
+ applied.push(write.path);
3383
+ }
3384
+ for (const path of deletes) {
3385
+ await assertUnchangedSinceSnapshot(root, path, snapshot);
3386
+ await rm2(join2(root, path), { force: true });
3387
+ applied.push(path);
3388
+ }
3389
+ };
3390
+ try {
3391
+ await apply(
3392
+ orderedWrites(plan.writes.filter((write) => !isRecord9(write.path)), recordFirst),
3393
+ plan.deletes.filter((path) => !isRecord9(path))
3394
+ );
3395
+ await recordDirectoriesOrExplain(false);
3396
+ await apply(recordWrites, recordDeletes);
3397
+ } catch (error) {
3398
+ await recordDirectoriesOrExplain(true);
3399
+ throw error;
3400
+ }
3401
+ } catch (error) {
3402
+ const message = error instanceof Error ? error.message : String(error);
3403
+ throw new Error(
3404
+ `${message}
3405
+ Already applied before this failure: ${applied.join(", ") || "(nothing)"}`
3406
+ );
3407
+ }
3408
+ for (const directory of plan.removableDirectories ?? []) {
3409
+ await rmdir(join2(root, directory)).catch(() => void 0);
3410
+ }
3411
+ }
3412
+ function orderedWrites(writes, recordFirst) {
3413
+ const record = writes.filter((write) => write.path === HARNESS_RECORD_PATH);
3414
+ const rest = writes.filter((write) => write.path !== HARNESS_RECORD_PATH);
3415
+ return recordFirst ? [...record, ...rest] : [...rest, ...record];
3416
+ }
3417
+ async function assertUnchangedSinceSnapshot(root, relativePath, snapshot) {
3418
+ if (!snapshot.has(relativePath)) {
3419
+ return;
3420
+ }
3421
+ const now = await readIfExists(join2(root, relativePath));
3422
+ if (now !== snapshot.get(relativePath)) {
3423
+ throw new Error(
3424
+ `${relativePath} changed while init was running; nothing was written. Run init again.`
3425
+ );
3426
+ }
3427
+ }
3428
+ async function writeManagedFile(root, write, onDirectoriesCreated) {
3429
+ const absolutePath = join2(root, write.path);
3430
+ const targetDirectory = dirname2(absolutePath);
3431
+ const firstCreated = await mkdir2(targetDirectory, { recursive: true });
3432
+ const created = createdChain(root, firstCreated, targetDirectory);
3433
+ onDirectoriesCreated?.(created);
3434
+ const previousMode = await fileMode(absolutePath);
3435
+ const tmpPath = `${absolutePath}.${process.pid}.${Date.now()}.tmp`;
3436
+ await writeFile2(tmpPath, write.content, { encoding: "utf8", mode: previousMode ?? 384 });
3437
+ if (previousMode !== void 0) {
3438
+ await chmod2(tmpPath, previousMode);
3439
+ }
3440
+ await rename2(tmpPath, absolutePath);
3441
+ return created;
3442
+ }
3443
+ function createdChain(root, firstCreated, targetDirectory) {
3444
+ if (firstCreated === void 0) {
3445
+ return [];
3446
+ }
3447
+ const stop = resolve(firstCreated);
3448
+ const made = [];
3449
+ for (let at = resolve(targetDirectory); ; at = dirname2(at)) {
3450
+ const asRelative = relative(root, at);
3451
+ if (asRelative && !asRelative.startsWith("..")) {
3452
+ made.push(asRelative.split(sep).join("/"));
3453
+ }
3454
+ if (at === stop || dirname2(at) === at) {
3455
+ break;
3456
+ }
3457
+ }
3458
+ return made;
3459
+ }
3460
+ async function fileMode(absolutePath) {
3461
+ try {
3462
+ return (await stat3(absolutePath)).mode & 4095;
3463
+ } catch (error) {
3464
+ if (isRecord8(error) && error.code === "ENOENT") {
3465
+ return void 0;
3466
+ }
3467
+ throw error;
3468
+ }
3469
+ }
3470
+ function parseInitArgs(argv) {
3471
+ const args = { dryRun: false, undo: false, yes: false };
3472
+ for (let index = 0; index < argv.length; index += 1) {
3473
+ const arg = argv[index];
3474
+ if (!arg) {
3475
+ continue;
3476
+ }
3477
+ if (arg === "--project") {
3478
+ args.projectUuid = requireValue3(arg, argv[++index]);
3479
+ } else if (arg === "--project-name") {
3480
+ args.projectName = requireValue3(arg, argv[++index]);
3481
+ } else if (arg === "--dry-run") {
3482
+ args.dryRun = true;
3483
+ } else if (arg === "--undo") {
3484
+ args.undo = true;
3485
+ } else if (arg === "--yes" || arg === "-y") {
3486
+ args.yes = true;
3487
+ } else if (arg === "--json") {
3488
+ } else {
3489
+ throw new Error(`Unknown argument: ${arg}`);
3490
+ }
3491
+ }
3492
+ return args;
3493
+ }
3494
+ function requireValue3(flag, value) {
3495
+ if (!value || value.startsWith("--")) {
3496
+ throw new Error(`${flag} requires a value.`);
3497
+ }
3498
+ return value;
3499
+ }
3500
+ function basenameOf(path) {
3501
+ const parts = path.split(/[\\/]/).filter(Boolean);
3502
+ return parts[parts.length - 1] ?? "ai-erd-project";
3503
+ }
3504
+ function isRecord8(value) {
3505
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3506
+ }
3507
+
3508
+ // src/core/version.ts
3509
+ var CLI_VERSION = true ? "0.3.0" : "dev";
3510
+ var CLI_USER_AGENT = `@ai-erd/mcp ${CLI_VERSION}`;
3511
+
3512
+ // src/adapters/cli/ainectoCli.ts
3513
+ async function runAinectoCli(argv, io) {
3514
+ let parsed = { json: argv.includes("--json"), help: false, positionals: [] };
3515
+ try {
3516
+ parsed = parseGlobalArgs(argv);
3517
+ if (parsed.help || parsed.positionals.length === 0) {
3518
+ io.stdout.write(helpText());
3519
+ return 0;
3520
+ }
3521
+ const resolved = resolveEndpoint({ env: parsed.env, endpoint: parsed.endpoint });
3522
+ let sessionRole = resolveRole({ role: parsed.role }).role;
3523
+ if (parsed.positionals[0] === "init" && sessionRole === void 0 && !argv.includes("--undo")) {
3524
+ sessionRole = await readRepositoryRole(process.cwd());
3525
+ }
3526
+ const tokenStore = new FileTokenStore();
3527
+ const auth = new OAuthClient({ endpoint: resolved.endpoint, tokenStore, role: sessionRole });
3528
+ const client = new McpRpcClient({
3529
+ endpoint: resolved.endpoint,
3530
+ tokenProvider: auth,
3531
+ role: sessionRole
3532
+ });
3533
+ const [domain, action, ...rest] = parsed.positionals;
3534
+ if (domain === "mcp") {
3535
+ await runConnector({
3536
+ endpoint: resolved.endpoint,
3537
+ // `ai-erd mcp` 로 붙는 경로도 같은 역할을 쓴다 — bin/mcp.ts 와 같은 규칙.
3538
+ role: sessionRole,
3539
+ input: io.stdin,
3540
+ output: io.stdout,
3541
+ errorOutput: io.stderr
3542
+ });
3543
+ return 0;
3544
+ }
3545
+ if (domain === "init") {
3546
+ return await executeInitCommand({
3547
+ argv: parsed.positionals.slice(1),
3548
+ role: sessionRole,
3549
+ // 문서는 인증 후에 받는다 — 토큰이 없으면 패키지 기본값으로 가고, 그 사실을 보고한다.
3550
+ // ★«필요할 때» 가져온다. --undo 는 로컬 작업이라 토큰을 건드릴 이유가 없다(S1).
3551
+ accessToken: () => auth.getAccessToken().catch(() => void 0),
3552
+ client,
3553
+ endpoint: resolved.endpoint,
3554
+ env: resolved.env,
3555
+ cliVersion: `@ai-erd/mcp ${CLI_VERSION}`,
3556
+ cwd: process.cwd(),
3557
+ json: parsed.json,
3558
+ io
3559
+ });
3560
+ }
3561
+ if (domain === "auth") {
3562
+ return await handleAuth(action, auth, resolved.endpoint, parsed.json, io);
3563
+ }
3564
+ if (isAttachmentsUploadCommand(domain, action)) {
3565
+ return await executeAttachmentsUploadCommand({
3566
+ argv: rest,
3567
+ client,
3568
+ json: parsed.json,
3569
+ io
3570
+ });
3571
+ }
3572
+ const generatedResult = await executeGeneratedCommand({
3573
+ env: parsed.env ?? "prod",
3574
+ argv: parsed.positionals,
3575
+ client,
3576
+ json: parsed.json,
3577
+ io
3578
+ });
3579
+ if (generatedResult !== 2) {
3580
+ return generatedResult;
3581
+ }
3582
+ if (domain === "tools") {
3583
+ return await handleTools(action, rest, parsed, client, io);
3584
+ }
3585
+ throw new Error(`Unknown command "${domain}".`);
3586
+ } catch (error) {
3587
+ io.stderr.write(renderError(error, { json: parsed.json }));
3588
+ return 1;
3589
+ }
3590
+ }
3591
+ async function handleAuth(action, auth, endpoint, json, io) {
3592
+ if (action === "login") {
3593
+ await auth.login();
3594
+ io.stdout.write(renderSuccess({ endpoint, authenticated: true }, { json }));
3595
+ return 0;
3596
+ }
3597
+ if (action === "status") {
3598
+ const token = await auth.getAccessToken();
3599
+ io.stdout.write(renderSuccess({ endpoint, authenticated: Boolean(token) }, { json }));
3600
+ return 0;
3601
+ }
3602
+ if (action === "logout") {
3603
+ await auth.logout();
3604
+ io.stdout.write(renderSuccess({ endpoint, authenticated: false }, { json }));
3605
+ return 0;
3606
+ }
3607
+ throw new Error("Expected auth login, auth status, or auth logout.");
3608
+ }
3609
+ async function handleTools(action, rest, parsed, client, io) {
3610
+ if (action === "list") {
3611
+ const tools = await client.toolsList();
3612
+ io.stdout.write(renderSuccess(tools, { json: parsed.json }));
3613
+ return 0;
3614
+ }
3615
+ if (action === "call") {
3616
+ const toolName = rest[0];
3617
+ if (!toolName) {
3618
+ throw new Error("Expected MCP tool name after tools call.");
3619
+ }
3620
+ const toolArgs = rest.slice(1);
3621
+ const payloadOptions = parsePayloadArgs(toolArgs);
3622
+ const payload = await parsePayloadSource({
3623
+ file: payloadOptions.file,
3624
+ inline: payloadOptions.inline,
3625
+ stdin: io.stdin
3626
+ });
3627
+ const result = await client.toolsCall(toolName, payload.value);
3628
+ io.stdout.write(renderSuccess(result, { json: parsed.json, warnings: payload.warnings }));
3629
+ return isToolError(result) ? 1 : 0;
3630
+ }
3631
+ if (action === "catalog") {
3632
+ const catalogEnv = parsed.env ?? "prod";
3633
+ io.stdout.write(renderSuccess(getGeneratedTools(parsed.env ?? "prod"), {
3634
+ json: parsed.json,
3635
+ warnings: [catalogEnv === "dev" ? "Local dev catalog is a checked-in live sync snapshot; rerun sync:tools --env dev to refresh it." : "Local prod catalog is seed fixture only until authenticated prod live sync updates generated catalogs."]
3636
+ }));
3637
+ return 0;
3638
+ }
3639
+ throw new Error("Expected tools list, tools call <mcpName>, or tools catalog.");
3640
+ }
3641
+ function parseGlobalArgs(argv) {
3642
+ const result = { json: false, help: false, positionals: [] };
3643
+ for (let index = 0; index < argv.length; index += 1) {
3644
+ const arg = argv[index];
3645
+ if (!arg) {
3646
+ continue;
3647
+ }
3648
+ if (arg === "--env") {
3649
+ result.env = assertEnv(requireValue4(arg, argv[++index]));
3650
+ } else if (arg === "--endpoint") {
3651
+ result.endpoint = requireValue4(arg, argv[++index]);
3652
+ } else if (arg === "--role") {
3653
+ result.role = assertRole(requireValue4(arg, argv[++index]));
3654
+ } else if (arg === "--json") {
3655
+ result.json = true;
3656
+ } else if (arg === "--help" || arg === "-h") {
3657
+ result.help = true;
3658
+ } else {
3659
+ result.positionals.push(arg);
3660
+ }
3661
+ }
3662
+ return result;
3663
+ }
3664
+ function parsePayloadArgs(argv) {
3665
+ const result = {};
3666
+ const inline = [];
3667
+ for (let index = 0; index < argv.length; index += 1) {
3668
+ const arg = argv[index];
3669
+ if (!arg) {
3670
+ continue;
3671
+ }
3672
+ if (arg === "--file" || arg === "-f") {
3673
+ result.file = requireValue4(arg, argv[++index]);
3674
+ } else {
3675
+ inline.push(arg);
3676
+ }
3677
+ }
3678
+ if (inline.length > 0) {
3679
+ result.inline = inline.join(" ");
3680
+ }
3681
+ return result;
3682
+ }
3683
+ function requireValue4(flag, value) {
3684
+ if (!value || value.startsWith("--")) {
3685
+ throw new Error(`${flag} requires a value.`);
3686
+ }
3687
+ return value;
3688
+ }
3689
+ function helpText() {
3690
+ return [
3691
+ "Ainecto CLI",
3692
+ "",
3693
+ "Usage:",
3694
+ " ainecto auth login --role <design|development|test|validation> [--env prod|dev] [--json]",
3695
+ " ainecto auth status|logout [--role <role>] [--env prod|dev] [--json]",
3696
+ " ainecto tools list [--env prod|dev] [--endpoint URL] [--json]",
3697
+ " ainecto tools call <mcpName> [-f payload.json|@-] [inline-json] [--json]",
3698
+ " ainecto attachments upload --document-uuid <uuid> <file...> [--json]",
3699
+ " ainecto <generated-command> [flags] [-f payload.json] [--yes] [--json]",
3700
+ " ainecto mcp [--env prod|dev] [--endpoint URL] [--role design|development|test|validation]",
3701
+ "",
3702
+ "Harness:",
3703
+ " ai-erd init --role <design|development|test|validation> [--project <uuid>] [--dry-run]",
3704
+ " ai-erd init --undo",
1860
3705
  ""
1861
3706
  ].join("\n");
1862
3707
  }