@akira-tl/forgerelay 1.2.5 → 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 +20 -0
- package/README.md +2 -2
- 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 +6 -22
- 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 -0
- 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/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 +18 -3
- package/dist/runtime/config/resolution/resolver.js +7 -4
- package/dist/runtime/config/user-config.js +6 -6
- package/dist/runtime/config/validation/paths.js +12 -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 +57 -2
- 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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.3.0] - 2026-09-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 新的 domain-first CLI 将运行职责收敛到 `serve`、`init`、`config`、`connect` 与 `system`:`config` 统一 General / Agent Context / External MCP / Hooks / Language Servers / Subagent Profiles 的读取、修改与 provenance 操作,`connect relay` / `connect mcp` 负责运行时连接关系,`system` 负责诊断与维护。
|
|
12
|
+
- `serve` 支持仅当前进程生效的 host、port、allowed root 与 public URL override,不会把调试或多实例参数写回持久配置。
|
|
13
|
+
- Agent context source 现在是 Config System v2 的正式配置:`systemInstructionsPath`、`instructionNames` 与 `skillPaths` 支持 runtime / Project Local / Project / User / built-in precedence、source inspection 和 live refresh。
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 裸 `forgerelay` 现在只显示帮助,不再隐式启动服务器;脚本和服务启动应显式使用 `forgerelay serve`。旧 `start`、`hooks`、`auth`、`mcp`、`maintenance` 与 `agents` 路由继续作为不出现在公共 help 中的兼容入口。
|
|
18
|
+
- Agent context built-in defaults 收敛为 `~/.agents/AGENTS.md`、Project instruction basename `AGENTS.md`,以及 `~/.agents/skills` / `./.agents/skills`;ForgeRelay 不再隐式维护 `~/.forgerelay/skills` 或 Project `.forgerelay/skills` 作为 Agent Skill 默认来源。
|
|
19
|
+
- ForgeRelay-owned legacy Config source 继续兼容读取,但 1.3 的 deprecation diagnostic 现在明确显示计划在 `1.4.0` 移除,并继续提供 canonical replacement 路径。
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `forgerelay serve --help` 现在返回专用 serve usage/options,而不是把 `--help` 当成未知启动参数;`system status` 在尚未运行 `init`、没有 Owner credential 的全新环境里也保持只读并报告 `Instance: not initialized` / `Runtime: not running`。
|
|
24
|
+
- `config` domain adapters 现在统一使用 stable resource names、原子 schema-valid mutation 与一致的 Project / global scope 语义;Subagent Profile 名称与各 domain `get` / `set` / `unset` / `remove` contract 保持一致。
|
|
25
|
+
- 带 public base path 的交互式 debug server 现在打印真实 mounted health URL,例如 `/forgerelay/debug/healthz`,而不是错误的 root `/healthz`。
|
|
26
|
+
|
|
7
27
|
## [1.2.5] - 2026-09-16
|
|
8
28
|
|
|
9
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ ForgeRelay 不会默认为每个任务创建 worktree。只有你明确要求隔
|
|
|
110
110
|
- Linux / macOS 可以用 Bash、zsh、POSIX sh;Windows 原生支持 PowerShell 7、Windows PowerShell 5.1 和 `cmd.exe`。
|
|
111
111
|
- 同一个 checkout 会保留自己的 Workspace 身份。换一次对话,不需要重新创建工作区。
|
|
112
112
|
- `code.intelligence` 可以查 definition、hover、references、symbols 和 diagnostics。
|
|
113
|
-
-
|
|
113
|
+
- Agent 上下文来源走 Config v2:默认 system instructions 是 `~/.agents/AGENTS.md`,项目指令默认只匹配 `AGENTS.md`,Skills 默认按顺序扫描 `~/.agents/skills` 与项目 `./.agents/skills`。项目/User/Project Local/runtime 都可以用 replace semantics 选择其他通用 Agent 目录;ForgeRelay 不再隐式维护 `.forgerelay/skills` 来源。
|
|
114
114
|
- 需要并行开发时可以创建真实 Git worktree;起始 commit 与最终 `targetBranch` 独立,集成仍只接受安全的 fast-forward,不自动制造 merge conflict。
|
|
115
115
|
- 支持 MCP Apps 的 Host 会显示 Workspace Summary / Activity Panel;Workspace 身份色在 Panel 与 pretty log 中保持一致,长路径会在详情区正常换行。
|
|
116
116
|
- Workspace Relay 可以把执行放到另一台 ForgeRelay;Composite Workspace 可以同时协调几个独立环境。
|
|
@@ -234,7 +234,7 @@ Long commands do not require tight polling either. Once the current wait window
|
|
|
234
234
|
- Linux/macOS can use Bash, zsh, or POSIX sh. Windows has native PowerShell 7, Windows PowerShell 5.1, and `cmd.exe` support.
|
|
235
235
|
- Reopening the same checkout reuses the same Workspace identity instead of creating another one for every conversation.
|
|
236
236
|
- `code.intelligence` provides definition, hover, references, symbols, and diagnostics.
|
|
237
|
-
-
|
|
237
|
+
- Agent context sources use Config v2: the default system instruction is `~/.agents/AGENTS.md`, project instructions match only `AGENTS.md` by default, and Skills scan `~/.agents/skills` then project `./.agents/skills`. Project/User/Project Local/runtime config can replace those generic sources; ForgeRelay no longer implicitly owns `.forgerelay/skills` sources.
|
|
238
238
|
- Managed worktrees provide real Git isolation when you ask for parallel work. Starting commit and local `targetBranch` are tracked independently, while finalization remains fast-forward-only.
|
|
239
239
|
- MCP Apps-capable hosts can render the Workspace Summary / Activity Panel. Workspace identity colors stay consistent between the panel and pretty logs, and long paths wrap in detail rows instead of hiding their tail.
|
|
240
240
|
- Workspace Relay runs work on another ForgeRelay instance; Composite Workspaces coordinate several independent environments from one Host.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { assertConfigResolutionValid } from "../../../runtime/config/resolution/resolver.js";
|
|
2
|
+
import { resolveGeneralConfigForScope, runGeneralConfigSet, runGeneralConfigUnset, } from "../general.js";
|
|
3
|
+
import { runConfigInspection } from "../inspect.js";
|
|
4
|
+
import { parseConfigScopeArgs } from "../scope.js";
|
|
5
|
+
const CONTEXT_FIELDS = [
|
|
6
|
+
"systemInstructionsPath",
|
|
7
|
+
"instructionNames",
|
|
8
|
+
"skillPaths",
|
|
9
|
+
];
|
|
10
|
+
const CONTEXT_LOGICAL_PATHS = new Set(CONTEXT_FIELDS.map((field) => `config.${field}`));
|
|
11
|
+
export async function runConfigContextCommand(args) {
|
|
12
|
+
const [command, ...rest] = args;
|
|
13
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
14
|
+
console.log(renderConfigContextHelp());
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (command === "get") {
|
|
18
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
19
|
+
if (parsed.rest.length > 0)
|
|
20
|
+
throw new Error(`Unknown config context get option: ${parsed.rest[0]}`);
|
|
21
|
+
const resolution = await resolveGeneralConfigForScope(parsed.scope);
|
|
22
|
+
assertConfigResolutionValid(resolution);
|
|
23
|
+
const output = Object.fromEntries(CONTEXT_FIELDS.map((field) => {
|
|
24
|
+
const entry = resolution.entries[field];
|
|
25
|
+
if (!entry)
|
|
26
|
+
throw new Error(`Missing General Config context field: config.${field}.`);
|
|
27
|
+
return [field, entry.effective.effectiveValue];
|
|
28
|
+
}));
|
|
29
|
+
console.log(JSON.stringify(output, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (command === "set") {
|
|
33
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
34
|
+
if (parsed.rest.length < 2) {
|
|
35
|
+
throw new Error("Usage: forgerelay config context set <field> <value> [--project <path>|--global]");
|
|
36
|
+
}
|
|
37
|
+
const field = normalizeContextField(parsed.rest[0]);
|
|
38
|
+
await runGeneralConfigSet([field, ...parsed.rest.slice(1), ...scopeArgs(parsed.scope)]);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (command === "unset") {
|
|
42
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
43
|
+
if (parsed.rest.length !== 1) {
|
|
44
|
+
throw new Error("Usage: forgerelay config context unset <field> [--project <path>|--global]");
|
|
45
|
+
}
|
|
46
|
+
const field = normalizeContextField(parsed.rest[0]);
|
|
47
|
+
await runGeneralConfigUnset([field, ...scopeArgs(parsed.scope)]);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (command === "check" || command === "sources") {
|
|
51
|
+
process.exitCode = await runConfigInspection([command, ...rest], "config", CONTEXT_LOGICAL_PATHS);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (command === "explain") {
|
|
55
|
+
const parsed = parseConfigScopeArgs(rest);
|
|
56
|
+
if (parsed.rest.length < 1) {
|
|
57
|
+
throw new Error("Usage: forgerelay config context explain <field> [--project <path>|--global] [--json]");
|
|
58
|
+
}
|
|
59
|
+
const field = normalizeContextField(parsed.rest[0]);
|
|
60
|
+
process.exitCode = await runConfigInspection(["explain", `config.${field}`, ...parsed.rest.slice(1), ...scopeArgs(parsed.scope)], "config", CONTEXT_LOGICAL_PATHS);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Unknown config context command: ${command}`);
|
|
64
|
+
}
|
|
65
|
+
function normalizeContextField(value) {
|
|
66
|
+
const field = value.startsWith("config.") ? value.slice("config.".length) : value;
|
|
67
|
+
if (CONTEXT_FIELDS.includes(field))
|
|
68
|
+
return field;
|
|
69
|
+
throw new Error(`Unknown config context field: ${value}. Expected ${CONTEXT_FIELDS.join(", ")}.`);
|
|
70
|
+
}
|
|
71
|
+
function scopeArgs(scope) {
|
|
72
|
+
return scope.mode === "global" ? ["--global"] : ["--project", scope.projectRoot];
|
|
73
|
+
}
|
|
74
|
+
function renderConfigContextHelp() {
|
|
75
|
+
return [
|
|
76
|
+
"ForgeRelay config context",
|
|
77
|
+
"",
|
|
78
|
+
"Usage:",
|
|
79
|
+
" forgerelay config context get [--project <path>|--global]",
|
|
80
|
+
" forgerelay config context set <systemInstructionsPath|instructionNames|skillPaths> <value> [--project <path>|--global]",
|
|
81
|
+
" forgerelay config context unset <systemInstructionsPath|instructionNames|skillPaths> [--project <path>|--global]",
|
|
82
|
+
" forgerelay config context check [--project <path>|--global] [--json]",
|
|
83
|
+
" forgerelay config context sources [--project <path>|--global] [--json]",
|
|
84
|
+
" forgerelay config context explain <systemInstructionsPath|instructionNames|skillPaths> [--project <path>|--global] [--json]",
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|