diff --git a/docs/guide/extending-opencli.md b/docs/guide/extending-opencli.md index b26dd4965..a346420af 100644 --- a/docs/guide/extending-opencli.md +++ b/docs/guide/extending-opencli.md @@ -1,6 +1,6 @@ # 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 | |------|-----|-----------------|-----------------| @@ -8,6 +8,7 @@ OpenCLI has five extension paths. Pick the path based on where you want the sour | Quickly draft a private adapter on this machine | User adapter | `~/.opencli/clis//.js` | `opencli ` | | Edit an official adapter locally | Adapter override | `~/.opencli/clis//` | `opencli ` | | Publish or install third-party commands | Plugin | Git repo, installed into `~/.opencli/plugins/` | `opencli ` | +| Scope commands to a single project | Project-local adapter / plugin | `./.opencli/clis//` or `./.opencli/plugins//` | `opencli ` | | Wrap an existing local binary | External CLI | `~/.opencli/external-clis.yaml` | `opencli ...` | ## Personal commands in your own Git repo @@ -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//.js # adapters local to this project (commit) + plugins//.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 +``` + +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. diff --git a/src/discovery-project.test.ts b/src/discovery-project.test.ts new file mode 100644 index 000000000..5cc56ca27 --- /dev/null +++ b/src/discovery-project.test.ts @@ -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 }); + } + }); +}); diff --git a/src/discovery.ts b/src/discovery.ts index 9a179e8b1..d7ab6961b 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -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*\(/; @@ -88,6 +100,19 @@ export async function ensureUserAdapters(): Promise { 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 { + 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. @@ -154,7 +179,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< async function discoverClisFromFs(dir: string): Promise { 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) => { @@ -182,15 +207,18 @@ async function discoverClisFromFs(dir: string): Promise { } /** - * 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 { - 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 { + 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); })); diff --git a/src/main.ts b/src/main.ts index 9a88e7294..79503e77f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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'); @@ -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)