diff --git a/metadata/commands.mjs b/metadata/commands.mjs index 975c38d..703c170 100644 --- a/metadata/commands.mjs +++ b/metadata/commands.mjs @@ -146,10 +146,11 @@ export const cliCommandSections = [ commands: [ { usage: "feynman packages list", description: "Show core and optional Pi package presets." }, { usage: "feynman packages install ", description: "Install optional package presets on demand." }, + { usage: "feynman extensions", description: "List project and package-provided Pi extensions." }, { usage: "feynman search status", description: "Show Pi web-access status and config path." }, { usage: "feynman search set [api-key]", description: "Set the web search provider and optionally save its API key." }, { usage: "feynman search clear", description: "Reset web search provider to auto while preserving API keys." }, - { usage: "feynman update [package]", description: "Update installed packages, or a specific package." }, + { usage: "feynman update [package]", description: "Update installed packages, or a specific package. Extensions are provided by packages and update with them; there is no separate --extensions flag." }, ], }, ]; @@ -169,7 +170,7 @@ export const legacyFlags = [ { usage: "--setup-preview", description: "Alias for `feynman setup preview`." }, ]; -export const topLevelCommandNames = ["alpha", "chat", "doctor", "help", "model", "packages", "paper", "rank", "search", "serve", "setup", "status", "update"]; +export const topLevelCommandNames = ["alpha", "chat", "doctor", "extensions", "help", "model", "packages", "paper", "rank", "search", "serve", "setup", "status", "update"]; export function formatSlashUsage(command) { return `/${command.name}${command.args ? ` ${command.args}` : ""}`; diff --git a/src/cli.ts b/src/cli.ts index ac35741..dfd8f35 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -86,6 +86,7 @@ import { } from "./telemetry/posthog.js"; import { ASH, printAsciiHeader, printInfo, printPanel, printSection, RESET, SAGE } from "./ui/terminal.js"; import { createModelRegistry } from "./model/registry.js"; +import { summarizeExtensions } from "./workbench/package-resources.js"; import { parseWorkbenchPort, serveWorkbench } from "./workbench/server.js"; import { cliCommandSections, @@ -442,6 +443,30 @@ async function handlePackagesCommand(subcommand: string | undefined, args: strin } } +function handleExtensionsCommand(workingDir: string): void { + const { projectExtensions, packageExtensions } = summarizeExtensions(workingDir); + printPanel("Feynman Extensions", [ + "Pi extensions add tools to workbench chat sessions.", + ]); + printSection("Project extensions"); + if (projectExtensions.length === 0) { + printInfo("No project extensions found in ./extensions."); + } else { + for (const extension of projectExtensions) { + printInfo(`${extension.name} ${extension.path}`); + } + } + printSection("From packages"); + if (packageExtensions.length === 0) { + printInfo("No installed packages provide extensions."); + } else { + for (const entry of packageExtensions) { + printInfo(`${entry.name} ${entry.count} extension${entry.count === 1 ? "" : "s"} (${entry.source})`); + } + } + printInfo("Extensions are provided by packages and update with them: feynman update [package]."); +} + function handleSearchCommand(subcommand: string | undefined, args: string[]): void { if (!subcommand || subcommand === "status") { printSearchStatus(); @@ -1044,6 +1069,11 @@ async function runMain(input: { here: string; appRoot: string; feynmanVersion: s return; } + if (command === "extensions") { + handleExtensionsCommand(workingDir); + return; + } + if (command === "update") { await handleUpdateCommand(workingDir, feynmanAgentDir, appRoot, feynmanVersion, rest[0]); return; diff --git a/src/workbench/package-resources.ts b/src/workbench/package-resources.ts index 4366c13..4d66527 100644 --- a/src/workbench/package-resources.ts +++ b/src/workbench/package-resources.ts @@ -275,3 +275,29 @@ export function buildConnectorResources(workingDir: string): WorkbenchResource[] })); return [...packages, ...extensionFiles]; } + +export interface ExtensionSummary { + projectExtensions: { name: string; path: string }[]; + packageExtensions: { name: string; source: string; count: number }[]; +} + +const EXTENSION_COUNT_TAG = /^(\d+) extensions?$/; + +// Derives the extensions view from the shared connector enumeration so the CLI +// `feynman extensions` command and the workbench stay in sync. Project +// extensions are the loose .ts/.js files under ./extensions; package extensions +// are surfaced by installed Pi packages that declare `pi.extensions`. +export function summarizeExtensions(workingDir: string): ExtensionSummary { + const resources = buildConnectorResources(workingDir); + const projectExtensions = resources + .filter((resource) => resource.section === "Project extensions") + .map((resource) => ({ name: resource.name, path: resource.path ?? resource.name })); + const packageExtensions = resources.flatMap((resource) => { + if (resource.connectorKind !== "package") return []; + const countTag = resource.tags?.find((tag) => EXTENSION_COUNT_TAG.test(tag)); + const count = countTag ? Number.parseInt(countTag, 10) : 0; + if (!Number.isInteger(count) || count <= 0) return []; + return [{ name: resource.name, source: resource.packageSources?.[0] ?? resource.name, count }]; + }); + return { projectExtensions, packageExtensions }; +} diff --git a/tests/summarize-extensions.test.ts b/tests/summarize-extensions.test.ts new file mode 100644 index 0000000..e5942d1 --- /dev/null +++ b/tests/summarize-extensions.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { summarizeExtensions } from "../src/workbench/package-resources.js"; + +function writePackage(root: string, name: string, manifest: Record): void { + const packageRoot = join(root, ".feynman", "npm", "node_modules", ...name.split("/")); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name, ...manifest }, null, 2)); +} + +test("summarizeExtensions lists project and package-provided extensions", () => { + const root = mkdtempSync(join(tmpdir(), "feynman-extensions-")); + try { + mkdirSync(join(root, ".feynman"), { recursive: true }); + writeFileSync(join(root, ".feynman", "settings.json"), JSON.stringify({ + packages: ["npm:pi-web-access"], + }, null, 2)); + writePackage(root, "pi-web-access", { + version: "0.13.0", + description: "Web search, URL fetching, and PDF extraction for Pi.", + pi: { extensions: ["./index.ts", "./fetch.ts"] }, + }); + + mkdirSync(join(root, "extensions"), { recursive: true }); + writeFileSync(join(root, "extensions", "my-tool.ts"), "export default {};\n"); + // Nested files should not be counted as top-level project extensions. + mkdirSync(join(root, "extensions", "nested"), { recursive: true }); + writeFileSync(join(root, "extensions", "nested", "ignored.ts"), "export default {};\n"); + + const summary = summarizeExtensions(root); + + assert.deepEqual( + summary.projectExtensions.map((extension) => extension.name), + ["my-tool"], + ); + assert.equal(summary.projectExtensions[0].path, "extensions/my-tool.ts"); + + const webAccess = summary.packageExtensions.find((entry) => entry.source === "npm:pi-web-access"); + assert.ok(webAccess, "expected pi-web-access to be reported as providing extensions"); + assert.equal(webAccess.count, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("summarizeExtensions reports empty state when nothing is configured", () => { + const root = mkdtempSync(join(tmpdir(), "feynman-extensions-empty-")); + try { + const summary = summarizeExtensions(root); + assert.deepEqual(summary.projectExtensions, []); + assert.deepEqual(summary.packageExtensions, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});