Skip to content

Commit 853e1fd

Browse files
committed
feat(runtime): add project-local adapter and plugin discovery under ./.opencli/
Adds a project-local discovery step that loads adapters from `./.opencli/clis/<site>/<command>.js` and plugins from `./.opencli/plugins/<plugin>/` when opencli runs inside a project. Mirrors the existing user-level layer at `~/.opencli/`: same import surface (`@jackwener/opencli/registry`), same compat shim mechanism, so a repo can check project-specific adapters into VCS and they override built-ins for that working directory. Discovery order (last wins on collision): built-in -> ~/.opencli/clis -> ./.opencli/clis -> ~/.opencli/plugins -> ./.opencli/plugins Pure addition. No flags, env vars, or config file. External CLIs (gh, ntn, tg, ...) are passthrough binaries on a separate registration path and are unaffected. Closes the project-isolation half of #1423. The "disable built-ins" half is intentionally deferred: recent work (#1559 notion -> ntn migration, #1544 -cli suffix drop) suggests built-in adapter scope will shrink organically via external-CLI replacement, which addresses the underlying AI-agent tool-list concern more directly than a runtime flag. Changes: - src/discovery.ts: add projectOpenCliDir / projectClisDir / projectPluginsDir helpers, add ensureProjectCliCompatShims (reuses ensureUserCliCompatShims with a project root), widen discoverPlugins to take an optional dir parameter so project plugins can be discovered from ./.opencli/plugins/. - src/main.ts: thread the new helpers, call ensureProjectCliCompatShims in the parallel startup block, run discoverClis(PROJECT_CLIS) after USER_CLIS, and discoverPlugins(PROJECT_PLUGINS) after the default plugins pass. - docs/guide/extending-opencli.md: document the project-local layout and discovery order. - src/discovery-project.test.ts: 3 tests covering the path helpers, project-local adapter discovery, and project-local plugin discovery.
1 parent dadf01b commit 853e1fd

4 files changed

Lines changed: 141 additions & 12 deletions

File tree

docs/guide/extending-opencli.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# Extending OpenCLI
22

3-
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.
3+
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.
44

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

1314
## Personal commands in your own Git repo
@@ -132,3 +133,18 @@ opencli my-tool --help
132133
```
133134

134135
External CLIs pass stdio and exit codes through to the underlying binary.
136+
137+
## Project-local adapters and plugins
138+
139+
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.
140+
141+
```text
142+
my-project/
143+
.opencli/
144+
clis/<site>/<command>.js # adapters local to this project
145+
plugins/<plugin>/<file>.js # plugins local to this project
146+
```
147+
148+
Project-local commands are discovered when `opencli` runs from a directory that contains `./.opencli/`. The discovery order is `built-in → user (~/.opencli) → project (./.opencli)`, so a project adapter overrides a user adapter, which in turn overrides a built-in adapter with the same `site/command`.
149+
150+
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.

src/discovery-project.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Tests for project-local discovery: ./.opencli/clis and ./.opencli/plugins.
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import * as fs from 'node:fs';
7+
import * as os from 'node:os';
8+
import * as path from 'node:path';
9+
import { pathToFileURL } from 'node:url';
10+
import {
11+
discoverClis,
12+
discoverPlugins,
13+
projectClisDir,
14+
projectOpenCliDir,
15+
projectPluginsDir,
16+
} from './discovery.js';
17+
import { getRegistry } from './registry.js';
18+
19+
describe('project-local discovery paths', () => {
20+
it('projectOpenCliDir / projectClisDir / projectPluginsDir resolve relative to the given cwd', () => {
21+
const cwd = '/tmp/example-project';
22+
expect(projectOpenCliDir(cwd)).toBe(path.join(cwd, '.opencli'));
23+
expect(projectClisDir(cwd)).toBe(path.join(cwd, '.opencli', 'clis'));
24+
expect(projectPluginsDir(cwd)).toBe(path.join(cwd, '.opencli', 'plugins'));
25+
});
26+
27+
it('discoverClis(projectDir) loads adapters from a project-local clis directory', async () => {
28+
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-project-clis-'));
29+
const site = 'project-local-site';
30+
const registryUrl = pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href;
31+
32+
try {
33+
const siteDir = path.join(tempRoot, site);
34+
await fs.promises.mkdir(siteDir, { recursive: true });
35+
await fs.promises.writeFile(path.join(siteDir, 'hello.js'), `
36+
import { cli, Strategy } from '${registryUrl}';
37+
cli({
38+
site: '${site}',
39+
name: 'hello', access: 'read',
40+
description: 'hello command',
41+
strategy: Strategy.PUBLIC,
42+
browser: false,
43+
func: async () => [{ ok: true }],
44+
});
45+
`);
46+
47+
await discoverClis(tempRoot);
48+
expect(getRegistry().get(`${site}/hello`)).toBeDefined();
49+
} finally {
50+
await fs.promises.rm(tempRoot, { recursive: true, force: true });
51+
}
52+
});
53+
54+
it('discoverPlugins(dir) loads plugin files from a project-local plugins directory', async () => {
55+
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-project-plugins-'));
56+
const pluginName = 'project-plugin-site';
57+
const pluginDir = path.join(tempRoot, pluginName);
58+
const registryUrl = pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href;
59+
60+
try {
61+
await fs.promises.mkdir(pluginDir, { recursive: true });
62+
await fs.promises.writeFile(path.join(pluginDir, 'hi.js'), `
63+
import { cli, Strategy } from '${registryUrl}';
64+
cli({
65+
site: '${pluginName}',
66+
name: 'hi', access: 'read',
67+
description: 'hi command',
68+
strategy: Strategy.PUBLIC,
69+
browser: false,
70+
func: async () => [{ ok: true }],
71+
});
72+
`);
73+
74+
await discoverPlugins(tempRoot);
75+
expect(getRegistry().get(`${pluginName}/hi`)).toBeDefined();
76+
} finally {
77+
await fs.promises.rm(tempRoot, { recursive: true, force: true });
78+
}
79+
});
80+
});

src/discovery.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli');
2424
export const USER_CLIS_DIR = path.join(USER_OPENCLI_DIR, 'clis');
2525
/** Plugins directory: ~/.opencli/plugins/ */
2626
export const PLUGINS_DIR = path.join(USER_OPENCLI_DIR, 'plugins');
27+
/** Project-local opencli root, resolved against the current working directory at call time. */
28+
export function projectOpenCliDir(cwd: string = process.cwd()): string {
29+
return path.join(cwd, '.opencli');
30+
}
31+
/** Project-local CLIs directory: ./.opencli/clis */
32+
export function projectClisDir(cwd: string = process.cwd()): string {
33+
return path.join(projectOpenCliDir(cwd), 'clis');
34+
}
35+
/** Project-local plugins directory: ./.opencli/plugins */
36+
export function projectPluginsDir(cwd: string = process.cwd()): string {
37+
return path.join(projectOpenCliDir(cwd), 'plugins');
38+
}
2739
/** Matches files that register commands via cli() or lifecycle hooks */
2840
const PLUGIN_MODULE_PATTERN = /\b(?:cli|onStartup|onBeforeExecute|onAfterExecute)\s*\(/;
2941

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

103+
/**
104+
* Set up the package-exports symlink for a project-local `.opencli` directory
105+
* so adapters under `./.opencli/clis/` can `import { cli } from '@jackwener/opencli/registry'`.
106+
*
107+
* Mirrors `ensureUserCliCompatShims` but scoped to a project root. Returns
108+
* silently when the project root has no `.opencli/` directory yet.
109+
*/
110+
export async function ensureProjectCliCompatShims(cwd: string = process.cwd()): Promise<void> {
111+
const baseDir = projectOpenCliDir(cwd);
112+
try { await fs.promises.access(baseDir); } catch { return; }
113+
await ensureUserCliCompatShims(baseDir);
114+
}
115+
91116
/**
92117
* Discover and register CLI commands.
93118
* Uses pre-compiled manifest when available for instant startup.
@@ -154,7 +179,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
154179
async function discoverClisFromFs(dir: string): Promise<void> {
155180
try { await fs.promises.access(dir); } catch { return; }
156181
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
157-
182+
158183
const sitePromises = entries
159184
.filter(entry => entry.isDirectory())
160185
.map(async (entry) => {
@@ -182,15 +207,18 @@ async function discoverClisFromFs(dir: string): Promise<void> {
182207
}
183208

184209
/**
185-
* Discover and register plugins from ~/.opencli/plugins/.
210+
* Discover and register plugins from a plugins directory.
211+
* Defaults to `~/.opencli/plugins/`; pass a custom directory to load
212+
* project-local plugins from `./.opencli/plugins/`.
213+
*
186214
* Each subdirectory is treated as a plugin (site = directory name).
187215
* Files inside are scanned flat (no nested site subdirs).
188216
*/
189-
export async function discoverPlugins(): Promise<void> {
190-
try { await fs.promises.access(PLUGINS_DIR); } catch { return; }
191-
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
217+
export async function discoverPlugins(dir: string = PLUGINS_DIR): Promise<void> {
218+
try { await fs.promises.access(dir); } catch { return; }
219+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
192220
await Promise.all(entries.map(async (entry) => {
193-
const pluginDir = path.join(PLUGINS_DIR, entry.name);
221+
const pluginDir = path.join(dir, entry.name);
194222
if (!(await isDiscoverablePluginDir(entry, pluginDir))) return;
195223
await discoverPluginDir(pluginDir, entry.name);
196224
}));

src/main.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ if (getCompIdx !== -1) {
9191

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

101101
installNodeNetwork();
102102

103+
const PROJECT_CLIS = projectClisDir();
104+
const PROJECT_PLUGINS = projectPluginsDir();
105+
103106
// Parallelise independent startup I/O:
104107
// - Built-in adapter discovery has no dependency on user-dir setup.
105108
// - ensureUserCliCompatShims and ensureUserAdapters operate on different paths
106109
// (~/.opencli/node_modules/ vs ~/.opencli/clis/ + adapter-manifest.json).
107110
// - registerCommand() overwrites on name collision (see registry.ts), so
108-
// user-CLI discovery MUST run after built-in discovery to preserve the
109-
// intended override order (user adapters override built-in ones).
110-
// - discoverPlugins runs last: plugins may override both built-in and user CLIs.
111+
// later layers MUST run after earlier ones to preserve the override order:
112+
// built-in < user < project < plugin < project-plugin (last wins).
111113
const skipUserDiscovery = argv[0] === 'convention-audit';
112114
if (skipUserDiscovery) {
113115
await discoverClis(BUILTIN_CLIS);
114116
} else {
115-
const [, ,] = await Promise.all([
117+
await Promise.all([
116118
ensureUserCliCompatShims(),
117119
ensureUserAdapters(),
120+
ensureProjectCliCompatShims(),
118121
discoverClis(BUILTIN_CLIS),
119122
]);
120123
await discoverClis(USER_CLIS);
124+
await discoverClis(PROJECT_CLIS);
121125
await discoverPlugins();
126+
await discoverPlugins(PROJECT_PLUGINS);
122127
}
123128

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

0 commit comments

Comments
 (0)