- Add repository-vcs-context.ts to detect and inject VCS context (Git/Jujutsu) into the agent system prompt; wire in repo-vcs bundled skill trigger - Add src/resources/skills/repo-vcs/ skill for commit, push, and safe-push workflows - Add JSDoc Purpose/Consumer annotations to app-paths, bundled-extension-paths, errors, extension-discovery, extension-registry, headless-types, headless, and traces - Add justfile and just to flake.nix devShell - Fill out new-user-onboarding.md spec (Draft) and core-beliefs.md (Status: Accepted) - Add notification-event-model.md design doc and notification-source-hygiene.md spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
/**
|
|
* 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 `sf extensions disable <id>`.
|
|
*
|
|
* Purpose: provide a single source of truth for which extensions are active so that
|
|
* the loader can decide what to load and the CLI can show the user what is installed.
|
|
*
|
|
* Consumer: extension-discovery.ts (reads manifests), loader.ts (decides what to load),
|
|
* and commands-handlers.ts (implements `sf extensions list/enable/disable`).
|
|
*/
|
|
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { appRoot } from "./app-paths.js";
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Describes the static metadata shipped with an extension.
|
|
*
|
|
* Purpose: let the registry and loader validate an extension before loading it
|
|
* and present human-readable information in CLI listings.
|
|
*
|
|
* Consumer: readManifest(), discoverAllManifests(), and the `sf extensions list` command.
|
|
*/
|
|
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[];
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A single entry in the on-disk registry file.
|
|
*
|
|
* Purpose: persist whether an extension is enabled, why it was disabled, and
|
|
* where it came from so that decisions survive process restarts.
|
|
*
|
|
* Consumer: loadRegistry(), saveRegistry(), and the enable/disable mutations.
|
|
*/
|
|
export interface ExtensionRegistryEntry {
|
|
id: string;
|
|
enabled: boolean;
|
|
source: "bundled" | "user" | "project";
|
|
disabledAt?: string;
|
|
disabledReason?: string;
|
|
}
|
|
|
|
/**
|
|
* The top-level shape of the persisted registry file.
|
|
*
|
|
* Purpose: version the JSON schema so future migrations can detect old formats.
|
|
*
|
|
* Consumer: loadRegistry() and saveRegistry().
|
|
*/
|
|
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 ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns the absolute path to the persisted registry JSON file.
|
|
*
|
|
* Purpose: centralise the registry location so every I/O operation targets the
|
|
* same file and the path can be overridden in tests.
|
|
*
|
|
* Consumer: loadRegistry(), saveRegistry(), and test fixtures that need to
|
|
* point at a temporary registry.
|
|
*/
|
|
export function getRegistryPath(): string {
|
|
return join(appRoot, "extensions", "registry.json");
|
|
}
|
|
|
|
// ─── Registry I/O ───────────────────────────────────────────────────────────
|
|
|
|
function defaultRegistry(): ExtensionRegistry {
|
|
return { version: 1, entries: {} };
|
|
}
|
|
|
|
/**
|
|
* Reads the registry from disk, returning a default empty registry if the file
|
|
* is missing, malformed, or unreadable.
|
|
*
|
|
* Purpose: guarantee that every caller receives a valid ExtensionRegistry object
|
|
* without having to handle I/O edge cases themselves.
|
|
*
|
|
* Consumer: ensureRegistryEntries(), the extension loader, and CLI commands that
|
|
* need to inspect or mutate extension state.
|
|
*/
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomically writes the registry to disk using a temp-file + rename pattern.
|
|
*
|
|
* Purpose: prevent corrupt or partial registry files if the process crashes
|
|
* mid-write, and silently swallow non-fatal persistence errors so the CLI
|
|
* remains usable even when the filesystem is read-only.
|
|
*
|
|
* Consumer: enableExtension(), disableExtension(), and ensureRegistryEntries().
|
|
*/
|
|
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).
|
|
*
|
|
* Purpose: let the loader decide whether to activate an extension without
|
|
* requiring every caller to know the "missing means enabled" default.
|
|
*
|
|
* Consumer: the extension loader and `sf extensions list` when rendering status.
|
|
*/
|
|
export function isExtensionEnabled(
|
|
registry: ExtensionRegistry,
|
|
id: string,
|
|
): boolean {
|
|
const entry = registry.entries[id];
|
|
if (!entry) return true;
|
|
return entry.enabled;
|
|
}
|
|
|
|
// ─── Mutations ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Marks an extension as enabled, clearing any previous disable metadata.
|
|
*
|
|
* Purpose: provide the atomic state transition used by `sf extensions enable`
|
|
* and by the auto-discovery flow when a new extension is first seen.
|
|
*
|
|
* Consumer: `sf extensions enable` command handler and ensureRegistryEntries().
|
|
*/
|
|
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.
|
|
*
|
|
* Purpose: protect core extensions from accidental disablement while allowing users
|
|
* to turn off bundled or community extensions and recording why.
|
|
*
|
|
* Consumer: `sf extensions disable` command handler.
|
|
*/
|
|
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.
|
|
*
|
|
* Purpose: isolate manifest parsing and validation so callers receive either a
|
|
* fully typed ExtensionManifest or a clear null signal.
|
|
*
|
|
* Consumer: discoverAllManifests(), readManifestFromEntryPath(), and tests that
|
|
* verify manifest schema evolution.
|
|
*/
|
|
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.
|
|
*
|
|
* Purpose: bridge the gap between a discovered entry-point file and its
|
|
* containing extension's metadata, used when the loader needs tier or
|
|
* dependency information for a specific file.
|
|
*
|
|
* Consumer: extension loader when validating an entry point before require()ing it.
|
|
*/
|
|
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>.
|
|
*
|
|
* Purpose: produce a complete, de-duplicated inventory of installed extensions
|
|
* so the registry can be reconciled against the filesystem.
|
|
*
|
|
* Consumer: ensureRegistryEntries() and the extension loader's bootstrap phase.
|
|
*/
|
|
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 })) {
|
|
// Accept both real directories and directory symlinks. Dirent.isDirectory()
|
|
// returns false for symlinks even when they point at a directory, so dev-workflow
|
|
// symlinked extensions under ~/.sf/agent/extensions/ are invisible otherwise
|
|
// (regression tested in symlink-extension-discovery.test.ts).
|
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) 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.
|
|
*
|
|
* Purpose: keep the registry in sync with the filesystem after installs or
|
|
* updates without overwriting user preferences (e.g. disabled state).
|
|
*
|
|
* Consumer: sf-run startup sequence and `sf extensions sync` command.
|
|
*/
|
|
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);
|
|
}
|
|
}
|