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:
parent
3102831db9
commit
92f0b15268
20 changed files with 835 additions and 7 deletions
219
src/extension-registry.ts
Normal file
219
src/extension-registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
13
src/resources/extensions/async-jobs/extension-manifest.json
Normal file
13
src/resources/extensions/async-jobs/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
14
src/resources/extensions/bg-shell/extension-manifest.json
Normal file
14
src/resources/extensions/bg-shell/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
12
src/resources/extensions/context7/extension-manifest.json
Normal file
12
src/resources/extensions/context7/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
328
src/resources/extensions/gsd/commands-extensions.ts
Normal file
328
src/resources/extensions/gsd/commands-extensions.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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]",
|
||||
|
|
|
|||
18
src/resources/extensions/gsd/extension-manifest.json
Normal file
18
src/resources/extensions/gsd/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
16
src/resources/extensions/mac-tools/extension-manifest.json
Normal file
16
src/resources/extensions/mac-tools/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
12
src/resources/extensions/mcporter/extension-manifest.json
Normal file
12
src/resources/extensions/mcporter/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
13
src/resources/extensions/subagent/extension-manifest.json
Normal file
13
src/resources/extensions/subagent/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
11
src/resources/extensions/ttsr/extension-manifest.json
Normal file
11
src/resources/extensions/ttsr/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
12
src/resources/extensions/voice/extension-manifest.json
Normal file
12
src/resources/extensions/voice/extension-manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue