From 92f0b152685c346d5df83e793011c22d4b59507d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 18 Mar 2026 14:12:19 -0600 Subject: [PATCH] feat: add extension manifest + registry for user-managed enable/disable (#1238) Every extension gets a declarative extension-manifest.json (id, tier, provides, dependencies). A persistent registry at ~/.gsd/extensions/registry.json tracks enabled/disabled state. `gsd extensions` command family (list, enable, disable, info) lets users manage extensions without touching source code. Registry gate filters disabled extensions in loader.ts and resource-loader.ts before paths reach loadExtensions(). Zero breakage: extensions without manifests default to enabled, fresh installs have an empty registry. Co-authored-by: Claude Opus 4.6 (1M context) --- src/extension-registry.ts | 219 ++++++++++++ src/loader.ts | 12 +- src/resource-loader.ts | 13 +- .../async-jobs/extension-manifest.json | 13 + .../bg-shell/extension-manifest.json | 14 + .../browser-tools/extension-manifest.json | 37 ++ .../context7/extension-manifest.json | 12 + .../google-search/extension-manifest.json | 12 + .../extensions/gsd/commands-extensions.ts | 328 ++++++++++++++++++ src/resources/extensions/gsd/commands.ts | 52 ++- .../extensions/gsd/extension-manifest.json | 18 + .../mac-tools/extension-manifest.json | 16 + .../mcporter/extension-manifest.json | 12 + .../remote-questions/extension-manifest.json | 11 + .../search-the-web/extension-manifest.json | 13 + .../slash-commands/extension-manifest.json | 11 + .../subagent/extension-manifest.json | 13 + .../extensions/ttsr/extension-manifest.json | 11 + .../universal-config/extension-manifest.json | 13 + .../extensions/voice/extension-manifest.json | 12 + 20 files changed, 835 insertions(+), 7 deletions(-) create mode 100644 src/extension-registry.ts create mode 100644 src/resources/extensions/async-jobs/extension-manifest.json create mode 100644 src/resources/extensions/bg-shell/extension-manifest.json create mode 100644 src/resources/extensions/browser-tools/extension-manifest.json create mode 100644 src/resources/extensions/context7/extension-manifest.json create mode 100644 src/resources/extensions/google-search/extension-manifest.json create mode 100644 src/resources/extensions/gsd/commands-extensions.ts create mode 100644 src/resources/extensions/gsd/extension-manifest.json create mode 100644 src/resources/extensions/mac-tools/extension-manifest.json create mode 100644 src/resources/extensions/mcporter/extension-manifest.json create mode 100644 src/resources/extensions/remote-questions/extension-manifest.json create mode 100644 src/resources/extensions/search-the-web/extension-manifest.json create mode 100644 src/resources/extensions/slash-commands/extension-manifest.json create mode 100644 src/resources/extensions/subagent/extension-manifest.json create mode 100644 src/resources/extensions/ttsr/extension-manifest.json create mode 100644 src/resources/extensions/universal-config/extension-manifest.json create mode 100644 src/resources/extensions/voice/extension-manifest.json diff --git a/src/extension-registry.ts b/src/extension-registry.ts new file mode 100644 index 000000000..0f30eeefd --- /dev/null +++ b/src/extension-registry.ts @@ -0,0 +1,219 @@ +/** + * Extension Registry — manages manifest reading, registry persistence, and enable/disable state. + * + * Extensions without manifests always load (backwards compatible). + * A fresh install has an empty registry — all extensions enabled by default. + * The only way an extension stops loading is an explicit `gsd extensions disable `. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface ExtensionManifest { + id: string; + name: string; + version: string; + description: string; + tier: "core" | "bundled" | "community"; + requires: { platform: string }; + provides?: { + tools?: string[]; + commands?: string[]; + hooks?: string[]; + shortcuts?: string[]; + }; + dependencies?: { + extensions?: string[]; + runtime?: string[]; + }; +} + +export interface ExtensionRegistryEntry { + id: string; + enabled: boolean; + source: "bundled" | "user" | "project"; + disabledAt?: string; + disabledReason?: string; +} + +export interface ExtensionRegistry { + version: 1; + entries: Record; +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +function isRegistry(data: unknown): data is ExtensionRegistry { + if (typeof data !== "object" || data === null) return false; + const obj = data as Record; + return obj.version === 1 && typeof obj.entries === "object" && obj.entries !== null; +} + +function isManifest(data: unknown): data is ExtensionManifest { + if (typeof data !== "object" || data === null) return false; + const obj = data as Record; + return ( + typeof obj.id === "string" && + typeof obj.name === "string" && + typeof obj.version === "string" && + typeof obj.tier === "string" + ); +} + +// ─── Registry Path ────────────────────────────────────────────────────────── + +export function getRegistryPath(): string { + return join(homedir(), ".gsd", "extensions", "registry.json"); +} + +// ─── Registry I/O ─────────────────────────────────────────────────────────── + +function defaultRegistry(): ExtensionRegistry { + return { version: 1, entries: {} }; +} + +export function loadRegistry(): ExtensionRegistry { + const filePath = getRegistryPath(); + try { + if (!existsSync(filePath)) return defaultRegistry(); + const raw = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + return isRegistry(parsed) ? parsed : defaultRegistry(); + } catch { + return defaultRegistry(); + } +} + +export function saveRegistry(registry: ExtensionRegistry): void { + const filePath = getRegistryPath(); + try { + mkdirSync(dirname(filePath), { recursive: true }); + const tmp = filePath + ".tmp"; + writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf-8"); + renameSync(tmp, filePath); + } catch { + // Non-fatal — don't let persistence failures break operation + } +} + +// ─── Query ────────────────────────────────────────────────────────────────── + +/** Returns true if the extension is enabled (missing entries default to enabled). */ +export function isExtensionEnabled(registry: ExtensionRegistry, id: string): boolean { + const entry = registry.entries[id]; + if (!entry) return true; + return entry.enabled; +} + +// ─── Mutations ────────────────────────────────────────────────────────────── + +export function enableExtension(registry: ExtensionRegistry, id: string): void { + const entry = registry.entries[id]; + if (entry) { + entry.enabled = true; + delete entry.disabledAt; + delete entry.disabledReason; + } else { + registry.entries[id] = { id, enabled: true, source: "bundled" }; + } +} + +/** + * Disable an extension. Returns an error string if the extension is core (cannot disable), + * or null on success. + */ +export function disableExtension( + registry: ExtensionRegistry, + id: string, + manifest: ExtensionManifest | null, + reason?: string, +): string | null { + if (manifest?.tier === "core") { + return `Cannot disable "${id}" — it is a core extension.`; + } + const entry = registry.entries[id]; + if (entry) { + entry.enabled = false; + entry.disabledAt = new Date().toISOString(); + entry.disabledReason = reason; + } else { + registry.entries[id] = { + id, + enabled: false, + source: "bundled", + disabledAt: new Date().toISOString(), + disabledReason: reason, + }; + } + return null; +} + +// ─── Manifest Reading ─────────────────────────────────────────────────────── + +/** Read extension-manifest.json from a directory. Returns null if missing or invalid. */ +export function readManifest(extensionDir: string): ExtensionManifest | null { + const manifestPath = join(extensionDir, "extension-manifest.json"); + if (!existsSync(manifestPath)) return null; + try { + const raw = JSON.parse(readFileSync(manifestPath, "utf-8")); + return isManifest(raw) ? raw : null; + } catch { + return null; + } +} + +/** + * Given an entry path (e.g. `.../extensions/browser-tools/index.ts`), + * resolve the parent directory and read its manifest. + */ +export function readManifestFromEntryPath(entryPath: string): ExtensionManifest | null { + const dir = dirname(entryPath); + return readManifest(dir); +} + +// ─── Discovery ────────────────────────────────────────────────────────────── + +/** Scan all subdirectories of extensionsDir for manifests. Returns a Map. */ +export function discoverAllManifests(extensionsDir: string): Map { + const manifests = new Map(); + if (!existsSync(extensionsDir)) return manifests; + + for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifest = readManifest(join(extensionsDir, entry.name)); + if (manifest) { + manifests.set(manifest.id, manifest); + } + } + return manifests; +} + +/** + * Auto-populate registry entries for newly discovered extensions. + * Extensions already in the registry are left untouched. + */ +export function ensureRegistryEntries(extensionsDir: string): void { + const manifests = discoverAllManifests(extensionsDir); + if (manifests.size === 0) return; + + const registry = loadRegistry(); + let changed = false; + + for (const [id, manifest] of manifests) { + if (!registry.entries[id]) { + registry.entries[id] = { + id, + enabled: true, + source: "bundled", + }; + changed = true; + } + } + + if (changed) { + saveRegistry(registry); + } +} diff --git a/src/loader.ts b/src/loader.ts index 3ff0baa97..f40e2e0c5 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -33,6 +33,7 @@ if (firstArg === '--help' || firstArg === '-h') { import { agentDir, appRoot } from './app-paths.js' import { serializeBundledExtensionPaths } from './bundled-extension-paths.js' import { discoverExtensionEntryPaths } from './extension-discovery.js' +import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled } from './extension-registry.js' import { renderLogo } from './logo.js' // pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's @@ -101,9 +102,14 @@ process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md') // where initResources() will sync them. const bundledExtDir = join(resourcesDir, 'extensions') const agentExtDir = join(agentDir, 'extensions') -const discoveredExtensionPaths = discoverExtensionEntryPaths(bundledExtDir).map( - (entryPath) => join(agentExtDir, relative(bundledExtDir, entryPath)), -) +const registry = loadRegistry() +const discoveredExtensionPaths = discoverExtensionEntryPaths(bundledExtDir) + .map((entryPath) => join(agentExtDir, relative(bundledExtDir, entryPath))) + .filter((entryPath) => { + const manifest = readManifestFromEntryPath(entryPath) + if (!manifest) return true // no manifest = always load + return isExtensionEnabled(registry, manifest.id) + }) process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(discoveredExtensionPaths) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index f278b92b1..103ed6a01 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -6,6 +6,7 @@ import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { compareSemver } from './update-check.js' import { discoverExtensionEntryPaths } from './extension-discovery.js' +import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegistryEntries } from './extension-registry.js' // Resolve resources directory — prefer dist/resources/ (stable, set at build time) // over src/resources/ (live working tree, changes with git branch). @@ -240,6 +241,7 @@ export function initResources(agentDir: string): void { makeTreeWritable(agentDir) writeManagedResourceManifest(agentDir) + ensureRegistryEntries(join(agentDir, 'extensions')) } /** @@ -260,12 +262,17 @@ function getBundledExtensionKeys(): Set { } export function buildResourceLoader(agentDir: string): DefaultResourceLoader { + const registry = loadRegistry() const piAgentDir = join(homedir(), '.pi', 'agent') const piExtensionsDir = join(piAgentDir, 'extensions') const bundledKeys = getBundledExtensionKeys() - const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir).filter( - (entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)), - ) + const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir) + .filter((entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir))) + .filter((entryPath) => { + const manifest = readManifestFromEntryPath(entryPath) + if (!manifest) return true + return isExtensionEnabled(registry, manifest.id) + }) return new DefaultResourceLoader({ agentDir, diff --git a/src/resources/extensions/async-jobs/extension-manifest.json b/src/resources/extensions/async-jobs/extension-manifest.json new file mode 100644 index 000000000..d849a5cab --- /dev/null +++ b/src/resources/extensions/async-jobs/extension-manifest.json @@ -0,0 +1,13 @@ +{ + "id": "async-jobs", + "name": "Async Jobs", + "version": "1.0.0", + "description": "Run bash commands in the background with job tracking and cancellation", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["async_bash", "await_job", "cancel_job"], + "commands": ["jobs"], + "hooks": ["session_start"] + } +} diff --git a/src/resources/extensions/bg-shell/extension-manifest.json b/src/resources/extensions/bg-shell/extension-manifest.json new file mode 100644 index 000000000..952ed8ace --- /dev/null +++ b/src/resources/extensions/bg-shell/extension-manifest.json @@ -0,0 +1,14 @@ +{ + "id": "bg-shell", + "name": "Background Shell", + "version": "1.0.0", + "description": "Run and manage background shell processes with interactive monitoring", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["bg_shell"], + "commands": ["bg"], + "hooks": ["session_shutdown"], + "shortcuts": ["Ctrl+Alt+B"] + } +} diff --git a/src/resources/extensions/browser-tools/extension-manifest.json b/src/resources/extensions/browser-tools/extension-manifest.json new file mode 100644 index 000000000..f6156ebbd --- /dev/null +++ b/src/resources/extensions/browser-tools/extension-manifest.json @@ -0,0 +1,37 @@ +{ + "id": "browser-tools", + "name": "Browser Tools", + "version": "1.0.0", + "description": "Playwright-based web automation, screenshots, and analysis", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": [ + "browser_navigate", "browser_go_back", "browser_go_forward", "browser_reload", + "browser_click", "browser_drag", "browser_type", "browser_upload_file", + "browser_scroll", "browser_hover", "browser_key_press", "browser_select_option", + "browser_set_checked", "browser_screenshot", "browser_wait_for", + "browser_get_console_logs", "browser_get_network_logs", "browser_get_dialog_logs", + "browser_evaluate", "browser_get_accessibility_tree", "browser_find", + "browser_get_page_source", "browser_close", + "browser_trace_start", "browser_trace_stop", "browser_export_har", + "browser_timeline", "browser_session_summary", "browser_debug_bundle", + "browser_assert", "browser_diff", "browser_batch", + "browser_snapshot_refs", "browser_get_ref", "browser_click_ref", + "browser_hover_ref", "browser_fill_ref", + "browser_list_pages", "browser_switch_page", "browser_close_page", + "browser_list_frames", "browser_select_frame", + "browser_analyze_form", "browser_fill_form", + "browser_find_best", "browser_act", + "browser_save_pdf", "browser_save_state", "browser_restore_state", + "browser_mock_route", "browser_block_urls", "browser_clear_routes", + "browser_emulate_device", "browser_extract", + "browser_visual_diff", "browser_zoom_region", + "browser_generate_test", "browser_action_cache", "browser_check_injection" + ], + "hooks": ["session_shutdown"] + }, + "dependencies": { + "runtime": ["playwright"] + } +} diff --git a/src/resources/extensions/context7/extension-manifest.json b/src/resources/extensions/context7/extension-manifest.json new file mode 100644 index 000000000..e95788267 --- /dev/null +++ b/src/resources/extensions/context7/extension-manifest.json @@ -0,0 +1,12 @@ +{ + "id": "context7", + "name": "Context7", + "version": "1.0.0", + "description": "Fetch up-to-date library documentation and code examples from Context7", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["resolve_library", "get_library_docs"], + "hooks": ["session_start"] + } +} diff --git a/src/resources/extensions/google-search/extension-manifest.json b/src/resources/extensions/google-search/extension-manifest.json new file mode 100644 index 000000000..b2938627d --- /dev/null +++ b/src/resources/extensions/google-search/extension-manifest.json @@ -0,0 +1,12 @@ +{ + "id": "google-search", + "name": "Google Search", + "version": "1.0.0", + "description": "Web search via Google with AI-synthesized answers and source citations", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["google_search"], + "hooks": ["session_start"] + } +} diff --git a/src/resources/extensions/gsd/commands-extensions.ts b/src/resources/extensions/gsd/commands-extensions.ts new file mode 100644 index 000000000..95a51b18d --- /dev/null +++ b/src/resources/extensions/gsd/commands-extensions.ts @@ -0,0 +1,328 @@ +/** + * GSD Extensions Command — /gsd extensions + * + * Manage the extension registry: list, enable, disable, info. + * Self-contained — no imports outside the extensions tree (extensions are loaded + * via jiti at runtime from ~/.gsd/agent/, not compiled by tsc). + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +// ─── Types (mirrored from extension-registry.ts) ──────────────────────────── + +interface ExtensionManifest { + id: string; + name: string; + version: string; + description: string; + tier: "core" | "bundled" | "community"; + requires: { platform: string }; + provides?: { + tools?: string[]; + commands?: string[]; + hooks?: string[]; + shortcuts?: string[]; + }; + dependencies?: { + extensions?: string[]; + runtime?: string[]; + }; +} + +interface ExtensionRegistryEntry { + id: string; + enabled: boolean; + source: "bundled" | "user" | "project"; + disabledAt?: string; + disabledReason?: string; +} + +interface ExtensionRegistry { + version: 1; + entries: Record; +} + +// ─── Registry I/O ─────────────────────────────────────────────────────────── + +function getRegistryPath(): string { + return join(homedir(), ".gsd", "extensions", "registry.json"); +} + +function getAgentExtensionsDir(): string { + return join(homedir(), ".gsd", "agent", "extensions"); +} + +function loadRegistry(): ExtensionRegistry { + const filePath = getRegistryPath(); + try { + if (!existsSync(filePath)) return { version: 1, entries: {} }; + const raw = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null && parsed.version === 1 && typeof parsed.entries === "object") { + return parsed as ExtensionRegistry; + } + return { version: 1, entries: {} }; + } catch { + return { version: 1, entries: {} }; + } +} + +function saveRegistry(registry: ExtensionRegistry): void { + const filePath = getRegistryPath(); + try { + mkdirSync(dirname(filePath), { recursive: true }); + const tmp = filePath + ".tmp"; + writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf-8"); + renameSync(tmp, filePath); + } catch { /* non-fatal */ } +} + +function isEnabled(registry: ExtensionRegistry, id: string): boolean { + const entry = registry.entries[id]; + if (!entry) return true; + return entry.enabled; +} + +function readManifest(dir: string): ExtensionManifest | null { + const mPath = join(dir, "extension-manifest.json"); + if (!existsSync(mPath)) return null; + try { + const raw = JSON.parse(readFileSync(mPath, "utf-8")); + if (typeof raw?.id === "string" && typeof raw?.name === "string") return raw as ExtensionManifest; + return null; + } catch { + return null; + } +} + +function discoverManifests(): Map { + const extDir = getAgentExtensionsDir(); + const manifests = new Map(); + if (!existsSync(extDir)) return manifests; + for (const entry of readdirSync(extDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const m = readManifest(join(extDir, entry.name)); + if (m) manifests.set(m.id, m); + } + return manifests; +} + +// ─── Command Handler ──────────────────────────────────────────────────────── + +export async function handleExtensions(args: string, ctx: ExtensionCommandContext): Promise { + const parts = args.split(/\s+/).filter(Boolean); + const subCmd = parts[0] ?? "list"; + + if (subCmd === "list") { + handleList(ctx); + return; + } + + if (subCmd === "enable") { + handleEnable(parts[1], ctx); + return; + } + + if (subCmd === "disable") { + handleDisable(parts[1], parts.slice(2).join(" "), ctx); + return; + } + + if (subCmd === "info") { + handleInfo(parts[1], ctx); + return; + } + + ctx.ui.notify( + `Unknown: /gsd extensions ${subCmd}. Usage: /gsd extensions [list|enable|disable|info]`, + "warning", + ); +} + +function handleList(ctx: ExtensionCommandContext): void { + const manifests = discoverManifests(); + const registry = loadRegistry(); + + if (manifests.size === 0) { + ctx.ui.notify("No extension manifests found.", "warning"); + return; + } + + // Sort: core first, then alphabetical + const sorted = [...manifests.values()].sort((a, b) => { + if (a.tier === "core" && b.tier !== "core") return -1; + if (b.tier === "core" && a.tier !== "core") return 1; + return a.id.localeCompare(b.id); + }); + + const lines: string[] = []; + const hdr = padRight("Extensions", 38) + padRight("Status", 10) + padRight("Tier", 10) + padRight("Tools", 7) + "Commands"; + lines.push(hdr); + lines.push("─".repeat(hdr.length)); + + for (const m of sorted) { + const enabled = isEnabled(registry, m.id); + const status = enabled ? "enabled" : "disabled"; + const toolCount = m.provides?.tools?.length ?? 0; + const cmdCount = m.provides?.commands?.length ?? 0; + const label = `${m.id} (${m.name})`; + + lines.push( + padRight(label, 38) + + padRight(status, 10) + + padRight(m.tier, 10) + + padRight(String(toolCount), 7) + + String(cmdCount), + ); + + if (!enabled) { + lines.push(` ↳ gsd extensions enable ${m.id}`); + } + } + + ctx.ui.notify(lines.join("\n"), "info"); +} + +function handleEnable(id: string | undefined, ctx: ExtensionCommandContext): void { + if (!id) { + ctx.ui.notify("Usage: /gsd extensions enable ", "warning"); + return; + } + + const manifests = discoverManifests(); + if (!manifests.has(id)) { + ctx.ui.notify(`Extension "${id}" not found. Run /gsd extensions list to see available extensions.`, "warning"); + return; + } + + const registry = loadRegistry(); + if (isEnabled(registry, id)) { + ctx.ui.notify(`Extension "${id}" is already enabled.`, "info"); + return; + } + + const entry = registry.entries[id]; + if (entry) { + entry.enabled = true; + delete entry.disabledAt; + delete entry.disabledReason; + } else { + registry.entries[id] = { id, enabled: true, source: "bundled" }; + } + saveRegistry(registry); + ctx.ui.notify(`Enabled "${id}". Restart GSD to activate.`, "info"); +} + +function handleDisable(id: string | undefined, reason: string, ctx: ExtensionCommandContext): void { + if (!id) { + ctx.ui.notify("Usage: /gsd extensions disable ", "warning"); + return; + } + + const manifests = discoverManifests(); + const manifest = manifests.get(id) ?? null; + + if (!manifests.has(id)) { + ctx.ui.notify(`Extension "${id}" not found. Run /gsd extensions list to see available extensions.`, "warning"); + return; + } + + if (manifest?.tier === "core") { + ctx.ui.notify(`Cannot disable "${id}" — it is a core extension.`, "warning"); + return; + } + + const registry = loadRegistry(); + if (!isEnabled(registry, id)) { + ctx.ui.notify(`Extension "${id}" is already disabled.`, "info"); + return; + } + + const entry = registry.entries[id]; + if (entry) { + entry.enabled = false; + entry.disabledAt = new Date().toISOString(); + entry.disabledReason = reason || undefined; + } else { + registry.entries[id] = { + id, + enabled: false, + source: "bundled", + disabledAt: new Date().toISOString(), + disabledReason: reason || undefined, + }; + } + saveRegistry(registry); + ctx.ui.notify(`Disabled "${id}". Restart GSD to deactivate.`, "info"); +} + +function handleInfo(id: string | undefined, ctx: ExtensionCommandContext): void { + if (!id) { + ctx.ui.notify("Usage: /gsd extensions info ", "warning"); + return; + } + + const manifests = discoverManifests(); + const manifest = manifests.get(id); + if (!manifest) { + ctx.ui.notify(`Extension "${id}" not found.`, "warning"); + return; + } + + const registry = loadRegistry(); + const enabled = isEnabled(registry, id); + const entry = registry.entries[id]; + + const lines: string[] = [ + `${manifest.name} (${manifest.id})`, + "", + ` Version: ${manifest.version}`, + ` Description: ${manifest.description}`, + ` Tier: ${manifest.tier}`, + ` Status: ${enabled ? "enabled" : "disabled"}`, + ]; + + if (entry?.disabledAt) { + lines.push(` Disabled at: ${entry.disabledAt}`); + } + if (entry?.disabledReason) { + lines.push(` Reason: ${entry.disabledReason}`); + } + + if (manifest.provides) { + lines.push(""); + lines.push(" Provides:"); + if (manifest.provides.tools?.length) { + lines.push(` Tools: ${manifest.provides.tools.join(", ")}`); + } + if (manifest.provides.commands?.length) { + lines.push(` Commands: ${manifest.provides.commands.join(", ")}`); + } + if (manifest.provides.hooks?.length) { + lines.push(` Hooks: ${manifest.provides.hooks.join(", ")}`); + } + if (manifest.provides.shortcuts?.length) { + lines.push(` Shortcuts: ${manifest.provides.shortcuts.join(", ")}`); + } + } + + if (manifest.dependencies) { + lines.push(""); + lines.push(" Dependencies:"); + if (manifest.dependencies.extensions?.length) { + lines.push(` Extensions: ${manifest.dependencies.extensions.join(", ")}`); + } + if (manifest.dependencies.runtime?.length) { + lines.push(` Runtime: ${manifest.dependencies.runtime.join(", ")}`); + } + } + + ctx.ui.notify(lines.join("\n"), "info"); +} + +function padRight(str: string, len: number): string { + return str.length >= len ? str + " " : str + " ".repeat(len - str.length); +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 5cb93cdc0..a89c8ea50 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -6,7 +6,8 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; import type { GSDState } from "./types.js"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; import { enableDebug } from "./debug-logger.js"; @@ -101,6 +102,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { { cmd: "update", desc: "Update GSD to the latest version" }, { cmd: "start", desc: "Start a workflow template (bugfix, spike, feature, etc.)" }, { cmd: "templates", desc: "List available workflow templates" }, + { cmd: "extensions", desc: "Manage extensions (list, enable, disable, info)" }, ]; const parts = prefix.trim().split(/\s+/); @@ -322,6 +324,47 @@ export function registerGSDCommand(pi: ExtensionAPI): void { .map((c) => ({ value: `templates ${c.value}`, label: c.label, description: c.description })); } + if (parts[0] === "extensions") { + if (parts.length <= 2) { + const subPrefix = parts[1] ?? ""; + const subs = [ + { cmd: "list", desc: "List all extensions and their status" }, + { cmd: "enable", desc: "Enable a disabled extension" }, + { cmd: "disable", desc: "Disable an extension" }, + { cmd: "info", desc: "Show extension details" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `extensions ${s.cmd}`, label: s.cmd, description: s.desc })); + } + if (parts.length === 3 && ["enable", "disable", "info"].includes(parts[1])) { + const idPrefix = parts[2] ?? ""; + try { + const extDir = join(homedir(), ".gsd", "agent", "extensions"); + const ids: { id: string; name: string }[] = []; + for (const entry of readdirSync(extDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const mPath = join(extDir, entry.name, "extension-manifest.json"); + if (!existsSync(mPath)) continue; + try { + const m = JSON.parse(readFileSync(mPath, "utf-8")); + if (typeof m?.id === "string") ids.push({ id: m.id, name: m.name ?? m.id }); + } catch { /* skip malformed */ } + } + return ids + .filter((e) => e.id.startsWith(idPrefix)) + .map((e) => ({ + value: `extensions ${parts[1]} ${e.id}`, + label: e.id, + description: e.name, + })); + } catch { + return []; + } + } + return []; + } + if (parts[0] === "doctor") { const modePrefix = parts[1] ?? ""; const modes = [ @@ -830,6 +873,12 @@ Examples: return; } + if (trimmed === "extensions" || trimmed.startsWith("extensions ")) { + const { handleExtensions } = await import("./commands-extensions.js"); + await handleExtensions(trimmed.replace(/^extensions\s*/, "").trim(), ctx); + return; + } + ctx.ui.notify( `Unknown: /gsd ${trimmed}. Run /gsd help for available commands.`, "warning", @@ -878,6 +927,7 @@ function showHelp(ctx: ExtensionCommandContext): void { " /gsd config Set API keys for external tools", " /gsd keys API key manager [list|add|remove|test|rotate|doctor]", " /gsd hooks Show post-unit hook configuration", + " /gsd extensions Manage extensions [list|enable|disable|info]", "", "MAINTENANCE", " /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]", diff --git a/src/resources/extensions/gsd/extension-manifest.json b/src/resources/extensions/gsd/extension-manifest.json new file mode 100644 index 000000000..efeb7bfbe --- /dev/null +++ b/src/resources/extensions/gsd/extension-manifest.json @@ -0,0 +1,18 @@ +{ + "id": "gsd", + "name": "GSD Workflow", + "version": "1.0.0", + "description": "Core GSD workflow engine — milestone planning, execution, and tracking", + "tier": "core", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": [ + "bash", "write", "read", "edit", + "gsd_save_decision", "gsd_save_summary", + "gsd_update_requirement", "gsd_generate_milestone_id" + ], + "commands": ["gsd", "kill", "worktree", "exit"], + "hooks": ["session_start"], + "shortcuts": ["Ctrl+Alt+G"] + } +} diff --git a/src/resources/extensions/mac-tools/extension-manifest.json b/src/resources/extensions/mac-tools/extension-manifest.json new file mode 100644 index 000000000..c0087a555 --- /dev/null +++ b/src/resources/extensions/mac-tools/extension-manifest.json @@ -0,0 +1,16 @@ +{ + "id": "mac-tools", + "name": "Mac Tools", + "version": "1.0.0", + "description": "macOS automation via Accessibility API — screenshots, UI inspection, clicks, and typing", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": [ + "mac_check_permissions", "mac_list_apps", "mac_launch_app", "mac_activate_app", + "mac_quit_app", "mac_list_windows", "mac_screenshot", "mac_find", + "mac_get_tree", "mac_click", "mac_type", "mac_read" + ], + "hooks": ["before_agent_start"] + } +} diff --git a/src/resources/extensions/mcporter/extension-manifest.json b/src/resources/extensions/mcporter/extension-manifest.json new file mode 100644 index 000000000..76a01d3b5 --- /dev/null +++ b/src/resources/extensions/mcporter/extension-manifest.json @@ -0,0 +1,12 @@ +{ + "id": "mcporter", + "name": "MCPorter", + "version": "1.0.0", + "description": "Discover and call tools from MCP servers configured in Claude Desktop, Cursor, and VS Code", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["mcp_servers", "mcp_discover", "mcp_call"], + "hooks": ["session_start"] + } +} diff --git a/src/resources/extensions/remote-questions/extension-manifest.json b/src/resources/extensions/remote-questions/extension-manifest.json new file mode 100644 index 000000000..63ca3a463 --- /dev/null +++ b/src/resources/extensions/remote-questions/extension-manifest.json @@ -0,0 +1,11 @@ +{ + "id": "remote-questions", + "name": "Remote Questions", + "version": "1.0.0", + "description": "Remote user question routing via Slack, Discord, and Telegram adapters", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "commands": ["remote"] + } +} diff --git a/src/resources/extensions/search-the-web/extension-manifest.json b/src/resources/extensions/search-the-web/extension-manifest.json new file mode 100644 index 000000000..582c341d8 --- /dev/null +++ b/src/resources/extensions/search-the-web/extension-manifest.json @@ -0,0 +1,13 @@ +{ + "id": "search-the-web", + "name": "Web Search", + "version": "1.0.0", + "description": "Web search via Brave and page extraction via Jina Reader", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["search-the-web", "fetch_page", "search_and_read", "web_search"], + "commands": ["search-provider"], + "hooks": ["model_select", "before_provider_request"] + } +} diff --git a/src/resources/extensions/slash-commands/extension-manifest.json b/src/resources/extensions/slash-commands/extension-manifest.json new file mode 100644 index 000000000..104820183 --- /dev/null +++ b/src/resources/extensions/slash-commands/extension-manifest.json @@ -0,0 +1,11 @@ +{ + "id": "slash-commands", + "name": "Slash Commands", + "version": "1.0.0", + "description": "Boilerplate generators for slash commands, extensions, and audit tools", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "commands": ["create-slash-command", "create-extension", "audit", "clear"] + } +} diff --git a/src/resources/extensions/subagent/extension-manifest.json b/src/resources/extensions/subagent/extension-manifest.json new file mode 100644 index 000000000..cb71f8f86 --- /dev/null +++ b/src/resources/extensions/subagent/extension-manifest.json @@ -0,0 +1,13 @@ +{ + "id": "subagent", + "name": "Subagent", + "version": "1.0.0", + "description": "Delegate tasks to specialized subagents in single, parallel, or chain modes", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["subagent"], + "commands": ["subagent"], + "hooks": ["session_shutdown"] + } +} diff --git a/src/resources/extensions/ttsr/extension-manifest.json b/src/resources/extensions/ttsr/extension-manifest.json new file mode 100644 index 000000000..1de3f8797 --- /dev/null +++ b/src/resources/extensions/ttsr/extension-manifest.json @@ -0,0 +1,11 @@ +{ + "id": "ttsr", + "name": "Time Traveling Stream Rules", + "version": "1.0.0", + "description": "Zero-context-cost guardrails that monitor streaming output against regex patterns", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "hooks": ["session_start", "turn_start", "message_update", "turn_end", "agent_end"] + } +} diff --git a/src/resources/extensions/universal-config/extension-manifest.json b/src/resources/extensions/universal-config/extension-manifest.json new file mode 100644 index 000000000..6d5077390 --- /dev/null +++ b/src/resources/extensions/universal-config/extension-manifest.json @@ -0,0 +1,13 @@ +{ + "id": "universal-config", + "name": "Universal Config", + "version": "1.0.0", + "description": "Discover AI coding tool configurations across Claude Code, Cursor, Windsurf, Gemini CLI, and more", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "tools": ["discover_configs"], + "commands": ["configs"], + "hooks": ["session_switch"] + } +} diff --git a/src/resources/extensions/voice/extension-manifest.json b/src/resources/extensions/voice/extension-manifest.json new file mode 100644 index 000000000..3c1bc6950 --- /dev/null +++ b/src/resources/extensions/voice/extension-manifest.json @@ -0,0 +1,12 @@ +{ + "id": "voice", + "name": "Voice", + "version": "1.0.0", + "description": "Voice input mode for hands-free interaction", + "tier": "bundled", + "requires": { "platform": ">=2.29.0" }, + "provides": { + "commands": ["voice"], + "shortcuts": ["Ctrl+Alt+V"] + } +}