Skip to content
Open
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
84 changes: 84 additions & 0 deletions docs/guide/whitelist.md
Original file line number Diff line number Diff line change
@@ -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 <site>`) 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 '<site>'`.
- **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)
12 changes: 11 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <fmt>', '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';

Expand Down
20 changes: 16 additions & 4 deletions src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand Down
90 changes: 90 additions & 0 deletions src/whitelist.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string> | 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());
}