Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions metadata/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,11 @@ export const cliCommandSections = [
commands: [
{ usage: "feynman packages list", description: "Show core and optional Pi package presets." },
{ usage: "feynman packages install <preset>", 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 <provider> [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." },
],
},
];
Expand All @@ -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}` : ""}`;
Expand Down
30 changes: 30 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions src/workbench/package-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
59 changes: 59 additions & 0 deletions tests/summarize-extensions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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 });
}
});