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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-18 14:12:19 -06:00 committed by GitHub
parent 3102831db9
commit 92f0b15268
20 changed files with 835 additions and 7 deletions

219
src/extension-registry.ts Normal file
View file

@ -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 <id>`.
*/
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<string, ExtensionRegistryEntry>;
}
// ─── Validation ─────────────────────────────────────────────────────────────
function isRegistry(data: unknown): data is ExtensionRegistry {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
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<string, unknown>;
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<id, manifest>. */
export function discoverAllManifests(extensionsDir: string): Map<string, ExtensionManifest> {
const manifests = new Map<string, ExtensionManifest>();
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);
}
}

View file

@ -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)

View file

@ -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<string> {
}
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,

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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<string, ExtensionRegistryEntry>;
}
// ─── 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<string, ExtensionManifest> {
const extDir = getAgentExtensionsDir();
const manifests = new Map<string, ExtensionManifest>();
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<void> {
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 <id>", "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 <id>", "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 <id>", "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);
}

View file

@ -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]",

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}