@bahulam/code 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/src/agents/loader.mjs +26 -4
- package/src/auth/bahulam-auth.mjs +2 -13
- package/src/commands/plugin-manage.mjs +449 -0
- package/src/commands/plugin.mjs +247 -0
- package/src/config/env.mjs +2 -0
- package/src/config/hook-runner.mjs +8 -8
- package/src/config/memory-loader.mjs +7 -3
- package/src/config/settings-loader.mjs +5 -3
- package/src/core/attachments.mjs +2 -2
- package/src/core/headless.mjs +6 -1
- package/src/core/local-store.mjs +10 -10
- package/src/core/paths.mjs +10 -96
- package/src/core/policy-resolver.mjs +1 -1
- package/src/core/project-context-loader.mjs +2 -2
- package/src/core/stream-client.mjs +68 -1
- package/src/core/system-prompt.mjs +31 -12
- package/src/core/tool-executor.mjs +257 -12
- package/src/local-service/agent-relay.mjs +139 -12
- package/src/local-service/server.mjs +230 -7
- package/src/plugins/executor.mjs +121 -0
- package/src/plugins/loader.mjs +123 -123
- package/src/plugins/manifest.mjs +291 -0
- package/src/plugins/preflight.mjs +227 -0
- package/src/plugins/registry.mjs +233 -0
- package/src/plugins/state.mjs +290 -0
- package/src/terminal/agents.mjs +18 -1
- package/src/terminal/init.mjs +2 -2
- package/src/terminal/main.mjs +75 -2
- package/src/terminal/repl-explore.mjs +1 -1
- package/src/terminal/repl-render.mjs +2 -2
- package/src/terminal/repl.mjs +49 -3
- package/src/tools/analyze-image.mjs +1 -1
- package/src/tools/project-overview.mjs +7 -7
- package/src/ui/slash-commands.mjs +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bahulam/code",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "node src/terminal/main.mjs",
|
|
19
|
-
"test": "
|
|
19
|
+
"test": "node scripts/run-tests.mjs",
|
|
20
|
+
"test:unit": "node scripts/run-tests.mjs --unit",
|
|
21
|
+
"test:integration": "node scripts/run-tests.mjs --integration",
|
|
20
22
|
"sync-catalog": "node scripts/sync-catalog.mjs",
|
|
21
23
|
"prepublishOnly": "node scripts/sync-catalog.mjs"
|
|
22
24
|
},
|
|
@@ -40,13 +42,14 @@
|
|
|
40
42
|
"gemini"
|
|
41
43
|
],
|
|
42
44
|
"license": "Apache-2.0",
|
|
43
|
-
"author": "Bahulam <
|
|
45
|
+
"author": "Bahulam <support@bahulam.ai>",
|
|
44
46
|
"homepage": "https://bahulam.ai/code",
|
|
45
47
|
"repository": {
|
|
46
48
|
"type": "git",
|
|
47
49
|
"url": "git+https://github.com/BahulamAI/BahulamCode-CLI.git"
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
52
|
+
"js-yaml": "^5.4.1",
|
|
50
53
|
"mermaid": "^11.17.2",
|
|
51
54
|
"monaco-editor": "^0.56.0",
|
|
52
55
|
"pdf-parse": "^1.1.1",
|
package/src/agents/loader.mjs
CHANGED
|
@@ -24,13 +24,9 @@ export class AgentLoader {
|
|
|
24
24
|
* @param {string} [cwd] - project working directory
|
|
25
25
|
*/
|
|
26
26
|
load(cwd = process.cwd()) {
|
|
27
|
-
// Prefer .bahulam/, but also scan legacy .kepler/ so agents defined
|
|
28
|
-
// under the old convention keep loading for existing projects.
|
|
29
27
|
this.searchPaths = [
|
|
30
28
|
path.join(cwd, '.bahulam', 'agents'),
|
|
31
|
-
path.join(cwd, '.kepler', 'agents'),
|
|
32
29
|
path.join(process.env.HOME || '', '.bahulam', 'agents'),
|
|
33
|
-
path.join(process.env.HOME || '', '.kepler', 'agents'),
|
|
34
30
|
];
|
|
35
31
|
|
|
36
32
|
for (const dir of this.searchPaths) {
|
|
@@ -66,6 +62,32 @@ export class AgentLoader {
|
|
|
66
62
|
}
|
|
67
63
|
}
|
|
68
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Load agents from plugin manifests.
|
|
67
|
+
* Plugin agents have lower priority than project .bahulam/agents agents.
|
|
68
|
+
* @param {object[]} plugins - List of plugin manifests
|
|
69
|
+
* @returns {this}
|
|
70
|
+
*/
|
|
71
|
+
loadFromPlugins(plugins) {
|
|
72
|
+
if (!Array.isArray(plugins)) return this;
|
|
73
|
+
for (const plugin of plugins) {
|
|
74
|
+
const agents = plugin.spec?.agents || [];
|
|
75
|
+
for (const agentDef of agents) {
|
|
76
|
+
const slug = agentDef.slug || agentDef.name || '';
|
|
77
|
+
if (!slug) continue;
|
|
78
|
+
// Project agents take precedence — skip if already registered
|
|
79
|
+
if (this.agents.has(slug)) continue;
|
|
80
|
+
this.agents.set(slug, {
|
|
81
|
+
...agentDef,
|
|
82
|
+
slug,
|
|
83
|
+
source: `plugin:${plugin.metadata?.name || 'unknown'}`,
|
|
84
|
+
source_scope: 'plugin',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
/**
|
|
70
92
|
* Get an agent definition by name.
|
|
71
93
|
* @param {string} name
|
|
@@ -11,8 +11,8 @@ import { getLoginSuccessHTML } from '../ui/banner.mjs';
|
|
|
11
11
|
import { resolveBackendUrl } from '../core/backend-url.mjs';
|
|
12
12
|
import { bahulamHome } from '../core/paths.mjs';
|
|
13
13
|
|
|
14
|
-
// Note: computed via a function (not a constant) so that BAHULAM_HOME
|
|
15
|
-
//
|
|
14
|
+
// Note: computed via a function (not a constant) so that BAHULAM_HOME
|
|
15
|
+
// env-var swaps mid-process still work.
|
|
16
16
|
function configDir() { return bahulamHome(); }
|
|
17
17
|
function configPath() { return path.join(configDir(), 'config.json'); }
|
|
18
18
|
|
|
@@ -24,17 +24,6 @@ const CONFIG_PATH = configPath();
|
|
|
24
24
|
let _tokenEnvNoticeShown = false;
|
|
25
25
|
function readTokenFromEnv() {
|
|
26
26
|
if (process.env.B0_TOKEN) return process.env.B0_TOKEN;
|
|
27
|
-
if (process.env.KEPLER_TOKEN) {
|
|
28
|
-
if (!_tokenEnvNoticeShown && process.env.B0_QUIET_MIGRATION !== '1') {
|
|
29
|
-
_tokenEnvNoticeShown = true;
|
|
30
|
-
try {
|
|
31
|
-
process.stderr.write(
|
|
32
|
-
' \x1b[2mnote: KEPLER_TOKEN is deprecated; set B0_TOKEN instead.\x1b[0m\n'
|
|
33
|
-
);
|
|
34
|
-
} catch {}
|
|
35
|
-
}
|
|
36
|
-
return process.env.KEPLER_TOKEN;
|
|
37
|
-
}
|
|
38
27
|
return null;
|
|
39
28
|
}
|
|
40
29
|
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin management commands — install, list, remove, enable, disable,
|
|
3
|
+
* info, update.
|
|
4
|
+
*
|
|
5
|
+
* Design notes:
|
|
6
|
+
* - Sources: git URLs, tarball URLs, local paths. No opinion on a registry
|
|
7
|
+
* yet; if a bare name is given to `install`, we look it up in the
|
|
8
|
+
* awesome-bahulam-plugins index (a plain JSON manifest hosted in the
|
|
9
|
+
* community repo).
|
|
10
|
+
* - Install target: `~/.bahulam/plugins/` by default, `.bahulam/plugins/`
|
|
11
|
+
* with --project. Never global npm, never modifies the user's PATH.
|
|
12
|
+
* - Disable: rename directory to `<name>.disabled`. The plugin registry
|
|
13
|
+
* only scans directories with a valid manifest, so this hides the plugin
|
|
14
|
+
* without deleting it. `enable` reverses the rename.
|
|
15
|
+
* - Update: for git-installed plugins we recorded the origin in
|
|
16
|
+
* `.bahulam-plugin.json` at install time; `update` does `git fetch` +
|
|
17
|
+
* `git checkout <ref>` (or fast-forwards main).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { parsePluginManifestFile } from '../plugins/manifest.mjs';
|
|
25
|
+
import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
|
|
26
|
+
|
|
27
|
+
const RESET = '\x1b[0m';
|
|
28
|
+
const BOLD = '\x1b[1m';
|
|
29
|
+
const DIM = '\x1b[2m';
|
|
30
|
+
const CYAN = '\x1b[36m';
|
|
31
|
+
const GREEN = '\x1b[32m';
|
|
32
|
+
const YELLOW = '\x1b[33m';
|
|
33
|
+
const RED = '\x1b[31m';
|
|
34
|
+
|
|
35
|
+
const REGISTRY_URL = 'https://raw.githubusercontent.com/BahulamAI/awesome-bahulam-plugins/main/registry.json';
|
|
36
|
+
const INSTALL_STAMP = '.bahulam-plugin.json';
|
|
37
|
+
|
|
38
|
+
function searchDirs(cwd) {
|
|
39
|
+
return [
|
|
40
|
+
{ scope: 'project', dir: path.join(cwd, '.bahulam', 'plugins') },
|
|
41
|
+
{ scope: 'global', dir: path.join(os.homedir(), '.bahulam', 'plugins') },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pluginTargetDir({ global, cwd }) {
|
|
46
|
+
return global
|
|
47
|
+
? path.join(os.homedir(), '.bahulam', 'plugins')
|
|
48
|
+
: path.join(cwd, '.bahulam', 'plugins');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readManifest(dir) {
|
|
52
|
+
for (const name of ['plugin.yaml', 'plugin.json']) {
|
|
53
|
+
const p = path.join(dir, name);
|
|
54
|
+
if (fs.existsSync(p)) return { manifestPath: p, manifest: parsePluginManifestFile(p) };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scanInstalled(cwd) {
|
|
60
|
+
const found = [];
|
|
61
|
+
for (const { scope, dir } of searchDirs(cwd)) {
|
|
62
|
+
if (!fs.existsSync(dir)) continue;
|
|
63
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
64
|
+
if (!entry.isDirectory()) continue;
|
|
65
|
+
const pluginDir = path.join(dir, entry.name);
|
|
66
|
+
const disabled = entry.name.endsWith('.disabled');
|
|
67
|
+
const parsed = disabled ? null : readManifest(pluginDir);
|
|
68
|
+
const stamp = readStamp(pluginDir);
|
|
69
|
+
found.push({
|
|
70
|
+
scope,
|
|
71
|
+
directory: pluginDir,
|
|
72
|
+
directoryName: entry.name.replace(/\.disabled$/, ''),
|
|
73
|
+
name: parsed?.manifest?.metadata?.name || entry.name.replace(/\.disabled$/, ''),
|
|
74
|
+
version: parsed?.manifest?.metadata?.version || null,
|
|
75
|
+
description: parsed?.manifest?.metadata?.description || '',
|
|
76
|
+
tools: parsed?.manifest?.spec?.tools?.length || 0,
|
|
77
|
+
agents: parsed?.manifest?.spec?.agents?.length || 0,
|
|
78
|
+
views: parsed?.manifest?.spec?.workspace?.views?.length || 0,
|
|
79
|
+
disabled,
|
|
80
|
+
origin: stamp?.origin || null,
|
|
81
|
+
installed_at: stamp?.installed_at || null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findByName(name, cwd) {
|
|
89
|
+
const needle = String(name || '').trim().toLowerCase();
|
|
90
|
+
return scanInstalled(cwd).find(p =>
|
|
91
|
+
p.name.toLowerCase() === needle || p.directoryName.toLowerCase() === needle
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readStamp(pluginDir) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = fs.readFileSync(path.join(pluginDir, INSTALL_STAMP), 'utf-8');
|
|
98
|
+
return JSON.parse(raw);
|
|
99
|
+
} catch { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeStamp(pluginDir, data) {
|
|
103
|
+
const stamp = { installed_at: new Date().toISOString(), ...data };
|
|
104
|
+
fs.writeFileSync(path.join(pluginDir, INSTALL_STAMP), JSON.stringify(stamp, null, 2) + '\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function run(cmd, args, opts = {}) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const child = spawn(cmd, args, { stdio: 'inherit', ...opts });
|
|
110
|
+
child.on('error', reject);
|
|
111
|
+
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function rmrf(dir) { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
116
|
+
|
|
117
|
+
// ── install ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function classifySource(source) {
|
|
120
|
+
if (!source) return { kind: 'invalid' };
|
|
121
|
+
if (/^(git@|https?:\/\/).*(\.git|github\.com|gitlab\.com|bitbucket\.org)/i.test(source)) return { kind: 'git', url: source };
|
|
122
|
+
if (/^https?:\/\/.+\.(tar\.gz|tgz|zip)(\?.*)?$/i.test(source)) return { kind: 'tarball', url: source };
|
|
123
|
+
const abs = path.isAbsolute(source) ? source : path.resolve(process.cwd(), source);
|
|
124
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) return { kind: 'local', path: abs };
|
|
125
|
+
return { kind: 'name', name: source };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function fetchRegistryEntry(name) {
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetch(REGISTRY_URL);
|
|
131
|
+
if (!res.ok) return null;
|
|
132
|
+
const registry = await res.json();
|
|
133
|
+
const list = Array.isArray(registry) ? registry : (registry.plugins || []);
|
|
134
|
+
return list.find(entry => (entry.name || '').toLowerCase() === name.toLowerCase()) || null;
|
|
135
|
+
} catch { return null; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function installFromGit({ url, targetDir, name, ref, subdir, force }) {
|
|
139
|
+
const dirName = name || url.replace(/\.git$/, '').split('/').filter(Boolean).pop();
|
|
140
|
+
const dest = path.join(targetDir, dirName);
|
|
141
|
+
if (fs.existsSync(dest)) {
|
|
142
|
+
if (!force) throw new Error(`already installed: ${dest} (use --force to overwrite)`);
|
|
143
|
+
rmrf(dest);
|
|
144
|
+
}
|
|
145
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
146
|
+
// Monorepo case: clone to tmp, copy the requested subdir, remove clone.
|
|
147
|
+
// Single-plugin repo: clone straight into place.
|
|
148
|
+
if (subdir) {
|
|
149
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bahulam-plugin-'));
|
|
150
|
+
await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, tmp]);
|
|
151
|
+
const src = path.join(tmp, subdir);
|
|
152
|
+
if (!fs.existsSync(src)) { rmrf(tmp); throw new Error(`subdir "${subdir}" not found in ${url}`); }
|
|
153
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
154
|
+
rmrf(tmp);
|
|
155
|
+
} else {
|
|
156
|
+
await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, dest]);
|
|
157
|
+
}
|
|
158
|
+
writeStamp(dest, { origin: { kind: 'git', url, ref: ref || null, subdir: subdir || null } });
|
|
159
|
+
return dest;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function installFromTarball({ url, targetDir, name, force }) {
|
|
163
|
+
const guessed = name || path.basename(url).replace(/\.(tar\.gz|tgz|zip)(\?.*)?$/i, '');
|
|
164
|
+
const dest = path.join(targetDir, guessed);
|
|
165
|
+
if (fs.existsSync(dest)) {
|
|
166
|
+
if (!force) throw new Error(`already installed: ${dest} (use --force to overwrite)`);
|
|
167
|
+
rmrf(dest);
|
|
168
|
+
}
|
|
169
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
170
|
+
const isZip = /\.zip(\?.*)?$/i.test(url);
|
|
171
|
+
const res = await fetch(url);
|
|
172
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
|
|
173
|
+
const tmp = path.join(os.tmpdir(), `bahulam-plugin-${Date.now()}${isZip ? '.zip' : '.tgz'}`);
|
|
174
|
+
fs.writeFileSync(tmp, Buffer.from(await res.arrayBuffer()));
|
|
175
|
+
if (isZip) await run('unzip', ['-q', tmp, '-d', dest]);
|
|
176
|
+
else await run('tar', ['-xzf', tmp, '-C', dest, '--strip-components=1']);
|
|
177
|
+
fs.unlinkSync(tmp);
|
|
178
|
+
writeStamp(dest, { origin: { kind: 'tarball', url } });
|
|
179
|
+
return dest;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function installFromLocal({ src, targetDir, force }) {
|
|
183
|
+
const manifestScan = readManifest(src);
|
|
184
|
+
const name = manifestScan?.manifest?.metadata?.name || path.basename(src);
|
|
185
|
+
const dest = path.join(targetDir, name);
|
|
186
|
+
if (fs.existsSync(dest)) {
|
|
187
|
+
if (!force) throw new Error(`already installed: ${dest} (use --force to overwrite)`);
|
|
188
|
+
rmrf(dest);
|
|
189
|
+
}
|
|
190
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
191
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
192
|
+
writeStamp(dest, { origin: { kind: 'local', path: src } });
|
|
193
|
+
return dest;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function cmdInstall(args, cwd) {
|
|
197
|
+
const source = args.source;
|
|
198
|
+
if (!source) throw new Error('install requires a source (git URL, tarball URL, local path, or registry name)');
|
|
199
|
+
const targetDir = pluginTargetDir({ global: args.global, cwd });
|
|
200
|
+
const classified = classifySource(source);
|
|
201
|
+
|
|
202
|
+
let dest;
|
|
203
|
+
if (classified.kind === 'git') {
|
|
204
|
+
dest = await installFromGit({ url: classified.url, targetDir, ref: args.ref, force: args.force });
|
|
205
|
+
} else if (classified.kind === 'tarball') {
|
|
206
|
+
dest = await installFromTarball({ url: classified.url, targetDir, force: args.force });
|
|
207
|
+
} else if (classified.kind === 'local') {
|
|
208
|
+
dest = await installFromLocal({ src: classified.path, targetDir, force: args.force });
|
|
209
|
+
} else if (classified.kind === 'name') {
|
|
210
|
+
const entry = await fetchRegistryEntry(classified.name);
|
|
211
|
+
if (!entry) throw new Error(`no registry entry for "${classified.name}". Provide a git URL or local path instead.`);
|
|
212
|
+
if (entry.repository) {
|
|
213
|
+
dest = await installFromGit({ url: entry.repository, targetDir, name: entry.name, ref: args.ref || entry.ref, subdir: entry.subdir || null, force: args.force });
|
|
214
|
+
} else if (entry.tarball) {
|
|
215
|
+
dest = await installFromTarball({ url: entry.tarball, targetDir, name: entry.name, force: args.force });
|
|
216
|
+
} else {
|
|
217
|
+
throw new Error(`registry entry "${classified.name}" has no repository or tarball URL`);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
throw new Error(`could not resolve source: ${source}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Hard preflight: schema, tool names, handlers import, view files exist,
|
|
224
|
+
// agent tool refs resolve, no shadow of built-ins, no collisions.
|
|
225
|
+
// Any error rolls back the install so we never leave a broken plugin on disk.
|
|
226
|
+
const preflight = await preflightPlugin(dest, {
|
|
227
|
+
existingPluginNames: () => existingInstalledNames(cwd),
|
|
228
|
+
});
|
|
229
|
+
if (!preflight.ok) {
|
|
230
|
+
rmrf(dest);
|
|
231
|
+
const detail = preflight.errors.map(e => ` · ${e}`).join('\n');
|
|
232
|
+
throw new Error(`preflight failed — rolled back ${dest}:\n${detail}`);
|
|
233
|
+
}
|
|
234
|
+
if (preflight.warnings.length) {
|
|
235
|
+
for (const w of preflight.warnings) process.stderr.write(`${YELLOW}!${RESET} ${w}\n`);
|
|
236
|
+
}
|
|
237
|
+
const m = preflight.manifest;
|
|
238
|
+
const stamp = readStamp(dest);
|
|
239
|
+
if (stamp) writeStamp(dest, stamp);
|
|
240
|
+
|
|
241
|
+
if (args.json) {
|
|
242
|
+
process.stdout.write(JSON.stringify({ ok: true, name: m.metadata.name, version: m.metadata.version, directory: dest }, null, 2) + '\n');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
process.stderr.write(`\n${GREEN}✓${RESET} Installed ${BOLD}${m.metadata.name}${RESET} v${m.metadata.version}\n`);
|
|
246
|
+
process.stderr.write(` ${DIM}location${RESET} ${dest}\n`);
|
|
247
|
+
process.stderr.write(` ${DIM}tools${RESET} ${(m.spec.tools || []).map(t => t.name).join(', ') || '(none)'}\n`);
|
|
248
|
+
process.stderr.write(` ${DIM}agents${RESET} ${(m.spec.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
|
|
249
|
+
const views = m.spec.workspace?.views || [];
|
|
250
|
+
process.stderr.write(` ${DIM}views${RESET} ${views.length ? views.map(v => v.name).join(', ') : '(none)'}\n`);
|
|
251
|
+
process.stderr.write(`\n ${DIM}Open with:${RESET} ${CYAN}bahulam plugin ${m.metadata.name}${RESET}\n\n`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── list ────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function cmdList(args, cwd) {
|
|
257
|
+
const plugins = scanInstalled(cwd);
|
|
258
|
+
if (args.json) {
|
|
259
|
+
process.stdout.write(JSON.stringify({ ok: true, plugins }, null, 2) + '\n');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!plugins.length) {
|
|
263
|
+
process.stderr.write(`${DIM}No plugins installed.${RESET}\n`);
|
|
264
|
+
process.stderr.write(`Install one: ${CYAN}bahulam plugin install <git-url|local-path|name>${RESET}\n`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const nameW = Math.max(8, ...plugins.map(p => p.name.length));
|
|
268
|
+
const verW = Math.max(7, ...plugins.map(p => (p.version || '—').length));
|
|
269
|
+
const scopeW = 7;
|
|
270
|
+
const header = `${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} ${'SCOPE'.padEnd(scopeW)} STATUS SURFACE${RESET}\n`;
|
|
271
|
+
process.stderr.write(header);
|
|
272
|
+
for (const p of plugins) {
|
|
273
|
+
const status = p.disabled ? `${YELLOW}disabled${RESET}` : `${GREEN}active${RESET} `;
|
|
274
|
+
const surface = `${p.tools}t ${p.agents}a ${p.views}v`;
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
`${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${p.scope.padEnd(scopeW)} ${status} ${DIM}${surface}${RESET}\n`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
process.stderr.write(`\n${DIM}${plugins.length} plugin${plugins.length === 1 ? '' : 's'} · surface: t=tools a=agents v=views${RESET}\n`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── remove ──────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
function cmdRemove(args, cwd) {
|
|
285
|
+
if (!args.pluginName) throw new Error('remove requires a plugin name');
|
|
286
|
+
const found = findByName(args.pluginName, cwd);
|
|
287
|
+
if (!found) throw new Error(`plugin not found: ${args.pluginName}`);
|
|
288
|
+
rmrf(found.directory);
|
|
289
|
+
if (args.json) {
|
|
290
|
+
process.stdout.write(JSON.stringify({ ok: true, removed: found.name, directory: found.directory }) + '\n');
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
process.stderr.write(`${GREEN}✓${RESET} Removed ${BOLD}${found.name}${RESET} (${found.directory})\n`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── enable / disable ────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
function toggle(args, cwd, enable) {
|
|
299
|
+
if (!args.pluginName) throw new Error(`${enable ? 'enable' : 'disable'} requires a plugin name`);
|
|
300
|
+
const found = findByName(args.pluginName, cwd);
|
|
301
|
+
if (!found) throw new Error(`plugin not found: ${args.pluginName}`);
|
|
302
|
+
if (enable && !found.disabled) {
|
|
303
|
+
process.stderr.write(`${DIM}${found.name} is already active.${RESET}\n`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!enable && found.disabled) {
|
|
307
|
+
process.stderr.write(`${DIM}${found.name} is already disabled.${RESET}\n`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const currentBase = path.basename(found.directory);
|
|
311
|
+
const newBase = enable ? currentBase.replace(/\.disabled$/, '') : `${currentBase}.disabled`;
|
|
312
|
+
const target = path.join(path.dirname(found.directory), newBase);
|
|
313
|
+
if (fs.existsSync(target)) throw new Error(`cannot rename: ${target} already exists`);
|
|
314
|
+
fs.renameSync(found.directory, target);
|
|
315
|
+
process.stderr.write(
|
|
316
|
+
`${GREEN}✓${RESET} ${enable ? 'Enabled' : 'Disabled'} ${BOLD}${found.name}${RESET}\n` +
|
|
317
|
+
` ${DIM}${found.directory}${RESET}\n ${DIM}→ ${target}${RESET}\n`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── info ────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
function cmdInfo(args, cwd) {
|
|
324
|
+
if (!args.pluginName) throw new Error('info requires a plugin name');
|
|
325
|
+
const found = findByName(args.pluginName, cwd);
|
|
326
|
+
if (!found) throw new Error(`plugin not found: ${args.pluginName}`);
|
|
327
|
+
const scan = found.disabled ? null : readManifest(found.directory);
|
|
328
|
+
const m = scan?.manifest;
|
|
329
|
+
if (args.json) {
|
|
330
|
+
process.stdout.write(JSON.stringify({ ok: true, plugin: found, manifest: m || null }, null, 2) + '\n');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
process.stderr.write(`\n${BOLD}${CYAN}${found.name}${RESET}${found.version ? ` v${found.version}` : ''}\n`);
|
|
334
|
+
process.stderr.write(`${DIM}${found.description || '(no description)'}${RESET}\n\n`);
|
|
335
|
+
process.stderr.write(` ${DIM}scope${RESET} ${found.scope}\n`);
|
|
336
|
+
process.stderr.write(` ${DIM}status${RESET} ${found.disabled ? `${YELLOW}disabled${RESET}` : `${GREEN}active${RESET}`}\n`);
|
|
337
|
+
process.stderr.write(` ${DIM}directory${RESET} ${found.directory}\n`);
|
|
338
|
+
if (found.origin) {
|
|
339
|
+
const o = found.origin;
|
|
340
|
+
process.stderr.write(` ${DIM}origin${RESET} ${o.kind}${o.url ? ` ${o.url}` : ''}${o.ref ? ` @ ${o.ref}` : ''}${o.path ? ` ${o.path}` : ''}\n`);
|
|
341
|
+
}
|
|
342
|
+
if (found.installed_at) process.stderr.write(` ${DIM}installed${RESET} ${found.installed_at}\n`);
|
|
343
|
+
if (m) {
|
|
344
|
+
process.stderr.write(`\n ${DIM}tools${RESET} ${(m.spec.tools || []).map(t => t.name).join(', ') || '(none)'}\n`);
|
|
345
|
+
process.stderr.write(` ${DIM}agents${RESET} ${(m.spec.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
|
|
346
|
+
const views = m.spec.workspace?.views || [];
|
|
347
|
+
process.stderr.write(` ${DIM}views${RESET} ${views.length ? views.map(v => v.name).join(', ') : '(none)'}\n`);
|
|
348
|
+
}
|
|
349
|
+
process.stderr.write('\n');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── update ──────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
async function cmdUpdate(args, cwd) {
|
|
355
|
+
if (!args.pluginName) throw new Error('update requires a plugin name');
|
|
356
|
+
const found = findByName(args.pluginName, cwd);
|
|
357
|
+
if (!found) throw new Error(`plugin not found: ${args.pluginName}`);
|
|
358
|
+
if (!found.origin || found.origin.kind !== 'git') {
|
|
359
|
+
throw new Error(`update supports git-installed plugins only (this one: ${found.origin?.kind || 'unknown'})`);
|
|
360
|
+
}
|
|
361
|
+
const ref = args.ref || found.origin.ref;
|
|
362
|
+
const subdir = found.origin.subdir;
|
|
363
|
+
process.stderr.write(`${DIM}Updating ${found.name} from ${found.origin.url}${ref ? ` @ ${ref}` : ''}${subdir ? ` (subdir ${subdir})` : ''}...${RESET}\n`);
|
|
364
|
+
if (subdir) {
|
|
365
|
+
// Monorepo: no .git in place, so re-fetch subdir contents into tmp
|
|
366
|
+
// and rsync them over the install dir (preserves .bahulam-plugin.json).
|
|
367
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bahulam-plugin-'));
|
|
368
|
+
await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), found.origin.url, tmp]);
|
|
369
|
+
const src = path.join(tmp, subdir);
|
|
370
|
+
if (!fs.existsSync(src)) { rmrf(tmp); throw new Error(`subdir "${subdir}" not found in ${found.origin.url}`); }
|
|
371
|
+
// Wipe non-stamp files, then copy new contents on top.
|
|
372
|
+
for (const entry of fs.readdirSync(found.directory)) {
|
|
373
|
+
if (entry === INSTALL_STAMP) continue;
|
|
374
|
+
rmrf(path.join(found.directory, entry));
|
|
375
|
+
}
|
|
376
|
+
fs.cpSync(src, found.directory, { recursive: true });
|
|
377
|
+
rmrf(tmp);
|
|
378
|
+
} else {
|
|
379
|
+
await run('git', ['-C', found.directory, 'fetch', '--depth', '1', 'origin', ref || 'HEAD']);
|
|
380
|
+
await run('git', ['-C', found.directory, 'reset', '--hard', 'FETCH_HEAD']);
|
|
381
|
+
}
|
|
382
|
+
writeStamp(found.directory, { origin: { ...found.origin, ref: ref || found.origin.ref } });
|
|
383
|
+
const scan = readManifest(found.directory);
|
|
384
|
+
process.stderr.write(`${GREEN}✓${RESET} Updated ${BOLD}${found.name}${RESET}${scan?.manifest?.metadata?.version ? ` to v${scan.manifest.metadata.version}` : ''}\n`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── dispatcher ──────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
async function cmdValidate(args, cwd) {
|
|
390
|
+
// Accept a directory path OR an installed plugin name.
|
|
391
|
+
const target = args.pluginName || args.source;
|
|
392
|
+
if (!target) throw new Error('validate requires a plugin directory path or installed plugin name');
|
|
393
|
+
let dir;
|
|
394
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
395
|
+
dir = path.resolve(target);
|
|
396
|
+
} else {
|
|
397
|
+
const found = findByName(target, cwd);
|
|
398
|
+
if (!found) throw new Error(`not found as directory or installed plugin: ${target}`);
|
|
399
|
+
dir = found.directory;
|
|
400
|
+
}
|
|
401
|
+
// Pre-parse just to get the manifest name for the collision filter, so
|
|
402
|
+
// validating an already-installed plugin doesn't flag itself as a dupe.
|
|
403
|
+
const prelim = readManifest(dir);
|
|
404
|
+
const selfName = (prelim?.manifest?.metadata?.name || '').toLowerCase();
|
|
405
|
+
const result = await preflightPlugin(dir, {
|
|
406
|
+
existingPluginNames: () => existingInstalledNames(cwd).filter(n => n.toLowerCase() !== selfName),
|
|
407
|
+
});
|
|
408
|
+
if (args.json) {
|
|
409
|
+
process.stdout.write(JSON.stringify({ ok: result.ok, errors: result.errors, warnings: result.warnings, directory: dir }, null, 2) + '\n');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const name = result.manifest?.metadata?.name || path.basename(dir);
|
|
413
|
+
process.stderr.write(`\n${BOLD}${CYAN}${name}${RESET} ${DIM}${dir}${RESET}\n\n`);
|
|
414
|
+
if (result.errors.length) {
|
|
415
|
+
for (const e of result.errors) process.stderr.write(` ${RED}✗${RESET} ${e}\n`);
|
|
416
|
+
}
|
|
417
|
+
if (result.warnings.length) {
|
|
418
|
+
for (const w of result.warnings) process.stderr.write(` ${YELLOW}!${RESET} ${w}\n`);
|
|
419
|
+
}
|
|
420
|
+
if (result.ok && !result.warnings.length) {
|
|
421
|
+
process.stderr.write(` ${GREEN}✓${RESET} no issues found\n`);
|
|
422
|
+
} else if (result.ok) {
|
|
423
|
+
process.stderr.write(`\n ${GREEN}✓${RESET} ${result.warnings.length} warning(s), 0 errors — safe to install\n`);
|
|
424
|
+
} else {
|
|
425
|
+
process.stderr.write(`\n ${RED}✗${RESET} ${result.errors.length} error(s) — install would be rejected\n`);
|
|
426
|
+
}
|
|
427
|
+
process.stderr.write('\n');
|
|
428
|
+
if (!result.ok) process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function handlePluginManagementCommand(args, { cwd = process.cwd(), throwOnError = false } = {}) {
|
|
432
|
+
try {
|
|
433
|
+
switch (args.action) {
|
|
434
|
+
case 'install': await cmdInstall(args, cwd); return;
|
|
435
|
+
case 'validate': case 'check': case 'lint': await cmdValidate(args, cwd); return;
|
|
436
|
+
case 'list': case 'ls': cmdList(args, cwd); return;
|
|
437
|
+
case 'remove': case 'rm': case 'uninstall': cmdRemove(args, cwd); return;
|
|
438
|
+
case 'enable': toggle(args, cwd, true); return;
|
|
439
|
+
case 'disable': toggle(args, cwd, false); return;
|
|
440
|
+
case 'info': cmdInfo(args, cwd); return;
|
|
441
|
+
case 'update': case 'upgrade': await cmdUpdate(args, cwd); return;
|
|
442
|
+
default: throw new Error(`unknown plugin action: ${args.action}`);
|
|
443
|
+
}
|
|
444
|
+
} catch (err) {
|
|
445
|
+
process.stderr.write(`${RED}✗${RESET} ${err.message}\n`);
|
|
446
|
+
if (throwOnError) throw err;
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
}
|