@ainecto/mcp 0.2.0 → 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.
- package/README.md +1 -1
- package/dist/bin/ainecto.js +1995 -154
- package/dist/bin/ainecto.js.map +1 -1
- package/dist/bin/mcp.js +59 -16
- package/dist/bin/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/bin/ainecto.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -108,6 +109,9 @@ function normalizeEndpoint(endpoint) {
|
|
|
108
109
|
url.hash = "";
|
|
109
110
|
return url.toString().replace(/\/$/, "");
|
|
110
111
|
}
|
|
112
|
+
function isDefaultEndpointFor(env, endpoint) {
|
|
113
|
+
return normalizeEndpoint(endpoint) === DEFAULT_ENDPOINTS[env];
|
|
114
|
+
}
|
|
111
115
|
function isDefaultEndpoint(endpoint) {
|
|
112
116
|
const normalized = normalizeEndpoint(endpoint);
|
|
113
117
|
return Object.values(DEFAULT_ENDPOINTS).includes(normalized) || LEGACY_DEFAULT_ENDPOINTS.includes(normalized);
|
|
@@ -173,6 +177,31 @@ function isRecord(value) {
|
|
|
173
177
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
174
178
|
}
|
|
175
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
|
+
|
|
176
205
|
// src/core/auth/oauth.ts
|
|
177
206
|
var OAuthClient = class {
|
|
178
207
|
constructor(options) {
|
|
@@ -191,7 +220,7 @@ var OAuthClient = class {
|
|
|
191
220
|
assertEnvTokenEndpointAllowed(this.options.endpoint, this.envVars);
|
|
192
221
|
return envToken;
|
|
193
222
|
}
|
|
194
|
-
const stored = await this.options.tokenStore.load(this.options.endpoint);
|
|
223
|
+
const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
|
|
195
224
|
if (!stored) {
|
|
196
225
|
return void 0;
|
|
197
226
|
}
|
|
@@ -201,7 +230,7 @@ var OAuthClient = class {
|
|
|
201
230
|
return this.refreshStoredToken(stored);
|
|
202
231
|
}
|
|
203
232
|
async refreshAfterUnauthorized() {
|
|
204
|
-
const stored = await this.options.tokenStore.load(this.options.endpoint);
|
|
233
|
+
const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
|
|
205
234
|
if (!stored?.refreshToken) {
|
|
206
235
|
return void 0;
|
|
207
236
|
}
|
|
@@ -222,6 +251,9 @@ var OAuthClient = class {
|
|
|
222
251
|
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
223
252
|
authorizeUrl.searchParams.set("resource", this.options.endpoint);
|
|
224
253
|
authorizeUrl.searchParams.set("state", state);
|
|
254
|
+
if (this.options.role) {
|
|
255
|
+
authorizeUrl.searchParams.set("scope", `mcp ${ROLE_SCOPE_PREFIX}${this.options.role}`);
|
|
256
|
+
}
|
|
225
257
|
assertTrustedEndpointUrl(authorizeUrl, "OAuth authorization URL");
|
|
226
258
|
await this.openBrowserImpl(authorizeUrl.toString());
|
|
227
259
|
const code = await loopback.waitForCode();
|
|
@@ -235,14 +267,14 @@ var OAuthClient = class {
|
|
|
235
267
|
...registration.clientSecret ? { client_secret: registration.clientSecret } : {}
|
|
236
268
|
}, this.fetchImpl);
|
|
237
269
|
const stored = toStoredToken(this.options.endpoint, token, metadata, registration);
|
|
238
|
-
await this.options.tokenStore.save(this.options.endpoint, stored);
|
|
270
|
+
await this.options.tokenStore.save(this.options.endpoint, stored, this.options.role);
|
|
239
271
|
return stored;
|
|
240
272
|
} finally {
|
|
241
273
|
await loopback.close();
|
|
242
274
|
}
|
|
243
275
|
}
|
|
244
276
|
async logout() {
|
|
245
|
-
await this.options.tokenStore.delete(this.options.endpoint);
|
|
277
|
+
await this.options.tokenStore.delete(this.options.endpoint, this.options.role);
|
|
246
278
|
}
|
|
247
279
|
async refreshStoredToken(stored) {
|
|
248
280
|
if (!stored.refreshToken) {
|
|
@@ -257,7 +289,7 @@ var OAuthClient = class {
|
|
|
257
289
|
resource: this.options.endpoint
|
|
258
290
|
}, this.fetchImpl);
|
|
259
291
|
const next = toStoredToken(this.options.endpoint, token, metadata, stored);
|
|
260
|
-
await this.options.tokenStore.save(this.options.endpoint, next);
|
|
292
|
+
await this.options.tokenStore.save(this.options.endpoint, next, this.options.role);
|
|
261
293
|
return next.accessToken;
|
|
262
294
|
}
|
|
263
295
|
};
|
|
@@ -364,8 +396,8 @@ function toStoredToken(endpoint, token, metadata, previous) {
|
|
|
364
396
|
async function createLoopbackReceiver(expectedState) {
|
|
365
397
|
let resolveCode;
|
|
366
398
|
let rejectCode;
|
|
367
|
-
const codePromise = new Promise((
|
|
368
|
-
resolveCode =
|
|
399
|
+
const codePromise = new Promise((resolve2, reject) => {
|
|
400
|
+
resolveCode = resolve2;
|
|
369
401
|
rejectCode = reject;
|
|
370
402
|
});
|
|
371
403
|
codePromise.catch(() => void 0);
|
|
@@ -396,7 +428,7 @@ async function createLoopbackReceiver(expectedState) {
|
|
|
396
428
|
res.end("Ainecto CLI authorization complete. You can close this tab.");
|
|
397
429
|
resolveCode(code);
|
|
398
430
|
});
|
|
399
|
-
await new Promise((
|
|
431
|
+
await new Promise((resolve2) => server.listen(0, "127.0.0.1", resolve2));
|
|
400
432
|
const address = server.address();
|
|
401
433
|
if (!address || typeof address === "string") {
|
|
402
434
|
throw new Error("Loopback OAuth server did not bind to a TCP port.");
|
|
@@ -404,7 +436,7 @@ async function createLoopbackReceiver(expectedState) {
|
|
|
404
436
|
return {
|
|
405
437
|
redirectUri: `http://127.0.0.1:${address.port}/callback`,
|
|
406
438
|
waitForCode: () => codePromise,
|
|
407
|
-
close: () => new Promise((
|
|
439
|
+
close: () => new Promise((resolve2, reject) => server.close((error) => error ? reject(error) : resolve2()))
|
|
408
440
|
};
|
|
409
441
|
}
|
|
410
442
|
function parseStringArray(value) {
|
|
@@ -589,6 +621,9 @@ var McpRpcClient = class {
|
|
|
589
621
|
if (token) {
|
|
590
622
|
headers.authorization = `Bearer ${token}`;
|
|
591
623
|
}
|
|
624
|
+
if (this.options.role) {
|
|
625
|
+
headers[ROLE_HEADER.toLowerCase()] = this.options.role;
|
|
626
|
+
}
|
|
592
627
|
return this.fetchImpl(this.options.endpoint, {
|
|
593
628
|
method: "POST",
|
|
594
629
|
headers,
|
|
@@ -596,6 +631,9 @@ var McpRpcClient = class {
|
|
|
596
631
|
});
|
|
597
632
|
}
|
|
598
633
|
};
|
|
634
|
+
function isToolError(result) {
|
|
635
|
+
return isRecord2(result) && result.isError === true;
|
|
636
|
+
}
|
|
599
637
|
function isJsonRpcResponse(value) {
|
|
600
638
|
return isRecord2(value) && value.jsonrpc === "2.0" && ("result" in value || "error" in value);
|
|
601
639
|
}
|
|
@@ -884,8 +922,12 @@ function isRecord3(value) {
|
|
|
884
922
|
// src/adapters/mcp/connector.ts
|
|
885
923
|
async function runConnector(options) {
|
|
886
924
|
const tokenStore = new FileTokenStore();
|
|
887
|
-
const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore });
|
|
888
|
-
const client = new McpRpcClient({
|
|
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
|
+
});
|
|
889
931
|
await runStdioBridge({
|
|
890
932
|
input: options.input,
|
|
891
933
|
output: options.output,
|
|
@@ -1284,7 +1326,7 @@ async function executeGeneratedCommand(options) {
|
|
|
1284
1326
|
warnings,
|
|
1285
1327
|
presentation: route.presentation
|
|
1286
1328
|
}));
|
|
1287
|
-
return 0;
|
|
1329
|
+
return isToolError(result) ? 1 : 0;
|
|
1288
1330
|
}
|
|
1289
1331
|
function renderGeneratedResult(result, options) {
|
|
1290
1332
|
if (!options.json && options.presentation?.outputHint === "table") {
|
|
@@ -1701,166 +1743,1965 @@ function renderAttachmentUploadResult(result, json) {
|
|
|
1701
1743
|
return renderSuccess(lines.join("\n"), { json: false });
|
|
1702
1744
|
}
|
|
1703
1745
|
|
|
1704
|
-
// src/adapters/cli/
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
+
}
|
|
1707
1779
|
try {
|
|
1708
|
-
parsed =
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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);
|
|
1752
1849
|
} catch (error) {
|
|
1753
|
-
|
|
1754
|
-
|
|
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
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
if (!isRecord7(parsed)) {
|
|
1855
|
+
throw new Error("Existing MCP config must be a JSON object; it was left untouched.");
|
|
1755
1856
|
}
|
|
1857
|
+
return parsed;
|
|
1756
1858
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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
|
+
}
|
|
1762
1894
|
}
|
|
1763
|
-
if (
|
|
1764
|
-
|
|
1765
|
-
io.stdout.write(renderSuccess({ endpoint, authenticated: Boolean(token) }, { json }));
|
|
1766
|
-
return 0;
|
|
1895
|
+
if (key === AGENT_NOTE_KEY) {
|
|
1896
|
+
return managedBlockProblem(key, body);
|
|
1767
1897
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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;
|
|
1772
2025
|
}
|
|
1773
|
-
|
|
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
|
+
);
|
|
1774
2030
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
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
|
+
);
|
|
1780
2038
|
}
|
|
1781
|
-
if (
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
+
}
|
|
1785
2070
|
}
|
|
1786
|
-
|
|
1787
|
-
const payloadOptions = parsePayloadArgs(toolArgs);
|
|
1788
|
-
const payload = await parsePayloadSource({
|
|
1789
|
-
file: payloadOptions.file,
|
|
1790
|
-
inline: payloadOptions.inline,
|
|
1791
|
-
stdin: io.stdin
|
|
1792
|
-
});
|
|
1793
|
-
const result = await client.toolsCall(toolName, payload.value);
|
|
1794
|
-
io.stdout.write(renderSuccess(result, { json: parsed.json, warnings: payload.warnings }));
|
|
1795
|
-
return 0;
|
|
2071
|
+
offset = nextOffset;
|
|
1796
2072
|
}
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
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;
|
|
1804
2079
|
}
|
|
1805
|
-
|
|
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 };
|
|
1806
2086
|
}
|
|
1807
|
-
function
|
|
1808
|
-
const
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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;
|
|
1812
2127
|
continue;
|
|
1813
2128
|
}
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
result.json = true;
|
|
1820
|
-
} else if (arg === "--help" || arg === "-h") {
|
|
1821
|
-
result.help = true;
|
|
1822
|
-
} else {
|
|
1823
|
-
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;
|
|
1824
2134
|
}
|
|
2135
|
+
evidence.push(blockShape(rest.slice(begin, pairEnd + MARKER_END.length)));
|
|
2136
|
+
cursor = pairEnd + MARKER_END.length;
|
|
1825
2137
|
}
|
|
1826
|
-
|
|
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;
|
|
1827
2141
|
}
|
|
1828
|
-
function
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
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) {
|
|
1834
2167
|
continue;
|
|
1835
2168
|
}
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
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;
|
|
1840
2173
|
}
|
|
2174
|
+
if (isInsideFencedBlock(content, at)) {
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
return at;
|
|
1841
2178
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
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" };
|
|
1844
2192
|
}
|
|
1845
|
-
|
|
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
|
+
};
|
|
1846
2241
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
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;
|
|
1850
2256
|
}
|
|
1851
|
-
|
|
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);
|
|
1852
2278
|
}
|
|
1853
|
-
function
|
|
1854
|
-
return
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
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",
|
|
1864
3705
|
""
|
|
1865
3706
|
].join("\n");
|
|
1866
3707
|
}
|