@akira-tl/forgerelay 1.2.4 → 1.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/CHANGELOG.md +32 -0
- package/README.md +19 -11
- package/dist/cli/config/domains/context-cli.js +86 -0
- package/dist/cli/config/domains/domain-cli.js +468 -0
- package/dist/cli/config/general.js +174 -0
- package/dist/cli/config/inspect.js +29 -26
- package/dist/cli/config/migrate.js +11 -24
- package/dist/cli/config/scope.js +35 -0
- package/dist/cli/connect/relay.js +284 -0
- package/dist/cli/core/command-tree.js +68 -0
- package/dist/cli/core/serve-options.js +71 -0
- package/dist/cli/init/setup-config.js +26 -2
- package/dist/cli/init.js +37 -8
- package/dist/cli/maintenance-prune.js +1 -1
- package/dist/cli/maintenance.js +6 -6
- package/dist/cli/mcp/external-mcp.js +29 -17
- package/dist/cli/mcp/status.js +2 -2
- package/dist/cli/setup-support.js +3 -2
- package/dist/cli/system/status.js +35 -0
- package/dist/cli.js +132 -272
- package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +2 -2
- package/dist/mcp/server/core/schemas.js +2 -10
- package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -7
- package/dist/runtime/config/config.js +30 -29
- package/dist/runtime/config/definition/general-config.js +22 -6
- package/dist/runtime/config/external-mcp-config.js +4 -3
- package/dist/runtime/config/resolution/resolver.js +7 -4
- package/dist/runtime/config/user-config.js +9 -6
- package/dist/runtime/config/validation/paths.js +12 -0
- package/dist/runtime/config/validation/ports.js +9 -0
- package/dist/subagents/profiles.js +37 -0
- package/dist/workspaces/bootstrap.js +31 -14
- package/dist/workspaces/context.js +159 -7
- package/dist/workspaces/relay/auth/cli-test-support.js +22 -0
- package/dist/workspaces/resources/context-sources.js +29 -0
- package/dist/workspaces/resources/resource-monitor.js +29 -6
- package/dist/workspaces/resources/skills.js +15 -10
- package/dist/workspaces/sessions.js +4 -2
- package/dist/workspaces/state/project-context.js +34 -8
- package/dist/workspaces.js +5 -2
- package/docs/chatgpt-coding-workflow.md +23 -20
- package/docs/configuration.md +40 -27
- package/docs/gotchas.md +8 -6
- package/docs/roadmap.md +1 -1
- package/package.json +2 -2
- package/schemas/v1/config.project-local.schema.json +74 -0
- package/schemas/v1/config.project.schema.json +74 -0
- package/schemas/v1/config.user.schema.json +59 -4
- package/scripts/ci/config-v2-product-acceptance.mjs +17 -12
- package/scripts/debug/runtime.mjs +19 -3
- package/scripts/debug/runtime.test.mjs +3 -0
- package/scripts/debug/serve.mjs +2 -2
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { parseConfigSource } from "../../../runtime/config/definition/definition.js";
|
|
4
|
+
import { externalMcpConfigDefinition } from "../../../runtime/config/definition/external-mcp.js";
|
|
5
|
+
import { hooksConfigDefinition } from "../../../mcp/hooks/config.js";
|
|
6
|
+
import { languageServersConfigDefinition } from "../../../runtime/config/definition/language-servers.js";
|
|
7
|
+
import { ExternalMcpConfigRegistry } from "../../../runtime/config/external-mcp-registry.js";
|
|
8
|
+
import { resolveHooksConfig } from "../../../runtime/config/resolution/hooks.js";
|
|
9
|
+
import { resolveLanguageServersConfig } from "../../../runtime/config/resolution/language-servers.js";
|
|
10
|
+
import { assertConfigResolutionValid } from "../../../runtime/config/resolution/resolver.js";
|
|
11
|
+
import { ConfigSourceRuntime } from "../../../runtime/config/runtime/source-refresh.js";
|
|
12
|
+
import { forgerelayConfigDir, writeConfigJsonFile, writeConfigTextFile } from "../../../runtime/config/user-config.js";
|
|
13
|
+
import { ProjectContextResolver } from "../../../workspaces/state/project-context.js";
|
|
14
|
+
import { canonicalSubagentProfileDocument, canonicalSubagentProfileValueFromDocument, resolveSubagentProfilesConfigSources, } from "../../../subagents/profiles.js";
|
|
15
|
+
import { runConfigInspection } from "../inspect.js";
|
|
16
|
+
import { parseConfigScopeArgs } from "../scope.js";
|
|
17
|
+
import { runConfigContextCommand } from "./context-cli.js";
|
|
18
|
+
const JSON_DOMAIN_ADAPTERS = {
|
|
19
|
+
mcp: {
|
|
20
|
+
cliName: "mcp",
|
|
21
|
+
resolutionDomain: "mcp",
|
|
22
|
+
definition: externalMcpConfigDefinition,
|
|
23
|
+
fileName: "mcp.json",
|
|
24
|
+
rootField: "servers",
|
|
25
|
+
diskShape: "object",
|
|
26
|
+
resolve: resolveMcpConfiguration,
|
|
27
|
+
},
|
|
28
|
+
lsp: {
|
|
29
|
+
cliName: "lsp",
|
|
30
|
+
resolutionDomain: "language-servers",
|
|
31
|
+
definition: languageServersConfigDefinition,
|
|
32
|
+
fileName: "language-servers.json",
|
|
33
|
+
rootField: "servers",
|
|
34
|
+
diskShape: "keyed-root",
|
|
35
|
+
resolve: resolveLanguageServerConfiguration,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
export async function runConfigDomainCommand(domain, args) {
|
|
39
|
+
if (domain === "context") {
|
|
40
|
+
await runConfigContextCommand(args);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const adapter = JSON_DOMAIN_ADAPTERS[domain];
|
|
44
|
+
if (adapter) {
|
|
45
|
+
await runJsonDomainCommand(adapter, args);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (domain === "hooks") {
|
|
49
|
+
if (args[0] === "list" || args[0] === "help" || args[0] === "--help" || args[0] === "-h" || args[0] === undefined) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
await runHookDomainCommand(args);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (domain === "subagents") {
|
|
56
|
+
await runSubagentDomainCommand(args);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
async function runHookDomainCommand(args) {
|
|
62
|
+
const [command, ...rest] = args;
|
|
63
|
+
if (command === "check" || command === "sources" || command === "explain") {
|
|
64
|
+
process.exitCode = await runDomainInspection("hooks", "hooks", command, rest);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (command === "get") {
|
|
68
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
69
|
+
if (parsed.rest.length !== 1)
|
|
70
|
+
throw new Error("Usage: forgerelay config hooks get hooks.<name> [scope]");
|
|
71
|
+
const resolution = await resolveHookConfiguration(parsed.scope);
|
|
72
|
+
printConfiguredEntry(resolution, normalizeDomainLogicalPath("hooks", "hooks", parsed.rest[0]));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (command === "set") {
|
|
76
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
77
|
+
if (parsed.rest.length < 2)
|
|
78
|
+
throw new Error("Usage: forgerelay config hooks set hooks.<name>[.<field>] <value> [scope]");
|
|
79
|
+
const [logicalPath, ...valueParts] = parsed.rest;
|
|
80
|
+
const segments = domainLogicalSegments("hooks", "hooks", logicalPath);
|
|
81
|
+
const name = safeResourceName(segments[1] ?? "");
|
|
82
|
+
const target = await domainWriteTarget(parsed.scope, join("hooks", `${name}.json`));
|
|
83
|
+
const rawValue = valueParts.join(" ").trim();
|
|
84
|
+
if (!rawValue)
|
|
85
|
+
throw new Error(`Missing value for ${logicalPath}.`);
|
|
86
|
+
const candidate = segments.length === 2
|
|
87
|
+
? coerceConfigValue(rawValue)
|
|
88
|
+
: mutateExistingJson(target.path, segments.slice(2), coerceConfigValue(rawValue), "set");
|
|
89
|
+
if (!isRecord(candidate))
|
|
90
|
+
throw new Error(`hooks.${name} must be a JSON object.`);
|
|
91
|
+
writeHookTarget(target, name, candidate);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (command === "unset") {
|
|
95
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
96
|
+
if (parsed.rest.length !== 1)
|
|
97
|
+
throw new Error("Usage: forgerelay config hooks unset hooks.<name>.<field> [scope]");
|
|
98
|
+
const segments = domainLogicalSegments("hooks", "hooks", parsed.rest[0]);
|
|
99
|
+
if (segments.length < 3)
|
|
100
|
+
throw new Error("Use `config hooks remove <name>` to delete a complete Hook.");
|
|
101
|
+
const name = safeResourceName(segments[1]);
|
|
102
|
+
const target = await domainWriteTarget(parsed.scope, join("hooks", `${name}.json`));
|
|
103
|
+
const candidate = mutateExistingJson(target.path, segments.slice(2), undefined, "unset");
|
|
104
|
+
writeHookTarget(target, name, candidate);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command === "remove") {
|
|
108
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
109
|
+
if (parsed.rest.length !== 1)
|
|
110
|
+
throw new Error("Usage: forgerelay config hooks remove <name> [scope]");
|
|
111
|
+
const name = safeResourceName(parsed.rest[0]);
|
|
112
|
+
const target = await domainWriteTarget(parsed.scope, join("hooks", `${name}.json`));
|
|
113
|
+
rmSync(target.path, { force: true });
|
|
114
|
+
console.log(`Removed ${target.path}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Unknown config hooks command: ${command ?? ""}`);
|
|
118
|
+
}
|
|
119
|
+
async function runSubagentDomainCommand(args) {
|
|
120
|
+
const [command, ...rest] = args;
|
|
121
|
+
if (command === "check" || command === "sources" || command === "explain") {
|
|
122
|
+
process.exitCode = await runDomainInspection("subagents", "profiles", command, rest);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (command === "get") {
|
|
126
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
127
|
+
if (parsed.rest.length !== 1)
|
|
128
|
+
throw new Error("Usage: forgerelay config subagents get profiles.<name> [scope]");
|
|
129
|
+
const resolution = await resolveSubagentConfiguration(parsed.scope);
|
|
130
|
+
printConfiguredEntry(resolution, normalizeDomainLogicalPath("subagents", "profiles", parsed.rest[0]));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (command === "set") {
|
|
134
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
135
|
+
if (parsed.rest.length < 2)
|
|
136
|
+
throw new Error("Usage: forgerelay config subagents set profiles.<name>[.<field>] <value> [scope]");
|
|
137
|
+
const [logicalPath, ...valueParts] = parsed.rest;
|
|
138
|
+
const segments = domainLogicalSegments("subagents", "profiles", logicalPath);
|
|
139
|
+
const name = safeResourceName(segments[1] ?? "");
|
|
140
|
+
const target = await subagentWriteTarget(parsed.scope, name);
|
|
141
|
+
const rawValue = valueParts.join(" ").trim();
|
|
142
|
+
if (!rawValue)
|
|
143
|
+
throw new Error(`Missing value for ${logicalPath}.`);
|
|
144
|
+
const candidate = segments.length === 2
|
|
145
|
+
? coerceConfigValue(rawValue)
|
|
146
|
+
: mutateExistingSubagent(target.path, segments.slice(2), coerceConfigValue(rawValue), "set");
|
|
147
|
+
writeSubagentTarget(target, name, candidate);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (command === "unset") {
|
|
151
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
152
|
+
if (parsed.rest.length !== 1)
|
|
153
|
+
throw new Error("Usage: forgerelay config subagents unset profiles.<name>.<field> [scope]");
|
|
154
|
+
const segments = domainLogicalSegments("subagents", "profiles", parsed.rest[0]);
|
|
155
|
+
if (segments.length < 3)
|
|
156
|
+
throw new Error("Use `config subagents remove <name>` to delete a complete profile.");
|
|
157
|
+
const name = safeResourceName(segments[1]);
|
|
158
|
+
const target = await subagentWriteTarget(parsed.scope, name);
|
|
159
|
+
const candidate = mutateExistingSubagent(target.path, segments.slice(2), undefined, "unset");
|
|
160
|
+
writeSubagentTarget(target, name, candidate);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (command === "remove") {
|
|
164
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
165
|
+
if (parsed.rest.length !== 1)
|
|
166
|
+
throw new Error("Usage: forgerelay config subagents remove <name> [scope]");
|
|
167
|
+
const name = safeResourceName(parsed.rest[0]);
|
|
168
|
+
const target = await subagentWriteTarget(parsed.scope, name);
|
|
169
|
+
rmSync(target.path, { force: true });
|
|
170
|
+
console.log(`Removed ${target.path}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`Unknown config subagents command: ${command ?? ""}`);
|
|
174
|
+
}
|
|
175
|
+
async function runJsonDomainCommand(adapter, args) {
|
|
176
|
+
const [command, ...rest] = args;
|
|
177
|
+
if (command === "check" || command === "sources" || command === "explain") {
|
|
178
|
+
process.exitCode = await runDomainInspection(adapter.resolutionDomain, adapter.rootField, command, rest);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (command === "get") {
|
|
182
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
183
|
+
if (parsed.rest.length !== 1) {
|
|
184
|
+
throw new Error(`Usage: forgerelay config ${adapter.cliName} get <logical-path> [--project <path>|--global]`);
|
|
185
|
+
}
|
|
186
|
+
const logicalPath = normalizeDomainLogicalPath(adapter.resolutionDomain, adapter.rootField, parsed.rest[0]);
|
|
187
|
+
const resolution = await adapter.resolve(parsed.scope);
|
|
188
|
+
printConfiguredEntry(resolution, logicalPath);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (command === "set") {
|
|
192
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
193
|
+
if (parsed.rest.length < 2) {
|
|
194
|
+
throw new Error(`Usage: forgerelay config ${adapter.cliName} set <logical-path> <value> [--project <path>|--global]`);
|
|
195
|
+
}
|
|
196
|
+
const [logicalPath, ...valueParts] = parsed.rest;
|
|
197
|
+
const path = storagePathSegments(adapter, logicalPath);
|
|
198
|
+
const rawValue = valueParts.join(" ").trim();
|
|
199
|
+
if (!rawValue)
|
|
200
|
+
throw new Error(`Missing value for ${logicalPath}.`);
|
|
201
|
+
const target = await domainWriteTarget(parsed.scope, adapter.fileName);
|
|
202
|
+
const next = readJsonObject(target.path, domainFallback(adapter));
|
|
203
|
+
setNestedValue(next, path, coerceConfigValue(rawValue));
|
|
204
|
+
writeJsonDomainTarget(adapter, target, next);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (command === "unset") {
|
|
208
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
209
|
+
if (parsed.rest.length !== 1) {
|
|
210
|
+
throw new Error(`Usage: forgerelay config ${adapter.cliName} unset <logical-path> [--project <path>|--global]`);
|
|
211
|
+
}
|
|
212
|
+
const target = await domainWriteTarget(parsed.scope, adapter.fileName);
|
|
213
|
+
const next = readJsonObject(target.path, domainFallback(adapter));
|
|
214
|
+
const path = storagePathSegments(adapter, parsed.rest[0]);
|
|
215
|
+
if (isCompleteJsonResourcePath(adapter, path)) {
|
|
216
|
+
throw new Error(`Use \`config ${adapter.cliName} remove <name>\` to delete a complete resource.`);
|
|
217
|
+
}
|
|
218
|
+
deleteNestedValue(next, path);
|
|
219
|
+
writeJsonDomainTarget(adapter, target, next);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (command === "remove") {
|
|
223
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
224
|
+
if (parsed.rest.length !== 1) {
|
|
225
|
+
throw new Error(`Usage: forgerelay config ${adapter.cliName} remove <name> [--project <path>|--global]`);
|
|
226
|
+
}
|
|
227
|
+
const target = await domainWriteTarget(parsed.scope, adapter.fileName);
|
|
228
|
+
const next = readJsonObject(target.path, domainFallback(adapter));
|
|
229
|
+
const resourcePath = adapter.diskShape === "keyed-root"
|
|
230
|
+
? [parsed.rest[0]]
|
|
231
|
+
: [adapter.rootField, parsed.rest[0]];
|
|
232
|
+
deleteNestedValue(next, resourcePath);
|
|
233
|
+
writeJsonDomainTarget(adapter, target, next);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Unknown config ${adapter.cliName} command: ${command ?? ""}`);
|
|
237
|
+
}
|
|
238
|
+
async function runDomainInspection(domain, rootField, command, args) {
|
|
239
|
+
const inspectionArgs = command === "explain"
|
|
240
|
+
? [command, ...normalizeDomainExplainArgs(domain, rootField, args)]
|
|
241
|
+
: [command, ...args];
|
|
242
|
+
return runConfigInspection(inspectionArgs, domain);
|
|
243
|
+
}
|
|
244
|
+
function printConfiguredEntry(resolution, logicalPath) {
|
|
245
|
+
assertConfigResolutionValid(resolution);
|
|
246
|
+
const entry = Object.values(resolution.entries).find((candidate) => candidate.logicalPath === logicalPath);
|
|
247
|
+
if (!entry || entry.tombstone)
|
|
248
|
+
throw new Error(`Unknown configuration logical path: ${logicalPath}.`);
|
|
249
|
+
console.log(JSON.stringify(entry.effective.effectiveValue, null, 2));
|
|
250
|
+
}
|
|
251
|
+
async function resolveMcpConfiguration(scope) {
|
|
252
|
+
const configDir = forgerelayConfigDir();
|
|
253
|
+
const registry = new ExternalMcpConfigRegistry({ configDir, environment: process.env });
|
|
254
|
+
if (scope.mode === "global")
|
|
255
|
+
return registry.resolveConfiguration();
|
|
256
|
+
const project = await new ProjectContextResolver(configDir).inspect(scope.projectRoot);
|
|
257
|
+
return registry.resolveConfiguration({
|
|
258
|
+
projectSharedConfigDir: project.sharedConfigDir,
|
|
259
|
+
...(project.localConfigDir ? { projectLocalConfigDir: project.localConfigDir } : {}),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async function resolveHookConfiguration(scope) {
|
|
263
|
+
const configDir = forgerelayConfigDir();
|
|
264
|
+
if (scope.mode === "global")
|
|
265
|
+
return resolveHooksConfig({ configDir });
|
|
266
|
+
const project = await new ProjectContextResolver(configDir).inspect(scope.projectRoot);
|
|
267
|
+
return resolveHooksConfig({
|
|
268
|
+
configDir,
|
|
269
|
+
...(project.localConfigDir
|
|
270
|
+
? { project: { sharedConfigDir: project.sharedConfigDir, localConfigDir: project.localConfigDir } }
|
|
271
|
+
: { projectSharedConfigDir: project.sharedConfigDir }),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async function subagentWriteTarget(scope, name) {
|
|
275
|
+
const resolution = await resolveSubagentConfiguration(scope);
|
|
276
|
+
const entry = resolution.entries[`profiles.${name}`];
|
|
277
|
+
const targetScope = scope.mode === "global" ? "user" : "project";
|
|
278
|
+
const candidate = entry
|
|
279
|
+
? [entry.effective, ...entry.shadowed].find((value) => value.source.scope === targetScope &&
|
|
280
|
+
value.source.id.startsWith(`canonical:${targetScope}:subagent:`) &&
|
|
281
|
+
value.source.location !== undefined)
|
|
282
|
+
: undefined;
|
|
283
|
+
if (candidate?.source.location) {
|
|
284
|
+
return { scope: targetScope, path: candidate.source.location };
|
|
285
|
+
}
|
|
286
|
+
return domainWriteTarget(scope, join("subagents", `${name}.md`));
|
|
287
|
+
}
|
|
288
|
+
async function resolveSubagentConfiguration(scope) {
|
|
289
|
+
const configDir = forgerelayConfigDir();
|
|
290
|
+
const sourceRuntime = new ConfigSourceRuntime();
|
|
291
|
+
if (scope.mode === "global") {
|
|
292
|
+
return resolveSubagentProfilesConfigSources({ configDir, sourceRuntime });
|
|
293
|
+
}
|
|
294
|
+
const project = await new ProjectContextResolver(configDir).inspect(scope.projectRoot);
|
|
295
|
+
return resolveSubagentProfilesConfigSources({
|
|
296
|
+
configDir,
|
|
297
|
+
sourceRuntime,
|
|
298
|
+
projectSharedConfigDir: project.sharedConfigDir,
|
|
299
|
+
...(project.localConfigDir ? { projectLocalConfigDir: project.localConfigDir } : {}),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async function resolveLanguageServerConfiguration(scope) {
|
|
303
|
+
const configDir = forgerelayConfigDir();
|
|
304
|
+
if (scope.mode === "global") {
|
|
305
|
+
return resolveLanguageServersConfig({ configDir, environment: process.env });
|
|
306
|
+
}
|
|
307
|
+
const project = await new ProjectContextResolver(configDir).inspect(scope.projectRoot);
|
|
308
|
+
return resolveLanguageServersConfig({
|
|
309
|
+
configDir,
|
|
310
|
+
environment: process.env,
|
|
311
|
+
...(project.localConfigDir
|
|
312
|
+
? { project: { sharedConfigDir: project.sharedConfigDir, localConfigDir: project.localConfigDir } }
|
|
313
|
+
: { projectSharedConfigDir: project.sharedConfigDir }),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function writeJsonDomainTarget(adapter, target, value) {
|
|
317
|
+
const validated = parseConfigSource(adapter.definition, target.scope, value);
|
|
318
|
+
const diskValue = adapter.diskShape === "keyed-root" ? value : validated;
|
|
319
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
320
|
+
writeConfigJsonFile(target.path, diskValue, 0o600);
|
|
321
|
+
console.log(`Updated ${target.path}`);
|
|
322
|
+
}
|
|
323
|
+
function writeHookTarget(target, name, value) {
|
|
324
|
+
parseConfigSource(hooksConfigDefinition, target.scope, value, name);
|
|
325
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
326
|
+
writeConfigJsonFile(target.path, value, 0o600);
|
|
327
|
+
console.log(`Updated ${target.path}`);
|
|
328
|
+
}
|
|
329
|
+
function writeSubagentTarget(target, name, value) {
|
|
330
|
+
const document = canonicalSubagentProfileDocument(name, value);
|
|
331
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
332
|
+
writeConfigTextFile(target.path, document, 0o600);
|
|
333
|
+
console.log(`Updated ${target.path}`);
|
|
334
|
+
}
|
|
335
|
+
function mutateExistingJson(path, nestedPath, value, action) {
|
|
336
|
+
if (!existsSync(path))
|
|
337
|
+
throw new Error(`Configuration resource does not exist: ${path}`);
|
|
338
|
+
const next = readJsonObject(path, {});
|
|
339
|
+
if (action === "set")
|
|
340
|
+
setNestedValue(next, nestedPath, value);
|
|
341
|
+
else
|
|
342
|
+
deleteNestedValue(next, nestedPath);
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
345
|
+
function mutateExistingSubagent(path, nestedPath, value, action) {
|
|
346
|
+
if (!existsSync(path))
|
|
347
|
+
throw new Error(`Subagent Profile does not exist: ${path}`);
|
|
348
|
+
const current = canonicalSubagentProfileValueFromDocument(readFileSync(path, "utf8"), path);
|
|
349
|
+
if (!isRecord(current))
|
|
350
|
+
throw new Error(`Subagent Profile is not editable: ${path}`);
|
|
351
|
+
const next = structuredClone(current);
|
|
352
|
+
if (action === "set")
|
|
353
|
+
setNestedValue(next, nestedPath, value);
|
|
354
|
+
else
|
|
355
|
+
deleteNestedValue(next, nestedPath);
|
|
356
|
+
return next;
|
|
357
|
+
}
|
|
358
|
+
async function domainWriteTarget(scope, fileName) {
|
|
359
|
+
const configDir = forgerelayConfigDir();
|
|
360
|
+
if (scope.mode === "global")
|
|
361
|
+
return { scope: "user", path: join(configDir, fileName) };
|
|
362
|
+
const project = await new ProjectContextResolver(configDir).inspect(scope.projectRoot);
|
|
363
|
+
return { scope: "project", path: join(project.sharedConfigDir, fileName) };
|
|
364
|
+
}
|
|
365
|
+
function domainFallback(adapter) {
|
|
366
|
+
return adapter.diskShape === "keyed-root" ? {} : { [adapter.rootField]: {} };
|
|
367
|
+
}
|
|
368
|
+
function readJsonObject(path, fallback) {
|
|
369
|
+
if (!existsSync(path))
|
|
370
|
+
return structuredClone(fallback);
|
|
371
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
372
|
+
if (!isRecord(parsed))
|
|
373
|
+
throw new Error(`Configuration source must be a JSON object: ${path}`);
|
|
374
|
+
return structuredClone(parsed);
|
|
375
|
+
}
|
|
376
|
+
function safeResourceName(value) {
|
|
377
|
+
const name = value.trim();
|
|
378
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name === "." || name === "..") {
|
|
379
|
+
throw new Error(`Invalid configuration resource name: ${value}.`);
|
|
380
|
+
}
|
|
381
|
+
return name;
|
|
382
|
+
}
|
|
383
|
+
function isCompleteJsonResourcePath(adapter, path) {
|
|
384
|
+
return adapter.diskShape === "keyed-root"
|
|
385
|
+
? path.length === 1
|
|
386
|
+
: path.length === 2 && path[0] === adapter.rootField;
|
|
387
|
+
}
|
|
388
|
+
function storagePathSegments(adapter, logicalPath) {
|
|
389
|
+
const segments = domainLogicalSegments(adapter.resolutionDomain, adapter.rootField, logicalPath);
|
|
390
|
+
if (segments[0] !== adapter.rootField || segments.length < 2) {
|
|
391
|
+
throw new Error(`${adapter.cliName} logical paths must start with ${adapter.rootField}.<name>.`);
|
|
392
|
+
}
|
|
393
|
+
return adapter.diskShape === "keyed-root" ? segments.slice(1) : segments;
|
|
394
|
+
}
|
|
395
|
+
function domainLogicalSegments(domain, rootField, logicalPath) {
|
|
396
|
+
const normalized = normalizeDomainLogicalPath(domain, rootField, logicalPath);
|
|
397
|
+
return normalized.slice(`${domain}.`.length).split(".").filter(Boolean);
|
|
398
|
+
}
|
|
399
|
+
function normalizeDomainLogicalPath(domain, rootField, logicalPath) {
|
|
400
|
+
const normalized = logicalPath.trim();
|
|
401
|
+
if (!normalized)
|
|
402
|
+
throw new Error(`Invalid ${domain} logical path: ${logicalPath}.`);
|
|
403
|
+
const fullyQualifiedRoot = `${domain}.${rootField}`;
|
|
404
|
+
if (normalized === fullyQualifiedRoot || normalized.startsWith(`${fullyQualifiedRoot}.`))
|
|
405
|
+
return normalized;
|
|
406
|
+
return `${domain}.${normalized}`;
|
|
407
|
+
}
|
|
408
|
+
function normalizeDomainExplainArgs(domain, rootField, args) {
|
|
409
|
+
const normalized = [];
|
|
410
|
+
let logicalPathSeen = false;
|
|
411
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
412
|
+
const arg = args[index];
|
|
413
|
+
if (arg === "--project") {
|
|
414
|
+
normalized.push(arg);
|
|
415
|
+
const project = args[index + 1];
|
|
416
|
+
if (project !== undefined) {
|
|
417
|
+
normalized.push(project);
|
|
418
|
+
index += 1;
|
|
419
|
+
}
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (arg === "--global" || arg === "--json") {
|
|
423
|
+
normalized.push(arg);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (!logicalPathSeen) {
|
|
427
|
+
normalized.push(normalizeDomainLogicalPath(domain, rootField, arg));
|
|
428
|
+
logicalPathSeen = true;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
normalized.push(arg);
|
|
432
|
+
}
|
|
433
|
+
return normalized;
|
|
434
|
+
}
|
|
435
|
+
function coerceConfigValue(rawValue) {
|
|
436
|
+
try {
|
|
437
|
+
return JSON.parse(rawValue);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return rawValue;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function setNestedValue(target, path, value) {
|
|
444
|
+
let current = target;
|
|
445
|
+
for (const segment of path.slice(0, -1)) {
|
|
446
|
+
const existing = current[segment];
|
|
447
|
+
if (existing !== undefined && !isRecord(existing)) {
|
|
448
|
+
throw new Error(`Cannot set ${path.join(".")}: ${segment} is not an object.`);
|
|
449
|
+
}
|
|
450
|
+
const next = existing ?? {};
|
|
451
|
+
current[segment] = next;
|
|
452
|
+
current = next;
|
|
453
|
+
}
|
|
454
|
+
current[path[path.length - 1]] = value;
|
|
455
|
+
}
|
|
456
|
+
function deleteNestedValue(target, path) {
|
|
457
|
+
let current = target;
|
|
458
|
+
for (const segment of path.slice(0, -1)) {
|
|
459
|
+
const existing = current[segment];
|
|
460
|
+
if (!isRecord(existing))
|
|
461
|
+
return;
|
|
462
|
+
current = existing;
|
|
463
|
+
}
|
|
464
|
+
delete current[path[path.length - 1]];
|
|
465
|
+
}
|
|
466
|
+
function isRecord(value) {
|
|
467
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
468
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { forgerelayConfigDir, loadForgeRelayFiles, writeConfigJsonFile, writeForgeRelayConfig, } from "../../runtime/config/user-config.js";
|
|
4
|
+
import { parseConfigSource } from "../../runtime/config/definition/definition.js";
|
|
5
|
+
import { generalConfigDefinition } from "../../runtime/config/definition/general-config.js";
|
|
6
|
+
import { resolveGeneralConfig } from "../../runtime/config/resolution/general.js";
|
|
7
|
+
import { readJsonConfigSource } from "../../runtime/config/resolution/project-sources.js";
|
|
8
|
+
import { assertConfigResolutionValid } from "../../runtime/config/resolution/resolver.js";
|
|
9
|
+
import { ProjectContextResolver } from "../../workspaces/state/project-context.js";
|
|
10
|
+
import { normalizeOptionalPublicBaseUrl } from "../setup-support.js";
|
|
11
|
+
import { parseConfigScopeArgs } from "./scope.js";
|
|
12
|
+
export function renderGeneralConfigHelp() {
|
|
13
|
+
return [
|
|
14
|
+
"ForgeRelay config",
|
|
15
|
+
"",
|
|
16
|
+
"Usage:",
|
|
17
|
+
" forgerelay config get [--project <path>|--global]",
|
|
18
|
+
" forgerelay config set <logical-path> <value> [--project <path>|--global]",
|
|
19
|
+
" forgerelay config unset <logical-path> [--project <path>|--global]",
|
|
20
|
+
" forgerelay config check [--project <path>|--global] [--json]",
|
|
21
|
+
" forgerelay config sources [--project <path>|--global] [--json]",
|
|
22
|
+
" forgerelay config explain <logical-path> [--project <path>|--global] [--json]",
|
|
23
|
+
" forgerelay config migrate [--dry-run] [--project <path>|--global]",
|
|
24
|
+
" forgerelay config context <get|set|unset|check|sources|explain> ...",
|
|
25
|
+
" forgerelay config <mcp|hooks|lsp|subagents> <get|set|unset|remove|check|sources|explain> ...",
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
export async function runGeneralConfigGet(args) {
|
|
29
|
+
const parsed = parseConfigScopeArgs(args);
|
|
30
|
+
if (parsed.rest.length > 0)
|
|
31
|
+
throw new Error(`Unknown config get option: ${parsed.rest[0]}`);
|
|
32
|
+
const resolution = await resolveGeneralConfigForScope(parsed.scope);
|
|
33
|
+
assertConfigResolutionValid(resolution);
|
|
34
|
+
console.log(JSON.stringify(resolution.values, null, 2));
|
|
35
|
+
}
|
|
36
|
+
export async function resolveGeneralConfigForScope(scope) {
|
|
37
|
+
const configDir = forgerelayConfigDir();
|
|
38
|
+
const userSource = await readJsonConfigSource({
|
|
39
|
+
id: "user:config",
|
|
40
|
+
scope: "user",
|
|
41
|
+
location: join(configDir, "config.json"),
|
|
42
|
+
});
|
|
43
|
+
const project = scope.mode === "project"
|
|
44
|
+
? await new ProjectContextResolver(configDir).inspect(scope.projectRoot)
|
|
45
|
+
: undefined;
|
|
46
|
+
const projectSource = project
|
|
47
|
+
? await readJsonConfigSource({
|
|
48
|
+
id: "project:config",
|
|
49
|
+
scope: "project",
|
|
50
|
+
location: join(project.sharedConfigDir, "config.json"),
|
|
51
|
+
})
|
|
52
|
+
: undefined;
|
|
53
|
+
const projectLocalSource = project?.localConfigDir
|
|
54
|
+
? await readJsonConfigSource({
|
|
55
|
+
id: "project-local:config",
|
|
56
|
+
scope: "project-local",
|
|
57
|
+
location: join(project.localConfigDir, "config.json"),
|
|
58
|
+
})
|
|
59
|
+
: undefined;
|
|
60
|
+
return resolveGeneralConfig({
|
|
61
|
+
env: process.env,
|
|
62
|
+
...(userSource ? { userSource } : {}),
|
|
63
|
+
...(projectSource ? { projectSource } : {}),
|
|
64
|
+
...(projectLocalSource ? { projectLocalSource } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function runGeneralConfigSet(args) {
|
|
68
|
+
const parsed = parseConfigScopeArgs(args);
|
|
69
|
+
if (parsed.rest.length < 2) {
|
|
70
|
+
throw new Error("Usage: forgerelay config set <logical-path> <value> [--project <path>|--global]");
|
|
71
|
+
}
|
|
72
|
+
const [logicalPath, ...valueParts] = parsed.rest;
|
|
73
|
+
const path = generalPathSegments(logicalPath);
|
|
74
|
+
const rawValue = valueParts.join(" ").trim();
|
|
75
|
+
if (!rawValue)
|
|
76
|
+
throw new Error(`Missing value for ${logicalPath}.`);
|
|
77
|
+
const target = await generalConfigWriteTarget(parsed.scope);
|
|
78
|
+
const next = readGeneralConfigTarget(target);
|
|
79
|
+
setNestedValue(next, path, coerceConfigValue(path, rawValue));
|
|
80
|
+
console.log(`Updated ${writeGeneralConfigTarget(target, next)}`);
|
|
81
|
+
}
|
|
82
|
+
export async function runGeneralConfigUnset(args) {
|
|
83
|
+
const parsed = parseConfigScopeArgs(args);
|
|
84
|
+
if (parsed.rest.length !== 1) {
|
|
85
|
+
throw new Error("Usage: forgerelay config unset <logical-path> [--project <path>|--global]");
|
|
86
|
+
}
|
|
87
|
+
const path = generalPathSegments(parsed.rest[0]);
|
|
88
|
+
const target = await generalConfigWriteTarget(parsed.scope);
|
|
89
|
+
const next = readGeneralConfigTarget(target);
|
|
90
|
+
deleteNestedValue(next, path);
|
|
91
|
+
console.log(`Updated ${writeGeneralConfigTarget(target, next)}`);
|
|
92
|
+
}
|
|
93
|
+
async function generalConfigWriteTarget(scope) {
|
|
94
|
+
const configDir = forgerelayConfigDir();
|
|
95
|
+
if (scope.mode === "global") {
|
|
96
|
+
return { scope: "user", path: join(configDir, "config.json") };
|
|
97
|
+
}
|
|
98
|
+
const project = await new ProjectContextResolver(configDir).inspect(scope.projectRoot);
|
|
99
|
+
return { scope: "project", path: join(project.sharedConfigDir, "config.json") };
|
|
100
|
+
}
|
|
101
|
+
function readGeneralConfigTarget(target) {
|
|
102
|
+
if (target.scope === "user") {
|
|
103
|
+
return structuredClone(loadForgeRelayFiles().config);
|
|
104
|
+
}
|
|
105
|
+
if (!existsSync(target.path))
|
|
106
|
+
return {};
|
|
107
|
+
const parsed = JSON.parse(readFileSync(target.path, "utf8"));
|
|
108
|
+
if (!isRecord(parsed))
|
|
109
|
+
throw new Error(`General configuration must be a JSON object: ${target.path}`);
|
|
110
|
+
return structuredClone(parsed);
|
|
111
|
+
}
|
|
112
|
+
function writeGeneralConfigTarget(target, value) {
|
|
113
|
+
if (target.scope === "user") {
|
|
114
|
+
return writeForgeRelayConfig(value);
|
|
115
|
+
}
|
|
116
|
+
const validated = parseConfigSource(generalConfigDefinition, "project", value);
|
|
117
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
118
|
+
writeConfigJsonFile(target.path, validated, 0o600);
|
|
119
|
+
return target.path;
|
|
120
|
+
}
|
|
121
|
+
function generalPathSegments(logicalPath) {
|
|
122
|
+
const normalized = logicalPath.trim();
|
|
123
|
+
const path = normalized.startsWith("config.") ? normalized.slice("config.".length) : normalized;
|
|
124
|
+
const segments = path.split(".").filter(Boolean);
|
|
125
|
+
if (segments.length === 0)
|
|
126
|
+
throw new Error(`Invalid General Config logical path: ${logicalPath}.`);
|
|
127
|
+
return segments;
|
|
128
|
+
}
|
|
129
|
+
function coerceConfigValue(path, rawValue) {
|
|
130
|
+
let value;
|
|
131
|
+
try {
|
|
132
|
+
value = JSON.parse(rawValue);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
value = rawValue;
|
|
136
|
+
}
|
|
137
|
+
if (path.length === 1 && path[0] === "publicBaseUrl" && typeof value === "string") {
|
|
138
|
+
return normalizeOptionalPublicBaseUrl(value);
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function setNestedValue(target, path, value) {
|
|
143
|
+
let current = target;
|
|
144
|
+
for (const segment of path.slice(0, -1)) {
|
|
145
|
+
const existing = current[segment];
|
|
146
|
+
if (existing !== undefined && !isRecord(existing)) {
|
|
147
|
+
throw new Error(`Cannot set config.${path.join(".")}: config.${segment} is not an object.`);
|
|
148
|
+
}
|
|
149
|
+
const next = existing ?? {};
|
|
150
|
+
current[segment] = next;
|
|
151
|
+
current = next;
|
|
152
|
+
}
|
|
153
|
+
current[path[path.length - 1]] = value;
|
|
154
|
+
}
|
|
155
|
+
function deleteNestedValue(target, path) {
|
|
156
|
+
const parents = [];
|
|
157
|
+
let current = target;
|
|
158
|
+
for (const segment of path.slice(0, -1)) {
|
|
159
|
+
const existing = current[segment];
|
|
160
|
+
if (!isRecord(existing))
|
|
161
|
+
return;
|
|
162
|
+
parents.push({ object: current, key: segment });
|
|
163
|
+
current = existing;
|
|
164
|
+
}
|
|
165
|
+
delete current[path[path.length - 1]];
|
|
166
|
+
for (const parent of parents.reverse()) {
|
|
167
|
+
const value = parent.object[parent.key];
|
|
168
|
+
if (isRecord(value) && Object.keys(value).length === 0)
|
|
169
|
+
delete parent.object[parent.key];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function isRecord(value) {
|
|
173
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
174
|
+
}
|