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
37 changes: 36 additions & 1 deletion docs/guide/extending-opencli.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Extending OpenCLI

OpenCLI has five extension paths. Pick the path based on where you want the source code to live and how you want commands to be shared.
OpenCLI has six extension paths. Pick the path based on where you want the source code to live and how you want commands to be shared.

| Goal | Use | Source location | Command surface |
|------|-----|-----------------|-----------------|
| Build a personal website command in your own Git repo | Local plugin | Your project directory, symlinked into `~/.opencli/plugins/` | `opencli <plugin> <command>` |
| Quickly draft a private adapter on this machine | User adapter | `~/.opencli/clis/<site>/<command>.js` | `opencli <site> <command>` |
| Edit an official adapter locally | Adapter override | `~/.opencli/clis/<site>/` | `opencli <site> <command>` |
| Publish or install third-party commands | Plugin | Git repo, installed into `~/.opencli/plugins/` | `opencli <plugin> <command>` |
| Scope commands to a single project | Project-local adapter / plugin | `./.opencli/clis/<site>/` or `./.opencli/plugins/<plugin>/` | `opencli <site> <command>` |
| Wrap an existing local binary | External CLI | `~/.opencli/external-clis.yaml` | `opencli <tool> ...` |

## Personal commands in your own Git repo
Expand Down Expand Up @@ -132,3 +133,37 @@ opencli my-tool --help
```

External CLIs pass stdio and exit codes through to the underlying binary.

## Project-local adapters and plugins

When a project ships its own opencli commands, place them under `./.opencli/` next to the source tree so they can be checked into version control alongside the rest of the project.

```text
my-project/
.opencli/
clis/<site>/<command>.js # adapters local to this project (commit)
plugins/<plugin>/<file>.js # plugins local to this project (commit)
package.json # generated; ESM marker for runtime resolution
node_modules/@jackwener/opencli # generated; symlink to the installed package
```
Comment on lines +141 to +148

The `package.json` and `node_modules/@jackwener/opencli` symlink under `./.opencli/` are created automatically by opencli at startup (`ensureProjectCliCompatShims`) so that adapters can `import { cli } from '@jackwener/opencli/registry'` without a per-project `npm install`. Add the generated paths to `.gitignore`:

```gitignore
.opencli/package.json
.opencli/node_modules/
```

Project-local commands are discovered when `opencli` runs from a directory that contains `./.opencli/`. Full discovery order, last write wins on `site/command` collision:

```text
built-in
-> ~/.opencli/clis
-> ./.opencli/clis
-> ~/.opencli/plugins
-> ./.opencli/plugins
```

So a project adapter overrides a user adapter, which overrides a built-in adapter with the same name; a project plugin can in turn override any of those.

Use this layout to keep a small, repo-scoped set of commands available to AI agents working inside that project, without polluting the global `~/.opencli/` namespace.
80 changes: 80 additions & 0 deletions src/discovery-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Tests for project-local discovery: ./.opencli/clis and ./.opencli/plugins.
*/

import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
discoverClis,
discoverPlugins,
projectClisDir,
projectOpenCliDir,
projectPluginsDir,
} from './discovery.js';
import { getRegistry } from './registry.js';

describe('project-local discovery paths', () => {
it('projectOpenCliDir / projectClisDir / projectPluginsDir resolve relative to the given cwd', () => {
const cwd = '/tmp/example-project';
expect(projectOpenCliDir(cwd)).toBe(path.join(cwd, '.opencli'));
expect(projectClisDir(cwd)).toBe(path.join(cwd, '.opencli', 'clis'));
expect(projectPluginsDir(cwd)).toBe(path.join(cwd, '.opencli', 'plugins'));
});

it('discoverClis(projectDir) loads adapters from a project-local clis directory', async () => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-project-clis-'));
const site = 'project-local-site';
const registryUrl = pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href;

try {
const siteDir = path.join(tempRoot, site);
await fs.promises.mkdir(siteDir, { recursive: true });
await fs.promises.writeFile(path.join(siteDir, 'hello.js'), `
import { cli, Strategy } from '${registryUrl}';
cli({
site: '${site}',
name: 'hello', access: 'read',
description: 'hello command',
strategy: Strategy.PUBLIC,
browser: false,
func: async () => [{ ok: true }],
});
`);

await discoverClis(tempRoot);
expect(getRegistry().get(`${site}/hello`)).toBeDefined();
} finally {
await fs.promises.rm(tempRoot, { recursive: true, force: true });
}
});

it('discoverPlugins(dir) loads plugin files from a project-local plugins directory', async () => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-project-plugins-'));
const pluginName = 'project-plugin-site';
const pluginDir = path.join(tempRoot, pluginName);
const registryUrl = pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href;

try {
await fs.promises.mkdir(pluginDir, { recursive: true });
await fs.promises.writeFile(path.join(pluginDir, 'hi.js'), `
import { cli, Strategy } from '${registryUrl}';
cli({
site: '${pluginName}',
name: 'hi', access: 'read',
description: 'hi command',
strategy: Strategy.PUBLIC,
browser: false,
func: async () => [{ ok: true }],
});
`);

await discoverPlugins(tempRoot);
expect(getRegistry().get(`${pluginName}/hi`)).toBeDefined();
} finally {
await fs.promises.rm(tempRoot, { recursive: true, force: true });
}
});
});
40 changes: 34 additions & 6 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli');
export const USER_CLIS_DIR = path.join(USER_OPENCLI_DIR, 'clis');
/** Plugins directory: ~/.opencli/plugins/ */
export const PLUGINS_DIR = path.join(USER_OPENCLI_DIR, 'plugins');
/** Project-local opencli root, resolved against the current working directory at call time. */
export function projectOpenCliDir(cwd: string = process.cwd()): string {
return path.join(cwd, '.opencli');
}
/** Project-local CLIs directory: ./.opencli/clis */
export function projectClisDir(cwd: string = process.cwd()): string {
return path.join(projectOpenCliDir(cwd), 'clis');
}
/** Project-local plugins directory: ./.opencli/plugins */
export function projectPluginsDir(cwd: string = process.cwd()): string {
return path.join(projectOpenCliDir(cwd), 'plugins');
}
/** Matches files that register commands via cli() or lifecycle hooks */
const PLUGIN_MODULE_PATTERN = /\b(?:cli|onStartup|onBeforeExecute|onAfterExecute)\s*\(/;

Expand Down Expand Up @@ -88,6 +100,19 @@ export async function ensureUserAdapters(): Promise<void> {
await fs.promises.mkdir(USER_CLIS_DIR, { recursive: true });
}

/**
* Set up the package-exports symlink for a project-local `.opencli` directory
* so adapters under `./.opencli/clis/` can `import { cli } from '@jackwener/opencli/registry'`.
*
* Mirrors `ensureUserCliCompatShims` but scoped to a project root. Returns
* silently when the project root has no `.opencli/` directory yet.
*/
export async function ensureProjectCliCompatShims(cwd: string = process.cwd()): Promise<void> {
const baseDir = projectOpenCliDir(cwd);
try { await fs.promises.access(baseDir); } catch { return; }
await ensureUserCliCompatShims(baseDir);
}

/**
* Discover and register CLI commands.
* Uses pre-compiled manifest when available for instant startup.
Expand Down Expand Up @@ -154,7 +179,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
async function discoverClisFromFs(dir: string): Promise<void> {
try { await fs.promises.access(dir); } catch { return; }
const entries = await fs.promises.readdir(dir, { withFileTypes: true });

const sitePromises = entries
.filter(entry => entry.isDirectory())
.map(async (entry) => {
Expand Down Expand Up @@ -182,15 +207,18 @@ async function discoverClisFromFs(dir: string): Promise<void> {
}

/**
* Discover and register plugins from ~/.opencli/plugins/.
* Discover and register plugins from a plugins directory.
* Defaults to `~/.opencli/plugins/`; pass a custom directory to load
* project-local plugins from `./.opencli/plugins/`.
*
* Each subdirectory is treated as a plugin (site = directory name).
* Files inside are scanned flat (no nested site subdirs).
*/
export async function discoverPlugins(): Promise<void> {
try { await fs.promises.access(PLUGINS_DIR); } catch { return; }
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
export async function discoverPlugins(dir: string = PLUGINS_DIR): Promise<void> {
try { await fs.promises.access(dir); } catch { return; }
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
await Promise.all(entries.map(async (entry) => {
const pluginDir = path.join(PLUGINS_DIR, entry.name);
const pluginDir = path.join(dir, entry.name);
if (!(await isDiscoverablePluginDir(entry, pluginDir))) return;
await discoverPluginDir(pluginDir, entry.name);
}));
Expand Down
15 changes: 10 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ if (getCompIdx !== -1) {

// ── Full startup path ───────────────────────────────────────────────────
// Dynamic imports: these are deferred so the fast path above never pays the cost.
const { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters } = await import('./discovery.js');
const { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, ensureProjectCliCompatShims, projectClisDir, projectPluginsDir } = await import('./discovery.js');
const { getCompletions } = await import('./completion.js');
const { runCli } = await import('./cli.js');
const { emitHook } = await import('./hooks.js');
Expand All @@ -100,25 +100,30 @@ const { registerUpdateNoticeOnExit, checkForUpdateBackground } = await import('.

installNodeNetwork();

const PROJECT_CLIS = projectClisDir();
const PROJECT_PLUGINS = projectPluginsDir();

// Parallelise independent startup I/O:
// - Built-in adapter discovery has no dependency on user-dir setup.
// - ensureUserCliCompatShims and ensureUserAdapters operate on different paths
// (~/.opencli/node_modules/ vs ~/.opencli/clis/ + adapter-manifest.json).
// - registerCommand() overwrites on name collision (see registry.ts), so
// user-CLI discovery MUST run after built-in discovery to preserve the
// intended override order (user adapters override built-in ones).
// - discoverPlugins runs last: plugins may override both built-in and user CLIs.
// later layers MUST run after earlier ones to preserve the override order:
// built-in < user < project < plugin < project-plugin (last wins).
const skipUserDiscovery = argv[0] === 'convention-audit';
if (skipUserDiscovery) {
await discoverClis(BUILTIN_CLIS);
} else {
const [, ,] = await Promise.all([
await Promise.all([
ensureUserCliCompatShims(),
ensureUserAdapters(),
ensureProjectCliCompatShims(),
discoverClis(BUILTIN_CLIS),
]);
await discoverClis(USER_CLIS);
await discoverClis(PROJECT_CLIS);
await discoverPlugins();
await discoverPlugins(PROJECT_PLUGINS);
}

// Register exit hook: notice appears after command output (same as npm/gh/yarn)
Expand Down
Loading