- 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>
117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
/**
|
|
* Extension Discovery — resolves extension entry-point files from a directory tree.
|
|
*
|
|
* Supports two discovery modes:
|
|
* 1. package.json with a `pi` manifest (authoritative, allows opt-out).
|
|
* 2. Fallback to index.ts / index.js when no manifest is present.
|
|
*
|
|
* Purpose: decouple the physical layout of extensions on disk from the loader so
|
|
* that extensions can declare their own entry points and library directories can
|
|
* opt out of being loaded.
|
|
*
|
|
* Consumer: extension-registry.ts (ensureRegistryEntries), the sf-run loader, and
|
|
* the test suite that validates symlink and manifest edge cases.
|
|
*/
|
|
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
|
|
function isExtensionFile(name: string): boolean {
|
|
return (
|
|
(name.endsWith(".ts") && !name.endsWith(".d.ts")) ||
|
|
(name.endsWith(".js") && !name.endsWith(".d.js"))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolves the entry-point file(s) for a single extension directory.
|
|
*
|
|
* 1. If the directory contains a package.json with a `pi` manifest object,
|
|
* the manifest is authoritative:
|
|
* - `pi.extensions` array → resolve each entry relative to the directory.
|
|
* - `pi: {}` (no extensions) → return empty (library opt-out, e.g. cmux).
|
|
* 2. Only when no `pi` manifest exists does it fall back to `index.ts` → `index.js`.
|
|
*
|
|
* Purpose: give extension authors explicit control over what gets loaded while
|
|
* preserving backwards compatibility for simple extensions that only provide an
|
|
* index file.
|
|
*
|
|
* Consumer: discoverExtensionEntryPaths() and tests that verify manifest vs fallback
|
|
* resolution logic.
|
|
*/
|
|
export function resolveExtensionEntries(dir: string): string[] {
|
|
const packageJsonPath = join(dir, "package.json");
|
|
if (existsSync(packageJsonPath)) {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
if (pkg?.pi && typeof pkg.pi === "object") {
|
|
// When a pi manifest exists, it is authoritative — don't fall through
|
|
// to index.ts/index.js auto-detection. This allows library directories
|
|
// (like cmux) to opt out by declaring "pi": {} with no extensions.
|
|
const declared = pkg.pi.extensions;
|
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
return [];
|
|
}
|
|
return declared
|
|
.filter(
|
|
(entry: unknown): entry is string => typeof entry === "string",
|
|
)
|
|
.map((entry: string) => resolve(dir, entry))
|
|
.filter((entry: string) => existsSync(entry));
|
|
}
|
|
} catch {
|
|
// Ignore malformed manifests and fall back to index.ts/index.js discovery.
|
|
}
|
|
}
|
|
|
|
const indexTs = join(dir, "index.ts");
|
|
if (existsSync(indexTs)) {
|
|
return [indexTs];
|
|
}
|
|
|
|
const indexJs = join(dir, "index.js");
|
|
if (existsSync(indexJs)) {
|
|
return [indexJs];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Discovers all extension entry-point paths under an extensions directory.
|
|
*
|
|
* - Top-level .ts/.js files are treated as standalone extension entry points.
|
|
* - Subdirectories are resolved via `resolveExtensionEntries()` (package.json →
|
|
* pi.extensions, then index.ts/index.js fallback).
|
|
*
|
|
* Purpose: produce a flat list of absolute entry-point paths that the loader can
|
|
* require() in order, regardless of whether extensions are organised as files or
|
|
* directories.
|
|
*
|
|
* Consumer: the sf-run loader bootstrap and integration tests that verify discovery
|
|
* against fixture directories.
|
|
*/
|
|
export function discoverExtensionEntryPaths(extensionsDir: string): string[] {
|
|
if (!existsSync(extensionsDir)) {
|
|
return [];
|
|
}
|
|
|
|
const discovered: string[] = [];
|
|
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
|
|
const entryPath = join(extensionsDir, entry.name);
|
|
|
|
if (
|
|
(entry.isFile() || entry.isSymbolicLink()) &&
|
|
isExtensionFile(entry.name)
|
|
) {
|
|
discovered.push(entryPath);
|
|
continue;
|
|
}
|
|
|
|
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
discovered.push(...resolveExtensionEntries(entryPath));
|
|
}
|
|
}
|
|
|
|
return discovered;
|
|
}
|