From eba848ec2eb83d144f25d810641ca6e0bc49381f Mon Sep 17 00:00:00 2001 From: l5oo00 Date: Sat, 16 May 2026 23:31:05 +0800 Subject: [PATCH] feat: add whitelist support for site commands Introduce a whitelist mechanism that controls both visibility and executability of built-in commands. Commands not in the whitelist are hidden from `opencli list` and cannot be run. - Add whitelist module with YAML array config (site name or site: cmd1, cmd2 format), parsed into Sets for O(1) lookup - Skip site registration when no commands are whitelisted, producing `unknown command` for non-whitelisted sites - Add `opencli list --all` to bypass whitelist for display - Drop WhitelistConfig type, only array format supported - Document whitelist config, behavior, and `--all` flag --- docs/guide/whitelist.md | 84 ++++++++++++++++++++++++++++++++++++++ src/cli.ts | 12 +++++- src/commanderAdapter.ts | 20 +++++++-- src/whitelist.ts | 90 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 docs/guide/whitelist.md create mode 100644 src/whitelist.ts diff --git a/docs/guide/whitelist.md b/docs/guide/whitelist.md new file mode 100644 index 000000000..ef3e091af --- /dev/null +++ b/docs/guide/whitelist.md @@ -0,0 +1,84 @@ +# Whitelist + +Control which commands are **visible** and **executable** by configuring a whitelist. Commands not in the whitelist are entirely unavailable — they won't appear in `opencli list`, and running them directly (e.g. `opencli `) will show `error: unknown command`. + +Use `opencli list --all` to bypass the whitelist for display purposes. + +## Configuration File + +The whitelist is defined in `~/.opencli/whitelist.yaml`. Create this file if it doesn't exist. + +## Configuration Format + +Whitelist uses YAML array format. Each entry is either a site name (all commands enabled) or a site with comma-separated commands (only those commands enabled): + +```yaml +sites: + - bilibili # All bilibili commands + - reddit: hot, popular # Only hot and popular + - twitter: timeline, trending, search +``` + +### Enable Entire Site + +List just the site name: + +```yaml +sites: + - bilibili + - hackernews +``` + +### Enable Specific Commands + +Use `site: command1, command2, ...` format: + +```yaml +sites: + - bilibili: hot, search + - twitter: timeline, trending +``` + +Only `opencli bilibili hot`, `opencli bilibili search`, `opencli twitter timeline`, and `opencli twitter trending` are available. Any other bilibili or twitter commands are hidden and cannot be executed. + +## Examples + +### Mix Entire Sites and Specific Commands + +```yaml +sites: + - bilibili # All bilibili commands + - reddit: hot, popular # Only these reddit commands + - hackernews # All hackernews commands +``` + +### Hide a Site Completely + +Simply omit the site from the configuration. Running `opencli reddit` will report `unknown command`. + +```yaml +sites: + - bilibili: hot + # reddit is not listed — all reddit commands are hidden and unusable +``` + +## Default Behavior + +When no whitelist configuration exists (the file doesn't exist or `sites` is not an array), all registered commands are visible and executable. The whitelist only restricts what would otherwise be available. + +## Execution Behavior + +- **Hidden from list**: Commands not in the whitelist won't show in `opencli list`. +- **Cannot be executed**: Running a non-whitelisted command (e.g. `opencli zhihu hot`) reports `error: unknown command`. +- **Unknown site**: Running a non-whitelisted site (e.g. `opencli zhihu`) reports `error: unknown command ''`. +- **Temporary bypass**: `opencli list --all` shows every registered command but does not affect execution — non-whitelisted commands still cannot be run. + +## External CLIs + +The whitelist only affects built-in OpenCLI commands and adapters. External CLIs registered via `opencli external register` (e.g. `gh`, `docker`) are not affected — they always appear and can always be executed regardless of the whitelist settings. + +## Related Commands + +- `opencli list` — View available commands (filtered by whitelist if configured) +- `opencli list --all` — View all commands, ignoring whitelist for display +- `opencli external register` — Register external CLI tools (not affected by whitelist) \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index b72fff065..ff74a9a9a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { render as renderOutput } from './output.js'; import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js'; +import { loadWhitelist, isCommandWhitelisted } from './whitelist.js'; import { registerAllCommands } from './commanderAdapter.js'; import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, leadingPositionalFromUsage, rootHelpData, type RootAdapterGroups } from './help.js'; import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js'; @@ -565,10 +566,19 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command program .command('list') .description('List all available CLI commands') + .option('-a, --all', 'Show all commands ignoring whitelist') .option('-f, --format ', 'Output format: table, json, yaml, md, csv', 'table') .action((opts) => { const registry = getRegistry(); - const commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b))); + let commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b))); + + if (!opts.all) { + const whitelist = loadWhitelist(); + if (whitelist) { + commands = commands.filter((cmd) => isCommandWhitelisted(cmd.site, cmd.name, whitelist)); + } + } + const fmt = opts.format; const isStructured = fmt === 'json' || fmt === 'yaml'; diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 41cf618cd..802e35b57 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -32,6 +32,7 @@ import { EXIT_CODES, toEnvelope, } from './errors.js'; +import { loadWhitelist, isCommandWhitelisted } from './whitelist.js'; /** * Register a single CliCommand as a Commander subcommand. @@ -204,25 +205,36 @@ export function registerAllCommands( commandsBySite.set(cmd.site, commands); } + // Load whitelist once before registering commands + const whitelist = loadWhitelist(); + for (const [site, commands] of commandsBySite) { + const whitelistedCommands = whitelist + ? commands.filter(cmd => isCommandWhitelisted(cmd.site, cmd.name, whitelist)) + : commands; + + if (whitelistedCommands.length === 0) { + continue; + } + let siteCmd = siteGroups.get(site); if (!siteCmd) { siteCmd = program.command(site); siteGroups.set(site, siteCmd); } - for (const cmd of commands) { + for (const cmd of whitelistedCommands) { registerCommandToProgram(siteCmd, cmd); } - const commandTerms = new Map(commands.map(cmd => [cmd.name, formatCommandListTerm(cmd)])); + const commandTerms = new Map(whitelistedCommands.map(cmd => [cmd.name, formatCommandListTerm(cmd)])); siteCmd.configureHelp({ subcommandTerm: command => commandTerms.get(command.name()) ?? command.name(), }); const originalSiteHelpInformation = siteCmd.helpInformation.bind(siteCmd); siteCmd.helpInformation = ((contextOptions?: unknown) => { const format = getRequestedHelpFormat(); - if (format) return renderStructuredHelp(siteHelpData(site, commands), format); + if (format) return renderStructuredHelp(siteHelpData(site, whitelistedCommands), format); void originalSiteHelpInformation(contextOptions as never); - return formatSiteHelpText(site, commands); + return formatSiteHelpText(site, whitelistedCommands); }) as Command['helpInformation']; } return [...commandsBySite.keys()].sort((a, b) => a.localeCompare(b)); diff --git a/src/whitelist.ts b/src/whitelist.ts new file mode 100644 index 000000000..753a6d401 --- /dev/null +++ b/src/whitelist.ts @@ -0,0 +1,90 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import yaml from 'js-yaml'; +import { log } from './logger.js'; +import { getErrorMessage } from './errors.js'; + +export function getWhitelistPath(): string { + const home = os.homedir(); + return path.join(home, '.opencli', 'whitelist.yaml'); +} + +export interface ProcessedWhitelist { + sites: Record | null>; +} + +let _cachedWhitelist: ProcessedWhitelist | null = null; + +/** + * Supported YAML format (array only): + * + * sites: + * - bilibili + * - reddit: hot, popular + * - twitter: timeline, trending, search + * + * Strings → entire site enabled (null). + * Objects → comma-separated commands parsed into a Set. + */ +export function loadWhitelist(): ProcessedWhitelist | null { + if (_cachedWhitelist) return _cachedWhitelist; + + const whitelistPath = getWhitelistPath(); + if (!fs.existsSync(whitelistPath)) { + return null; + } + + try { + const raw = fs.readFileSync(whitelistPath, 'utf8'); + const parsed: any = yaml.load(raw); + if (!parsed || !Array.isArray(parsed.sites)) { + return null; + } + + const processed: ProcessedWhitelist = { sites: {} }; + + for (const item of parsed.sites) { + if (typeof item === 'string') { + processed.sites[item] = null; + } else if (item && typeof item === 'object') { + for (const [site, value] of Object.entries(item)) { + if (value === null || value === undefined) { + processed.sites[site] = null; + } else if (typeof value === 'string') { + processed.sites[site] = new Set( + value.split(',').map(cmd => cmd.trim().toLowerCase()).filter(Boolean) + ); + } + } + } + } + + _cachedWhitelist = processed; + return _cachedWhitelist; + } catch (err) { + log.warn(`Failed to parse whitelist.yaml: ${getErrorMessage(err)}`); + return null; + } +} + +/** + * Check if a command is whitelisted for a given site. + * O(1) lookup using Set.has() + */ +export function isCommandWhitelisted(site: string, name: string, whitelist: ProcessedWhitelist): boolean { + const siteConfig = whitelist.sites?.[site]; + + // If site not in config → not whitelisted + if (siteConfig === undefined) { + return false; + } + + // If site value is null → entire site enabled + if (siteConfig === null) { + return true; + } + + // If site value is Set → check if command in set (O(1)) + return siteConfig.has(name.toLowerCase()); +} \ No newline at end of file