@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -11
  3. package/dist/cli/config/domains/context-cli.js +86 -0
  4. package/dist/cli/config/domains/domain-cli.js +468 -0
  5. package/dist/cli/config/general.js +174 -0
  6. package/dist/cli/config/inspect.js +29 -26
  7. package/dist/cli/config/migrate.js +11 -24
  8. package/dist/cli/config/scope.js +35 -0
  9. package/dist/cli/connect/relay.js +284 -0
  10. package/dist/cli/core/command-tree.js +68 -0
  11. package/dist/cli/core/serve-options.js +71 -0
  12. package/dist/cli/init/setup-config.js +26 -2
  13. package/dist/cli/init.js +37 -8
  14. package/dist/cli/maintenance-prune.js +1 -1
  15. package/dist/cli/maintenance.js +6 -6
  16. package/dist/cli/mcp/external-mcp.js +29 -17
  17. package/dist/cli/mcp/status.js +2 -2
  18. package/dist/cli/setup-support.js +3 -2
  19. package/dist/cli/system/status.js +35 -0
  20. package/dist/cli.js +132 -272
  21. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +2 -2
  22. package/dist/mcp/server/core/schemas.js +2 -10
  23. package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -7
  24. package/dist/runtime/config/config.js +30 -29
  25. package/dist/runtime/config/definition/general-config.js +22 -6
  26. package/dist/runtime/config/external-mcp-config.js +4 -3
  27. package/dist/runtime/config/resolution/resolver.js +7 -4
  28. package/dist/runtime/config/user-config.js +9 -6
  29. package/dist/runtime/config/validation/paths.js +12 -0
  30. package/dist/runtime/config/validation/ports.js +9 -0
  31. package/dist/subagents/profiles.js +37 -0
  32. package/dist/workspaces/bootstrap.js +31 -14
  33. package/dist/workspaces/context.js +159 -7
  34. package/dist/workspaces/relay/auth/cli-test-support.js +22 -0
  35. package/dist/workspaces/resources/context-sources.js +29 -0
  36. package/dist/workspaces/resources/resource-monitor.js +29 -6
  37. package/dist/workspaces/resources/skills.js +15 -10
  38. package/dist/workspaces/sessions.js +4 -2
  39. package/dist/workspaces/state/project-context.js +34 -8
  40. package/dist/workspaces.js +5 -2
  41. package/docs/chatgpt-coding-workflow.md +23 -20
  42. package/docs/configuration.md +40 -27
  43. package/docs/gotchas.md +8 -6
  44. package/docs/roadmap.md +1 -1
  45. package/package.json +2 -2
  46. package/schemas/v1/config.project-local.schema.json +74 -0
  47. package/schemas/v1/config.project.schema.json +74 -0
  48. package/schemas/v1/config.user.schema.json +59 -4
  49. package/scripts/ci/config-v2-product-acceptance.mjs +17 -12
  50. package/scripts/debug/runtime.mjs +19 -3
  51. package/scripts/debug/runtime.test.mjs +3 -0
  52. package/scripts/debug/serve.mjs +2 -2
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_INSTRUCTION_NAMES, DEFAULT_SKILL_PATHS, DEFAULT_SYSTEM_INSTRUCTIONS_PATH, } from "../../runtime/config/definition/general-config.js";
1
2
  export function applySetupConfig(current, selection) {
2
3
  const next = {
3
4
  ...current,
@@ -5,6 +6,8 @@ export function applySetupConfig(current, selection) {
5
6
  allowedRoots: [...selection.allowedRoots],
6
7
  };
7
8
  applyNetworkSelection(next, selection.network);
9
+ if (selection.context)
10
+ applyAgentContextSelection(next, selection.context);
8
11
  if (selection.advanced)
9
12
  applyAdvancedSelection(next, selection.advanced);
10
13
  return next;
@@ -21,8 +24,29 @@ function applyNetworkSelection(config, selection) {
21
24
  }
22
25
  config.publicBaseUrl = selection.publicBaseUrl;
23
26
  }
24
- if (selection.mode === "proxy")
25
- config.trustedProxies = ["loopback"];
27
+ }
28
+ function applyAgentContextSelection(config, selection) {
29
+ if (selection.systemInstructionsPath === DEFAULT_SYSTEM_INSTRUCTIONS_PATH) {
30
+ delete config.systemInstructionsPath;
31
+ }
32
+ else {
33
+ config.systemInstructionsPath = selection.systemInstructionsPath;
34
+ }
35
+ if (sameStringList(selection.instructionNames, DEFAULT_INSTRUCTION_NAMES)) {
36
+ delete config.instructionNames;
37
+ }
38
+ else {
39
+ config.instructionNames = [...selection.instructionNames];
40
+ }
41
+ if (sameStringList(selection.skillPaths, DEFAULT_SKILL_PATHS)) {
42
+ delete config.skillPaths;
43
+ }
44
+ else {
45
+ config.skillPaths = [...selection.skillPaths];
46
+ }
47
+ }
48
+ function sameStringList(left, right) {
49
+ return left.length === right.length && left.every((value, index) => value === right[index]);
26
50
  }
27
51
  function applyAdvancedSelection(config, selection) {
28
52
  if (selection.port === 7676)
package/dist/cli/init.js CHANGED
@@ -1,16 +1,21 @@
1
- import { resolve } from "node:path";
2
1
  import * as prompts from "@clack/prompts";
3
2
  import { publicEndpointUrl } from "../mcp/oauth/public-url.js";
4
- import { expandHomePath } from "../mcp/filesystem/roots.js";
3
+ import { normalizeAllowedRootPaths } from "../runtime/config/validation/paths.js";
5
4
  import { detectLauncherCommandShell, } from "../runtime/shell/command-shell-runtime.js";
6
5
  import { seedShellInstructionFiles, shellInstructionFamiliesToSeed, } from "../runtime/instructions/shell-instructions.js";
7
6
  import { installManagedLanguageServers, installedManagedLanguageServers, managedLanguageServerOptions, } from "../lsp/runtime/managed-language-servers.js";
8
7
  import { generateInstanceId, generateOwnerToken, loadForgeRelayFiles, writeForgeRelayAuth, writeForgeRelayConfig, } from "../runtime/config/user-config.js";
9
- import { generalConfigDefinition } from "../runtime/config/definition/general-config.js";
8
+ import { DEFAULT_INSTRUCTION_NAMES, DEFAULT_SKILL_PATHS, DEFAULT_SYSTEM_INSTRUCTIONS_PATH, generalConfigDefinition, } from "../runtime/config/definition/general-config.js";
10
9
  import { configSchemaId } from "../runtime/config/definition/schema.js";
11
10
  import { classifyClientFacingBaseUrl, compactPublicBaseUrlConfig, hasInsecureLanBaseUrl, isLoopbackBindAddress, normalizePublicBaseUrlsInput, SetupCancelledError, textPrompt, validateHttpsProxyBaseUrls, validateLanClientFacingBaseUrls, validatePort, } from "./setup-support.js";
12
11
  import { commandShellCompatibilityWarning, commandShellSetupOptions, customPinnedPreference, defaultCommandShellSetupChoice, followLauncherPreference, pinnedFamilyPreference, preservePinnedPreference, shellFamiliesForCustomSelection, } from "./shell/setup.js";
13
- import { applySetupConfig } from "./init/setup-config.js";
12
+ import { applySetupConfig, } from "./init/setup-config.js";
13
+ function parseSetupList(value) {
14
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
15
+ }
16
+ function validateSetupList(value) {
17
+ return value && parseSetupList(value).length > 0 ? undefined : "Enter at least one comma-separated value.";
18
+ }
14
19
  export async function runInit({ force, advanced, version }) {
15
20
  const files = loadForgeRelayFiles();
16
21
  if (!force && !advanced && files.configExists && files.authExists) {
@@ -27,10 +32,33 @@ export async function runInit({ force, advanced, version }) {
27
32
  defaultValue: defaultRoots,
28
33
  validate: (value) => value?.trim() ? undefined : "Enter at least one project root.",
29
34
  });
30
- const allowedRoots = rootsAnswer
31
- .split(",")
32
- .map((root) => resolve(expandHomePath(root.trim())))
33
- .filter(Boolean);
35
+ const allowedRoots = normalizeAllowedRootPaths(rootsAnswer.split(","));
36
+ const defaultSystemInstructionsPath = files.config.systemInstructionsPath ?? DEFAULT_SYSTEM_INSTRUCTIONS_PATH;
37
+ const systemInstructionsPath = (await textPrompt({
38
+ message: `Which system instruction file should ForgeRelay load? Press Enter to use ${defaultSystemInstructionsPath}`,
39
+ placeholder: defaultSystemInstructionsPath,
40
+ defaultValue: defaultSystemInstructionsPath,
41
+ validate: (value) => value?.trim() ? undefined : "Enter an instruction file path.",
42
+ })).trim();
43
+ const defaultInstructionNames = (files.config.instructionNames ?? DEFAULT_INSTRUCTION_NAMES).join(", ");
44
+ const instructionNames = parseSetupList(await textPrompt({
45
+ message: `Which project instruction filenames should ForgeRelay discover? Press Enter to use ${defaultInstructionNames}`,
46
+ placeholder: defaultInstructionNames,
47
+ defaultValue: defaultInstructionNames,
48
+ validate: validateSetupList,
49
+ }));
50
+ const defaultSkillPaths = (files.config.skillPaths ?? DEFAULT_SKILL_PATHS).join(", ");
51
+ const skillPaths = parseSetupList(await textPrompt({
52
+ message: `Which Skill directories should ForgeRelay scan? Press Enter to use ${defaultSkillPaths}`,
53
+ placeholder: defaultSkillPaths,
54
+ defaultValue: defaultSkillPaths,
55
+ validate: validateSetupList,
56
+ }));
57
+ const contextSelection = {
58
+ systemInstructionsPath,
59
+ instructionNames,
60
+ skillPaths,
61
+ };
34
62
  const defaultPort = String(files.config.port ?? 7676);
35
63
  const port = advanced
36
64
  ? Number(await textPrompt({
@@ -231,6 +259,7 @@ export async function runInit({ force, advanced, version }) {
231
259
  mode: networkMode,
232
260
  ...(publicBaseUrl === undefined ? {} : { publicBaseUrl }),
233
261
  },
262
+ context: contextSelection,
234
263
  ...(advancedSelection ? { advanced: advancedSelection } : {}),
235
264
  });
236
265
  const auth = {
@@ -91,7 +91,7 @@ export function pruneMaintenanceState(stateDir, policy, now = new Date()) {
91
91
  }
92
92
  }
93
93
  export function printMaintenancePruneReport(report) {
94
- console.log("ForgeRelay maintenance prune");
94
+ console.log("ForgeRelay system prune");
95
95
  console.log(`State directory: ${report.stateDir}`);
96
96
  console.log(`Historical retention: ${report.historicalAuthorized ? `authorized before ${report.cutoff}` : "not authorized (unlimited)"}`);
97
97
  console.log(`Orphan administrative cleanup: ${report.administrativeAuthorized ? "authorized" : "not authorized"}`);
@@ -23,12 +23,12 @@ export function runMaintenanceCommand(args, env = process.env) {
23
23
  return;
24
24
  }
25
25
  if (subcommand !== "inspect" && subcommand !== "prune") {
26
- throw new Error(`Unknown maintenance command: ${subcommand}`);
26
+ throw new Error(`Unknown system maintenance command: ${subcommand}`);
27
27
  }
28
28
  const json = rest.includes("--json");
29
29
  const unknown = rest.filter((value) => value !== "--json");
30
30
  if (unknown.length > 0)
31
- throw new Error(`Unknown maintenance ${subcommand} option: ${unknown[0]}`);
31
+ throw new Error(`Unknown system ${subcommand} option: ${unknown[0]}`);
32
32
  const inspection = inspectMaintenanceState(env);
33
33
  if (subcommand === "inspect") {
34
34
  if (json) {
@@ -567,7 +567,7 @@ function errorMessage(error) {
567
567
  return error instanceof Error ? error.message : String(error);
568
568
  }
569
569
  function printMaintenanceReport(report) {
570
- console.log(`ForgeRelay maintenance inspection`);
570
+ console.log(`ForgeRelay system inspection`);
571
571
  console.log(`State directory: ${report.stateDir}`);
572
572
  console.log(`Database: ${report.database}`);
573
573
  console.log(`Retention: durable history ${report.policy.durableHistory}; orphaned administrative cleanup ${report.policy.orphanedAdministrativeState ? "enabled" : "disabled"}`);
@@ -595,11 +595,11 @@ function formatBytes(bytes) {
595
595
  }
596
596
  function printMaintenanceHelp() {
597
597
  console.log([
598
- "ForgeRelay maintenance",
598
+ "ForgeRelay system",
599
599
  "",
600
600
  "Usage:",
601
- " forgerelay maintenance inspect [--json]",
602
- " forgerelay maintenance prune [--json]",
601
+ " forgerelay system inspect [--json]",
602
+ " forgerelay system prune [--json]",
603
603
  "",
604
604
  "Inspection is read-only. Durable history is retained without an age limit unless retention.historyDays is explicitly configured.",
605
605
  "Prune is manual owner maintenance and removes only categories authorized by the configured retention policy.",
@@ -6,14 +6,17 @@ import { computeScopeUnion, selectClientAuthMethod, } from "@modelcontextprotoco
6
6
  import { externalMcpCredentialIdentity, } from "../../runtime/config/external-mcp-auth-store.js";
7
7
  import { ExternalMcpInteractiveOAuthProvider, beginExternalMcpInteractiveOAuth, finishExternalMcpInteractiveOAuth, } from "../../mcp/operations/external-mcp/external-mcp-oauth.js";
8
8
  import { ExternalMcpError, ExternalMcpGateway, } from "../../mcp/operations/external-mcp/external-mcp.js";
9
- import { findExternalMcpServerStatus, formatAuth, formatExternalMcpList, inspectExternalMcpStatus, resolveExternalMcpScope, } from "./status.js";
9
+ import { findExternalMcpServerStatus, formatAuth, formatExternalMcpStatus, inspectExternalMcpStatus, resolveExternalMcpScope, } from "./status.js";
10
10
  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1_000;
11
11
  export async function runExternalMcpCommand(args, dependencies = {}) {
12
12
  const [subcommand, ...rest] = args;
13
13
  switch (subcommand) {
14
+ case "status":
15
+ await runExternalMcpStatus(parseMcpScopeArgs("status", rest), dependencies);
16
+ return;
14
17
  case "list":
15
18
  case "ls":
16
- await runExternalMcpList(parseMcpScopeArgs("list", rest), dependencies);
19
+ await runExternalMcpStatus(parseMcpScopeArgs("list", rest), dependencies);
17
20
  return;
18
21
  case "test":
19
22
  await runExternalMcpTest(parseMcpTargetArgs("test", rest), dependencies);
@@ -31,7 +34,7 @@ export async function runExternalMcpCommand(args, dependencies = {}) {
31
34
  printMcpHelp();
32
35
  return;
33
36
  default:
34
- throw new Error(`Unknown mcp command: ${subcommand}`);
37
+ throw new Error(`Unknown connect mcp command: ${subcommand}`);
35
38
  }
36
39
  }
37
40
  function parseMcpScopeArgs(command, args) {
@@ -51,7 +54,7 @@ function parseMcpScopeArgs(command, args) {
51
54
  continue;
52
55
  }
53
56
  if (arg.startsWith("-"))
54
- throw new Error(`Unknown mcp ${command} option: ${arg}`);
57
+ throw new Error(`Unknown connect mcp ${command} option: ${arg}`);
55
58
  rest.push(arg);
56
59
  }
57
60
  if (global && projectRoot)
@@ -65,7 +68,7 @@ function parseMcpScopeArgs(command, args) {
65
68
  function parseMcpTargetArgs(command, args) {
66
69
  const parsed = parseMcpScopeArgs(command, args);
67
70
  if (parsed.rest.length !== 1) {
68
- throw new Error(`Usage: forgerelay mcp ${command} <server> [--project <path>|--global]`);
71
+ throw new Error(`Usage: forgerelay connect mcp ${command} <server> [--project <path>|--global]`);
69
72
  }
70
73
  return {
71
74
  server: parsed.rest[0],
@@ -73,13 +76,22 @@ function parseMcpTargetArgs(command, args) {
73
76
  ...(parsed.global ? { global: true } : {}),
74
77
  };
75
78
  }
76
- async function runExternalMcpList(options, dependencies) {
77
- if (options.rest.length > 0) {
78
- throw new Error("Usage: forgerelay mcp list [--project <path>|--global]");
79
+ async function runExternalMcpStatus(options, dependencies) {
80
+ if (options.rest.length > 1) {
81
+ throw new Error("Usage: forgerelay connect mcp status [server] [--project <path>|--global]");
79
82
  }
80
83
  const scope = await resolveExternalMcpScope(options, dependencies);
81
84
  const status = inspectExternalMcpStatus(scope);
82
- console.log(formatExternalMcpList(status));
85
+ const serverName = options.rest[0];
86
+ if (serverName) {
87
+ const server = findExternalMcpServerStatus(status, serverName);
88
+ if (!server)
89
+ throw new Error(`Unknown configured External MCP server: ${serverName}.`);
90
+ console.log(formatExternalMcpStatus({ ...status, servers: [server] }));
91
+ }
92
+ else {
93
+ console.log(formatExternalMcpStatus(status));
94
+ }
83
95
  if (status.configIssues > 0) {
84
96
  throw new Error("External MCP configuration or credential status contains issues.");
85
97
  }
@@ -144,8 +156,8 @@ function printTestTarget(scope, server) {
144
156
  }
145
157
  function externalMcpAuthCommand(scope, server) {
146
158
  return scope.mode === "global"
147
- ? `forgerelay mcp auth ${cliArgument(server)} --global`
148
- : `forgerelay mcp auth ${cliArgument(server)} --project ${cliArgument(scope.projectRoot)}`;
159
+ ? `forgerelay connect mcp auth ${cliArgument(server)} --global`
160
+ : `forgerelay connect mcp auth ${cliArgument(server)} --project ${cliArgument(scope.projectRoot)}`;
149
161
  }
150
162
  function cliArgument(value) {
151
163
  return /^[A-Za-z0-9_./:\\-]+$/.test(value) ? value : JSON.stringify(value);
@@ -340,7 +352,7 @@ function hasStaticAuthorizationHeader(headers) {
340
352
  function assertInteractive(dependencies) {
341
353
  const interactive = dependencies.isInteractive ?? (Boolean(input.isTTY) && Boolean(output.isTTY));
342
354
  if (!interactive) {
343
- throw new Error("forgerelay mcp auth requires an interactive terminal for browser/callback authorization.");
355
+ throw new Error("forgerelay connect mcp auth requires an interactive terminal for browser/callback authorization.");
344
356
  }
345
357
  }
346
358
  async function promptMaskedCallback(dependencies) {
@@ -474,12 +486,12 @@ function isHeadlessEnvironment(env) {
474
486
  }
475
487
  function printMcpHelp() {
476
488
  console.log([
477
- "ForgeRelay mcp",
489
+ "ForgeRelay connect mcp",
478
490
  "",
479
491
  "Usage:",
480
- " forgerelay mcp list [--project <path>|--global]",
481
- " forgerelay mcp test <server> [--project <path>|--global]",
482
- " forgerelay mcp auth <server> [--project <path>|--global]",
483
- " forgerelay mcp logout <server> [--project <path>|--global]",
492
+ " forgerelay connect mcp status [server] [--project <path>|--global]",
493
+ " forgerelay connect mcp test <server> [--project <path>|--global]",
494
+ " forgerelay connect mcp auth <server> [--project <path>|--global]",
495
+ " forgerelay connect mcp logout <server> [--project <path>|--global]",
484
496
  ].join("\n"));
485
497
  }
@@ -72,7 +72,7 @@ export function inspectExternalMcpStatus(scope) {
72
72
  export function findExternalMcpServerStatus(status, name) {
73
73
  return status.servers.find((server) => server.name === name);
74
74
  }
75
- export function formatExternalMcpList(status) {
75
+ export function formatExternalMcpStatus(status) {
76
76
  const lines = ["External MCP", ""];
77
77
  if (status.scope.mode === "global") {
78
78
  lines.push("Scope: global");
@@ -147,7 +147,7 @@ export function formatExternalMcpDoctor(status) {
147
147
  ` Credential store: ${status.credentialStore}`,
148
148
  ` Config issues: ${status.configIssues}`,
149
149
  " Hot reload: active",
150
- " Active checks: not run (use `forgerelay mcp test <server>`)",
150
+ " Active checks: not run (use `forgerelay connect mcp test <server>`)",
151
151
  ].join("\n");
152
152
  }
153
153
  export function formatAuth(status) {
@@ -2,6 +2,7 @@ import { isIP } from "node:net";
2
2
  import { createRequire } from "node:module";
3
3
  import * as prompts from "@clack/prompts";
4
4
  import { satisfies } from "semver";
5
+ import { isIntegerPort, LISTEN_PORT_MIN, PORT_MAX } from "../runtime/config/validation/ports.js";
5
6
  const SUPPORTED_NODE_RANGE = ">=20.12 <27";
6
7
  const require = createRequire(import.meta.url);
7
8
  export function isNullConfigValue(value) {
@@ -118,9 +119,9 @@ export async function textPrompt(options) {
118
119
  }
119
120
  export function validatePort(value) {
120
121
  const port = Number(value);
121
- return Number.isInteger(port) && port >= 1 && port <= 65535
122
+ return isIntegerPort(port, LISTEN_PORT_MIN, PORT_MAX)
122
123
  ? undefined
123
- : "Enter a port between 1 and 65535.";
124
+ : `Enter a port between ${LISTEN_PORT_MIN} and ${PORT_MAX}.`;
124
125
  }
125
126
  export function isLoopbackBindAddress(value) {
126
127
  const host = value.trim().toLowerCase();
@@ -0,0 +1,35 @@
1
+ import { resolveRuntimeConfigFacts } from "../../runtime/config/config.js";
2
+ import { inspectRuntimeLease } from "../../runtime/state/runtime-lease.js";
3
+ export function inspectSystemStatus(env = process.env) {
4
+ const config = resolveRuntimeConfigFacts(env);
5
+ return {
6
+ ...(config.instanceId ? { instanceId: config.instanceId } : {}),
7
+ stateDir: config.stateDir,
8
+ host: config.host,
9
+ port: config.port,
10
+ publicBaseUrl: config.publicBaseUrl,
11
+ lease: inspectRuntimeLease(config.stateDir),
12
+ };
13
+ }
14
+ export function runSystemStatus(env = process.env) {
15
+ const status = inspectSystemStatus(env);
16
+ console.log([
17
+ "ForgeRelay system status",
18
+ "",
19
+ `Instance: ${status.instanceId ?? "not initialized"}`,
20
+ `Runtime: ${formatRuntimeState(status.lease)}`,
21
+ ...(status.lease.pid === undefined ? [] : [`PID: ${status.lease.pid}`]),
22
+ `State dir: ${status.stateDir}`,
23
+ `Configured bind: ${status.host}:${status.port}`,
24
+ `Configured public URL: ${status.publicBaseUrl}`,
25
+ ].join("\n"));
26
+ }
27
+ function formatRuntimeState(lease) {
28
+ if (lease.malformed)
29
+ return "unknown (malformed runtime lease)";
30
+ if (lease.active)
31
+ return "running";
32
+ if (lease.stale)
33
+ return "not running (stale runtime lease present)";
34
+ return "not running";
35
+ }