Skip to content

Commit 982006b

Browse files
committed
feat(plugins): implement plugin commands caching and update plugin naming convention
1 parent 3c158ea commit 982006b

12 files changed

Lines changed: 353 additions & 189 deletions

File tree

packages/cli/src/commands/plugins/install.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ export default defineCommand({
1414
description: "Install a bailian-cli plugin package into ~/.bailian/plugins",
1515
usage: "bl plugins install <package>",
1616
options: [],
17-
examples: ["bl plugins install @alife/bailian-agent", "bl plugins install bailian-agent"],
17+
examples: [
18+
"bl plugins install @ali/bailian-plugin-agent",
19+
"bl plugins install bailian-plugin-agent",
20+
],
1821
async run(_config: Config, flags: GlobalFlags) {
1922
const positional = flags._positional as string[] | undefined;
2023
const packageSpec = positional?.[0];

packages/cli/src/commands/plugins/remove.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default defineCommand({
1313
name: "plugins remove",
1414
description: "Remove an installed bailian-cli plugin",
1515
usage: "bl plugins remove <name>",
16-
examples: ["bl plugins remove @alife/bailian-agent"],
16+
examples: ["bl plugins remove bailian-plugin-agent"],
1717
async run(_config: Config, flags: GlobalFlags) {
1818
const positional = flags._positional as string[] | undefined;
1919
const name = positional?.[0];

packages/cli/src/load-commands.ts

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,119 @@
11
import { commands as builtinCommands } from "./commands/catalog.ts";
2+
import {
3+
computeFingerprint,
4+
fingerprintMatches,
5+
isPluginCommandsCacheDisabled,
6+
pruneCache,
7+
readCommandsCache,
8+
writeCommandsCache,
9+
type CachedPluginEntry,
10+
type CommandsCache,
11+
COMMANDS_CACHE_SCHEMA,
12+
} from "./plugins/cache.ts";
213
import { discoverPlugins } from "./plugins/discover.ts";
3-
import { mergeCommands } from "./plugins/priority.ts";
4-
import { scanAllPluginCommands } from "./plugins/scan.ts";
5-
import type { CommandCatalog } from "./plugins/types.ts";
14+
import { mergeCommands, type PluginCommandEntry } from "./plugins/priority.ts";
15+
import { extractCommandMeta, lazyCommandFromMeta, scanPluginCommands } from "./plugins/scan.ts";
16+
import type { CommandCatalog, DiscoveredPlugin, PluginLoadError } from "./plugins/types.ts";
17+
import { CLI_VERSION } from "./version.ts";
618

719
let cachedCatalog: CommandCatalog | undefined;
820

21+
function collectNoAuthSetup(plugin: DiscoveredPlugin): string[] {
22+
return (plugin.bailianCli.noAuthSetup ?? []).map((p) => p.trim()).filter(Boolean);
23+
}
24+
25+
function expandNoAuthSetup(paths: string[]): string[][] {
26+
return paths.map((p) => p.split(/\s+/).filter(Boolean));
27+
}
28+
29+
/** 带盘缓存的插件命令加载 */
30+
async function loadPluginCommandsWithCache(plugins: DiscoveredPlugin[]): Promise<{
31+
entries: PluginCommandEntry[];
32+
pluginErrors: PluginLoadError[];
33+
noAuthSetup: string[][];
34+
}> {
35+
const pluginErrors: PluginLoadError[] = [];
36+
const noAuthSetup: string[][] = [];
37+
const entries: PluginCommandEntry[] = [];
38+
39+
const useCache = !isPluginCommandsCacheDisabled();
40+
const diskCache = useCache ? await readCommandsCache() : undefined;
41+
const cacheValid = diskCache?.cliVersion === CLI_VERSION;
42+
let needsWrite = useCache && !cacheValid;
43+
44+
const nextPlugins: Record<string, CachedPluginEntry> = {};
45+
46+
for (const plugin of plugins) {
47+
const fingerprint = computeFingerprint(plugin);
48+
const cached = useCache && cacheValid ? diskCache!.plugins[plugin.name] : undefined;
49+
50+
if (cached && fingerprintMatches(cached.fingerprint, fingerprint)) {
51+
for (const paths of expandNoAuthSetup(cached.noAuthSetup)) {
52+
noAuthSetup.push(paths);
53+
}
54+
for (const meta of cached.commands) {
55+
entries.push({
56+
path: meta.commandPath,
57+
command: lazyCommandFromMeta(meta),
58+
plugin,
59+
});
60+
}
61+
nextPlugins[plugin.name] = cached;
62+
continue;
63+
}
64+
65+
if (useCache) needsWrite = true;
66+
67+
const { commands: scanned, error } = await scanPluginCommands(plugin);
68+
if (error) pluginErrors.push(error);
69+
70+
const noAuthPaths = collectNoAuthSetup(plugin);
71+
for (const paths of expandNoAuthSetup(noAuthPaths)) {
72+
noAuthSetup.push(paths);
73+
}
74+
75+
const metas = [];
76+
for (const item of scanned) {
77+
const meta = await extractCommandMeta(item);
78+
if (!meta) {
79+
pluginErrors.push({
80+
plugin: plugin.name,
81+
message: `Could not load command module: ${item.commandPath}`,
82+
});
83+
continue;
84+
}
85+
metas.push(meta);
86+
entries.push({
87+
path: meta.commandPath,
88+
command: lazyCommandFromMeta(meta),
89+
plugin,
90+
});
91+
}
92+
93+
if (useCache) {
94+
nextPlugins[plugin.name] = {
95+
fingerprint,
96+
commands: metas,
97+
noAuthSetup: noAuthPaths,
98+
};
99+
}
100+
}
101+
102+
if (needsWrite && useCache) {
103+
const cache: CommandsCache = {
104+
schema: COMMANDS_CACHE_SCHEMA,
105+
cliVersion: CLI_VERSION,
106+
plugins: pruneCache(
107+
nextPlugins,
108+
plugins.map((p) => p.name),
109+
),
110+
};
111+
await writeCommandsCache(cache);
112+
}
113+
114+
return { entries, pluginErrors, noAuthSetup };
115+
}
116+
9117
/**
10118
* Load the command catalog.
11119
* @returns The command catalog.
@@ -18,7 +126,7 @@ export async function loadCommandCatalog(): Promise<CommandCatalog> {
18126
entries: pluginEntries,
19127
pluginErrors: scanErrors,
20128
noAuthSetup: pluginNoAuth,
21-
} = await scanAllPluginCommands(plugins);
129+
} = await loadPluginCommandsWithCache(plugins);
22130

23131
const { commands, pluginErrors: mergeErrors } = mergeCommands(builtinCommands, pluginEntries);
24132

packages/cli/src/plugins/cache.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { existsSync, statSync } from "fs";
2+
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
3+
import { dirname, join } from "path";
4+
import type { OptionDef } from "bailian-cli-core";
5+
import { getPluginsCachePath } from "./paths.ts";
6+
import type { DiscoveredPlugin, PluginSourceType } from "./types.ts";
7+
8+
/**
9+
* Disk cache schema version
10+
*/
11+
export const COMMANDS_CACHE_SCHEMA = 1;
12+
13+
/**
14+
* is plugin commands cache disabled
15+
* @returns true if plugin commands cache is disabled, false otherwise
16+
*/
17+
export function isPluginCommandsCacheDisabled(): boolean {
18+
return process.env.BAILIAN_CLI_PLUGINS_NO_CACHE === "1";
19+
}
20+
21+
/**
22+
* Serializable command metadata
23+
*/
24+
export interface CachedCommandMeta {
25+
commandPath: string;
26+
modulePath: string;
27+
name: string;
28+
description: string;
29+
usage?: string;
30+
options?: OptionDef[];
31+
examples?: string[];
32+
apiDocs?: string;
33+
}
34+
35+
/**
36+
* Plugin fingerprint: name + version + commands directory mtime
37+
*/
38+
export interface PluginFingerprint {
39+
name: string;
40+
version?: string;
41+
source: PluginSourceType;
42+
root: string;
43+
commandsMtimeMs: number;
44+
}
45+
46+
/**
47+
* Single plugin entry in the cache
48+
*/
49+
export interface CachedPluginEntry {
50+
fingerprint: PluginFingerprint;
51+
commands: CachedCommandMeta[];
52+
/**
53+
* No-auth setup command paths (space-separated raw strings)
54+
*/
55+
noAuthSetup: string[];
56+
}
57+
58+
/** ~/.bailian/plugins/commands-cache.json */
59+
export interface CommandsCache {
60+
schema: typeof COMMANDS_CACHE_SCHEMA;
61+
cliVersion: string;
62+
plugins: Record<string, CachedPluginEntry>;
63+
}
64+
65+
/**
66+
* Compute plugin fingerprint
67+
* @param plugin - The plugin to compute the fingerprint for
68+
* @returns The plugin fingerprint
69+
*/
70+
export function computeFingerprint(plugin: DiscoveredPlugin): PluginFingerprint {
71+
const commandsDir = join(plugin.root, plugin.bailianCli.commands ?? "");
72+
let commandsMtimeMs = 0;
73+
if (existsSync(commandsDir)) {
74+
try {
75+
commandsMtimeMs = statSync(commandsDir).mtimeMs;
76+
} catch {
77+
commandsMtimeMs = 0;
78+
}
79+
}
80+
return {
81+
name: plugin.name,
82+
version: plugin.version,
83+
source: plugin.source,
84+
root: plugin.root,
85+
commandsMtimeMs,
86+
};
87+
}
88+
89+
/**
90+
* Check if the fingerprints match
91+
* @param cached - The cached fingerprint
92+
* @param current - The current fingerprint
93+
* @returns true if the fingerprints match, false otherwise
94+
*/
95+
export function fingerprintMatches(cached: PluginFingerprint, current: PluginFingerprint): boolean {
96+
return (
97+
cached.name === current.name &&
98+
cached.version === current.version &&
99+
cached.source === current.source &&
100+
cached.root === current.root &&
101+
cached.commandsMtimeMs === current.commandsMtimeMs
102+
);
103+
}
104+
105+
/**
106+
* Remove cached entries for uninstalled plugins
107+
* @param plugins - The plugins to prune
108+
* @param activeNames - The names of the active plugins
109+
* @returns The pruned plugins
110+
*/
111+
export function pruneCache(
112+
plugins: Record<string, CachedPluginEntry>,
113+
activeNames: string[],
114+
): Record<string, CachedPluginEntry> {
115+
const active = new Set(activeNames);
116+
const pruned: Record<string, CachedPluginEntry> = {};
117+
for (const [name, entry] of Object.entries(plugins)) {
118+
if (active.has(name)) pruned[name] = entry;
119+
}
120+
return pruned;
121+
}
122+
123+
/**
124+
* Read the disk cache (returns undefined on failure)
125+
* @returns The commands cache or undefined if the cache is not found or invalid
126+
*/
127+
export async function readCommandsCache(): Promise<CommandsCache | undefined> {
128+
const path = getPluginsCachePath();
129+
if (!existsSync(path)) return undefined;
130+
try {
131+
const raw = JSON.parse(await readFile(path, "utf8")) as CommandsCache;
132+
if (raw.schema !== COMMANDS_CACHE_SCHEMA) return undefined;
133+
if (!raw.cliVersion || typeof raw.plugins !== "object") return undefined;
134+
return raw;
135+
} catch {
136+
return undefined;
137+
}
138+
}
139+
140+
/**
141+
* Write the disk cache (best-effort, silent on failure)
142+
* @param cache - The commands cache to write
143+
*/
144+
export async function writeCommandsCache(cache: CommandsCache): Promise<void> {
145+
const path = getPluginsCachePath();
146+
try {
147+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
148+
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, { mode: 0o600 });
149+
} catch {
150+
/* best-effort */
151+
}
152+
}
153+
154+
/**
155+
* Delete the disk cache (invalidated after install/remove/link)
156+
* @returns The commands cache or undefined if the cache is not found or invalid
157+
*/
158+
export async function clearCommandsCache(): Promise<void> {
159+
const path = getPluginsCachePath();
160+
try {
161+
await unlink(path);
162+
} catch {
163+
/* file may not exist */
164+
}
165+
}

packages/cli/src/plugins/manager.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type BailianCliPackageMeta,
1212
} from "bailian-cli-core";
1313
import { resetCommandCatalogCache } from "../load-commands.ts";
14+
import { clearCommandsCache } from "./cache.ts";
1415
import { discoverPlugins, readPackageJson } from "./discover.ts";
1516
import {
1617
diffAddedDepNames,
@@ -58,6 +59,7 @@ async function writeManifest(manifest: UserPluginsManifest): Promise<void> {
5859
},
5960
);
6061
resetCommandCatalogCache();
62+
void clearCommandsCache();
6163
}
6264

6365
async function validatePluginPackageAsync(
@@ -69,7 +71,7 @@ async function validatePluginPackageAsync(
6971
}
7072
if (!isBailianPluginPackage(pjson.name, pjson.bailianCli)) {
7173
throw new BailianError(
72-
`Package "${pjson.name}" is not a valid bailian-cli plugin (set bailianCli.plugin=true and bailianCli.commands).`,
74+
`Package "${pjson.name}" is not a valid bailian-cli plugin (name must be bailian-plugin-* or @scope/bailian-plugin-*, with bailianCli.plugin=true and bailianCli.commands).`,
7375
ExitCode.USAGE,
7476
);
7577
}

packages/cli/src/plugins/paths.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ export function resolveCliPackageRoot(): string {
2121
*/
2222
export function getNodeModuleRoots(): string[] {
2323
const roots = new Set<string>();
24-
const cwdModules = join(process.cwd(), "node_modules");
25-
if (existsSync(cwdModules)) roots.add(cwdModules);
2624

2725
const cliModules = join(resolveCliPackageRoot(), "node_modules");
2826
if (existsSync(cliModules)) roots.add(cliModules);
@@ -32,3 +30,11 @@ export function getNodeModuleRoots(): string[] {
3230

3331
return [...roots];
3432
}
33+
34+
/**
35+
* Get the plugins cache path.
36+
* @returns The plugins cache path.
37+
*/
38+
export function getPluginsCachePath(): string {
39+
return join(getPluginsDir(), "commands-cache.json");
40+
}

0 commit comments

Comments
 (0)