refactor: replace hardcoded extension list with dynamic discovery in loader

loader.ts previously maintained a hardcoded list of bundled extension paths
for GSD_BUNDLED_EXTENSION_PATHS. This required manual updates whenever
extensions were added or removed, and created a consistency gap with
buildResourceLoader() which already discovers extensions dynamically.

Replace with runtime directory scanning that mirrors the discovery rules
in resource-loader.ts:
- Top-level .ts/.js files → extension entry point
- Directories with index.ts or index.js → extension entry point
- Directories without either (shared/, remote-questions/) → skipped

Benefits:
- Adding a new extension no longer requires editing loader.ts
- GSD_BUNDLED_EXTENSION_PATHS stays in sync with what buildResourceLoader()
  loads in the main process — subagents now receive the same extensions
- Fixes: 5 extensions (google-search, mcporter, ttsr, universal-config,
  voice) were loaded in the main process but missing from
  GSD_BUNDLED_EXTENSION_PATHS, meaning subagents did not receive them
- Eliminates a common source of merge conflicts for contributors and forks
  that add custom extensions
This commit is contained in:
deseltrus 2026-03-14 20:06:08 +01:00
parent fd459041f5
commit aca53c5853
2 changed files with 57 additions and 38 deletions

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { fileURLToPath } from 'url'
import { dirname, resolve, join, delimiter } from 'path'
import { existsSync, readFileSync, mkdirSync, symlinkSync } from 'fs'
import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync } from 'fs'
import { agentDir, appRoot } from './app-paths.js'
import { serializeBundledExtensionPaths } from './bundled-extension-paths.js'
import { renderLogo } from './logo.js'
@ -78,27 +78,40 @@ const srcRes = join(loaderPackageRoot, 'src', 'resources')
const resourcesDir = existsSync(distRes) ? distRes : srcRes
process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md')
// GSD_BUNDLED_EXTENSION_PATHS — platform-delimited list of bundled extension entry point absolute
// paths, used by patched subagent to pass --extension <path> to spawned gsd processes.
// IMPORTANT: paths point to agentDir (~/.gsd/agent/extensions/) NOT src/resources/extensions/.
// initResources() syncs bundled extensions to agentDir before any extension loading occurs,
// so these paths are always valid at runtime. Using agentDir paths matches what buildResourceLoader
// discovers (it scans agentDir), so pi's deduplication works correctly and extensions are not
// double-loaded in subagent child processes.
// Note: shared/ is NOT included — it's a library imported by gsd and ask-user-questions, not an entry point.
process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths([
join(agentDir, 'extensions', 'gsd', 'index.ts'),
join(agentDir, 'extensions', 'bg-shell', 'index.ts'),
join(agentDir, 'extensions', 'browser-tools', 'index.ts'),
join(agentDir, 'extensions', 'context7', 'index.ts'),
join(agentDir, 'extensions', 'search-the-web', 'index.ts'),
join(agentDir, 'extensions', 'slash-commands', 'index.ts'),
join(agentDir, 'extensions', 'subagent', 'index.ts'),
join(agentDir, 'extensions', 'mac-tools', 'index.ts'),
join(agentDir, 'extensions', 'async-jobs', 'index.ts'),
join(agentDir, 'extensions', 'ask-user-questions.ts'),
join(agentDir, 'extensions', 'get-secrets-from-user.ts'),
])
// GSD_BUNDLED_EXTENSION_PATHS — dynamically discovered bundled extension entry points.
// Scans the bundled resources directory to find all extensions, then maps paths to
// agentDir (~/.gsd/agent/extensions/) where initResources() will sync them.
//
// Discovery rules (mirroring resource-loader.ts discoverExtensionEntryPaths):
// - Top-level .ts/.js files → extension entry point
// - Directories with index.ts or index.js → extension entry point
// - Directories without either (e.g. shared/, remote-questions/) → skipped
//
// Previously this was a hardcoded list that required manual updates whenever
// extensions were added or removed — causing merge conflicts in forks and
// falling out of sync with what buildResourceLoader() discovers at runtime.
const bundledExtDir = join(resourcesDir, 'extensions')
const agentExtDir = join(agentDir, 'extensions')
const discoveredExtensionPaths: string[] = []
if (existsSync(bundledExtDir)) {
for (const entry of readdirSync(bundledExtDir, { withFileTypes: true })) {
if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
discoveredExtensionPaths.push(join(agentExtDir, entry.name))
} else if (entry.isDirectory()) {
const srcIndex = existsSync(join(bundledExtDir, entry.name, 'index.ts'))
? 'index.ts'
: existsSync(join(bundledExtDir, entry.name, 'index.js'))
? 'index.js'
: null
if (srcIndex) {
discoveredExtensionPaths.push(join(agentExtDir, entry.name, srcIndex))
}
}
}
}
process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(discoveredExtensionPaths)
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars for all outbound requests.
// pi-coding-agent's cli.ts sets this, but GSD bypasses that entry point — so we

View file

@ -94,22 +94,28 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => {
assert.ok(loaderSrc.includes("serializeBundledExtensionPaths"), "loader uses shared bundled path serializer");
assert.ok(loaderSrc.includes("join(delimiter)"), "loader uses platform delimiter for NODE_PATH");
// Verify all 11 extension entry points are referenced in loader
// Loader uses join() calls like join(agentDir, 'extensions', 'gsd', 'index.ts')
// so we check for the distinguishing directory name of each extension
const extNames = [
"'gsd'",
"'bg-shell'",
"'browser-tools'",
"'context7'",
"'search-the-web'",
"'slash-commands'",
"'subagent'",
"'ask-user-questions.ts'",
"'get-secrets-from-user.ts'",
];
for (const name of extNames) {
assert.ok(loaderSrc.includes(name), `loader references extension ${name}`);
// Verify extension discovery mechanism is in place
// loader.ts now dynamically discovers extensions via readdirSync instead of
// hardcoding paths — verify the discovery infrastructure exists
assert.ok(loaderSrc.includes("readdirSync"), "loader uses readdirSync for extension discovery");
assert.ok(loaderSrc.includes("bundledExtDir"), "loader defines bundledExtDir for scanning");
assert.ok(loaderSrc.includes("discoveredExtensionPaths"), "loader collects discovered paths");
// Verify that the env var is populated at runtime by checking the actual
// extensions directory has discoverable entry points
const { discoverExtensionEntryPaths } = await import("../resource-loader.ts");
const bundledExtensionsDir = join(projectRoot, existsSync(join(projectRoot, "dist", "resources"))
? "dist" : "src", "resources", "extensions");
const discovered = discoverExtensionEntryPaths(bundledExtensionsDir);
assert.ok(discovered.length >= 10, `expected >=10 extensions, found ${discovered.length}`);
// Spot-check that core extensions are discoverable
const discoveredNames = discovered.map(p => {
const rel = p.slice(bundledExtensionsDir.length + 1);
return rel.split(/[\\/]/)[0].replace(/\.ts$/, "");
});
for (const core of ["gsd", "bg-shell", "browser-tools", "subagent", "search-the-web"]) {
assert.ok(discoveredNames.includes(core), `core extension '${core}' is discoverable`);
}
rmSync(tmp, { recursive: true, force: true });