@bahulam/code 0.1.11 → 0.1.13
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 +1 -1
- package/src/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/install.mjs +295 -0
- package/src/commands/plugin-manage.mjs +280 -88
- package/src/config/cli-args.mjs +16 -0
- package/src/config/settings-loader.mjs +15 -0
- package/src/core/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +54 -3
- package/src/core/local-agent.mjs +10 -1
- package/src/core/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +95 -15
- package/src/core/tool-executor.mjs +266 -15
- package/src/local-service/agent-relay.mjs +1 -1
- package/src/local-service/server.mjs +116 -14
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- package/src/plugins/executor.mjs +2 -2
- package/src/plugins/manifest.mjs +30 -27
- package/src/plugins/pi-compat/loader-hook.mjs +45 -0
- package/src/plugins/pi-compat/probe.mjs +294 -0
- package/src/plugins/pi-compat/scaffold.mjs +487 -0
- package/src/plugins/pi-compat/shim.mjs +134 -0
- package/src/plugins/pi-compose.mjs +147 -0
- package/src/plugins/preflight.mjs +35 -10
- package/src/plugins/registry.mjs +6 -0
- package/src/terminal/agents.mjs +8 -3
- package/src/terminal/main.mjs +39 -7
- package/src/terminal/paste-input.mjs +23 -0
- package/src/terminal/repl-render.mjs +65 -10
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +624 -103
- package/src/tools/agent.mjs +6 -2
- package/src/tools/registry.mjs +107 -4
- package/src/ui/input-dock.mjs +5 -2
- package/src/ui/slash-commands.mjs +1 -1
- package/src/ui/sub-agent.mjs +14 -8
package/package.json
CHANGED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bahulam Code Authentication — GitHub OAuth + config management.
|
|
3
|
+
* Reads/writes ~/.bahulam/config.json (fallback: ~/.kepler/config.json for
|
|
4
|
+
* legacy installs — see src/core/paths.mjs for the resolver).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import * as http from 'node:http';
|
|
10
|
+
import { getLoginSuccessHTML } from '../ui/banner.mjs';
|
|
11
|
+
import { resolveBackendUrl } from '../core/backend-url.mjs';
|
|
12
|
+
import { bahulamHome } from '../core/paths.mjs';
|
|
13
|
+
|
|
14
|
+
// Note: computed via a function (not a constant) so that BAHULAM_HOME /
|
|
15
|
+
// KEPLER_HOME env-var swaps mid-process still work.
|
|
16
|
+
function configDir() { return bahulamHome(); }
|
|
17
|
+
function configPath() { return path.join(configDir(), 'config.json'); }
|
|
18
|
+
|
|
19
|
+
// Legacy exports kept for backwards compat with any tests/scripts that
|
|
20
|
+
// still import CONFIG_DIR / CONFIG_PATH by name.
|
|
21
|
+
const CONFIG_DIR = configDir();
|
|
22
|
+
const CONFIG_PATH = configPath();
|
|
23
|
+
|
|
24
|
+
let _tokenEnvNoticeShown = false;
|
|
25
|
+
function readTokenFromEnv() {
|
|
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
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class TarangAuth {
|
|
42
|
+
constructor() {
|
|
43
|
+
this._config = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Ensure ~/.bahulam/ (or legacy ~/.kepler/) exists with secure permissions. */
|
|
47
|
+
_ensureConfigDir() {
|
|
48
|
+
const dir = configDir();
|
|
49
|
+
if (!fs.existsSync(dir)) {
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Load credentials and settings from config.json. */
|
|
55
|
+
loadCredentials() {
|
|
56
|
+
const cfgPath = configPath();
|
|
57
|
+
try {
|
|
58
|
+
if (fs.existsSync(cfgPath)) {
|
|
59
|
+
const raw = fs.readFileSync(cfgPath, 'utf-8');
|
|
60
|
+
this._config = JSON.parse(raw);
|
|
61
|
+
} else {
|
|
62
|
+
this._config = {};
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
this._config = {};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
token: readTokenFromEnv() || this._config.token || null,
|
|
69
|
+
openRouterKey: this._config.openrouter_key || process.env.OPENROUTER_API_KEY || null,
|
|
70
|
+
anthropicKey: this._config.anthropic_api_key || process.env.ANTHROPIC_API_KEY || null,
|
|
71
|
+
openaiKey: this._config.openai_api_key || process.env.OPENAI_API_KEY || null,
|
|
72
|
+
googleKey: this._config.google_api_key || process.env.GOOGLE_API_KEY || null,
|
|
73
|
+
backendUrl: resolveBackendUrl(),
|
|
74
|
+
mode: this._config.mode || 'auto',
|
|
75
|
+
gatewayType: this._config.gateway_type || 'openrouter',
|
|
76
|
+
models: this._config.models || {},
|
|
77
|
+
configuredProviders: this._config.configured_providers || [],
|
|
78
|
+
gatewayConfig: this._config.gateway_config || {},
|
|
79
|
+
// PRD-076 W7: persisted /model picks. `modelConfig` mirrors the
|
|
80
|
+
// Python-side key read_local_model_config() looks for; keep the
|
|
81
|
+
// snake_case JSON key so the runtime picks it up unchanged.
|
|
82
|
+
modelConfig: this._config.model_config || {},
|
|
83
|
+
modelMode: this._config.model_mode || null,
|
|
84
|
+
routePreference: this._config.route_preference || null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Get the raw config object. */
|
|
89
|
+
getRawConfig() {
|
|
90
|
+
if (!this._config) this.loadCredentials();
|
|
91
|
+
return this._config || {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Clear credentials — remove token and keys from config. */
|
|
95
|
+
logout() {
|
|
96
|
+
try {
|
|
97
|
+
const cfgPath = configPath();
|
|
98
|
+
if (fs.existsSync(cfgPath)) {
|
|
99
|
+
fs.unlinkSync(cfgPath);
|
|
100
|
+
}
|
|
101
|
+
this._config = null;
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Save credentials atomically (temp-file + rename). */
|
|
109
|
+
saveCredentials(updates) {
|
|
110
|
+
this._ensureConfigDir();
|
|
111
|
+
const current = this._config || {};
|
|
112
|
+
const merged = { ...current, ...updates };
|
|
113
|
+
const cfgPath = configPath();
|
|
114
|
+
const tmpPath = `${cfgPath}.tmp.${process.pid}`;
|
|
115
|
+
fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
116
|
+
fs.renameSync(tmpPath, cfgPath);
|
|
117
|
+
this._config = merged;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Check if user has a valid auth token. */
|
|
121
|
+
isAuthenticated() {
|
|
122
|
+
const creds = this.loadCredentials();
|
|
123
|
+
return !!creds.token;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Check if OpenRouter key is configured. */
|
|
127
|
+
hasOpenRouterKey() {
|
|
128
|
+
const creds = this.loadCredentials();
|
|
129
|
+
return !!creds.openRouterKey;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Save an API key by provider name. */
|
|
133
|
+
saveProviderKey(provider, key) {
|
|
134
|
+
const keyMap = {
|
|
135
|
+
openrouter: 'openrouter_key',
|
|
136
|
+
anthropic: 'anthropic_api_key',
|
|
137
|
+
openai: 'openai_api_key',
|
|
138
|
+
googleai: 'google_api_key',
|
|
139
|
+
azureopenai: 'azure_api_key',
|
|
140
|
+
bedrock: 'aws_access_key',
|
|
141
|
+
databricks: 'databricks_token',
|
|
142
|
+
};
|
|
143
|
+
const field = keyMap[provider];
|
|
144
|
+
if (!field) throw new Error(`Unknown provider: ${provider}`);
|
|
145
|
+
this.saveCredentials({ [field]: key });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Save OpenRouter API key. */
|
|
149
|
+
saveOpenRouterKey(key) {
|
|
150
|
+
this.saveProviderKey('openrouter', key);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Save Anthropic API key. */
|
|
154
|
+
saveAnthropicKey(key) {
|
|
155
|
+
this.saveProviderKey('anthropic', key);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Save OpenAI API key. */
|
|
159
|
+
saveOpenAIKey(key) {
|
|
160
|
+
this.saveProviderKey('openai', key);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Save Google AI API key. */
|
|
164
|
+
saveGoogleKey(key) {
|
|
165
|
+
this.saveProviderKey('googleai', key);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Sync settings from web backend and save locally. */
|
|
169
|
+
async syncSettings() {
|
|
170
|
+
const { fetchRemoteSettings, mergeRemoteSettings } = await import('../core/settings-sync.mjs');
|
|
171
|
+
const creds = this.loadCredentials();
|
|
172
|
+
if (!creds.token) throw new Error('Not logged in. Run `bahulam-code login` first.');
|
|
173
|
+
|
|
174
|
+
const remote = await fetchRemoteSettings(creds.token);
|
|
175
|
+
if (!remote) throw new Error('Failed to fetch settings from server.');
|
|
176
|
+
|
|
177
|
+
const merged = mergeRemoteSettings(this.getRawConfig(), remote);
|
|
178
|
+
this.saveCredentials(merged);
|
|
179
|
+
return remote;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Set default mode. */
|
|
183
|
+
setMode(mode) {
|
|
184
|
+
const valid = ['local', 'remote', 'auto'];
|
|
185
|
+
if (!valid.includes(mode)) {
|
|
186
|
+
throw new Error(`Invalid mode: ${mode}. Must be one of: ${valid.join(', ')}`);
|
|
187
|
+
}
|
|
188
|
+
this.saveCredentials({ mode });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Display config (styled). */
|
|
192
|
+
printConfig() {
|
|
193
|
+
const creds = this.loadCredentials();
|
|
194
|
+
const GREEN = '\x1b[32m', RED = '\x1b[31m', DIM = '\x1b[2m', BOLD = '\x1b[1m', CYAN = '\x1b[36m', RESET = '\x1b[0m';
|
|
195
|
+
const check = `${GREEN}\u2713${RESET}`;
|
|
196
|
+
const cross = `${RED}\u2717${RESET}`;
|
|
197
|
+
|
|
198
|
+
const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
|
|
199
|
+
|
|
200
|
+
process.stderr.write(`\n${BOLD}Bahulam Code Configuration${RESET}\n`);
|
|
201
|
+
process.stderr.write(`${'─'.repeat(50)}\n`);
|
|
202
|
+
process.stderr.write(` Auth: ${creds.token ? `${check} logged in` : `${cross} not logged in ${DIM}(/login)${RESET}`}\n`);
|
|
203
|
+
process.stderr.write(` Environment: ${DIM}${env}${RESET}\n`);
|
|
204
|
+
process.stderr.write(` Backend: ${DIM}${creds.backendUrl}${RESET}\n`);
|
|
205
|
+
process.stderr.write(` Mode: ${DIM}${creds.mode || 'auto'}${RESET}\n`);
|
|
206
|
+
process.stderr.write(` Gateway: ${DIM}${creds.gatewayType}${RESET}\n`);
|
|
207
|
+
|
|
208
|
+
const models = creds.models || {};
|
|
209
|
+
const planningModel = models.planning || models.orchestrator;
|
|
210
|
+
if (planningModel || models.reasoning || models.local) {
|
|
211
|
+
process.stderr.write(`\n${BOLD} Models${RESET}\n`);
|
|
212
|
+
if (planningModel) process.stderr.write(` Planning: ${DIM}${planningModel}${RESET}\n`);
|
|
213
|
+
if (models.reasoning) process.stderr.write(` Coding: ${DIM}${models.reasoning}${RESET}\n`);
|
|
214
|
+
if (models.local) process.stderr.write(` Local: ${DIM}${models.local}${RESET}\n`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const providers = creds.configuredProviders || [];
|
|
218
|
+
if (providers.length > 0) {
|
|
219
|
+
process.stderr.write(` Providers: ${DIM}${providers.join(', ')}${RESET}\n`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const raw = this.getRawConfig();
|
|
223
|
+
if (raw.last_synced_at) {
|
|
224
|
+
process.stderr.write(` Last synced: ${DIM}${new Date(raw.last_synced_at).toLocaleString()}${RESET}\n`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
process.stderr.write(`\n ${DIM}Run ${RESET}${CYAN}bahulam-code sync${RESET}${DIM} to sync settings from web.${RESET}\n`);
|
|
228
|
+
process.stderr.write(` ${DIM}Run ${RESET}${CYAN}bahulam-code configure${RESET}${DIM} to open settings in browser.${RESET}\n`);
|
|
229
|
+
process.stderr.write('\n');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Run login flow via web app.
|
|
234
|
+
*
|
|
235
|
+
* Flow:
|
|
236
|
+
* 1. CLI starts local HTTP server on random port
|
|
237
|
+
* 2. Opens browser to web /auth/cli?callback=http://127.0.0.1:{port}/callback
|
|
238
|
+
* 3. Web checks Supabase session (if none → GitHub OAuth → Supabase)
|
|
239
|
+
* 4. Web generates CLI token via /api/cli/token
|
|
240
|
+
* 5. Web redirects browser to CLI callback with token
|
|
241
|
+
* 6. CLI receives token, saves to ~/.bahulam/config.json
|
|
242
|
+
*/
|
|
243
|
+
async login() {
|
|
244
|
+
const { resolveWebUrl } = await import('../core/backend-url.mjs');
|
|
245
|
+
const webUrl = resolveWebUrl();
|
|
246
|
+
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
const server = http.createServer(async (req, res) => {
|
|
249
|
+
const url = new URL(req.url, `http://localhost`);
|
|
250
|
+
|
|
251
|
+
// The web app redirects here with ?token=<cli_token>
|
|
252
|
+
const token = url.searchParams.get('token');
|
|
253
|
+
|
|
254
|
+
if (!token) {
|
|
255
|
+
// Maybe an error or missing token
|
|
256
|
+
const error = url.searchParams.get('error');
|
|
257
|
+
if (error) {
|
|
258
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
259
|
+
res.end(`<html><body><h2>Login failed</h2><p>${error}</p></body></html>`);
|
|
260
|
+
server.close();
|
|
261
|
+
reject(new Error(error));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Ignore other requests (favicon, etc.)
|
|
265
|
+
res.writeHead(200);
|
|
266
|
+
res.end('');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Save the CLI token
|
|
271
|
+
this.saveCredentials({ token });
|
|
272
|
+
|
|
273
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
274
|
+
res.end(getLoginSuccessHTML());
|
|
275
|
+
|
|
276
|
+
server.close();
|
|
277
|
+
resolve(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
server.listen(0, '127.0.0.1', () => {
|
|
281
|
+
const port = server.address().port;
|
|
282
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
283
|
+
const authUrl = `${webUrl}/auth/cli?callback=${encodeURIComponent(callbackUrl)}`;
|
|
284
|
+
|
|
285
|
+
process.stderr.write(`\n\x1b[36mOpening browser for login...\x1b[0m\n`);
|
|
286
|
+
process.stderr.write(`\x1b[2mIf browser doesn't open, visit:\x1b[0m\n \x1b[4m${authUrl}\x1b[0m\n\n`);
|
|
287
|
+
|
|
288
|
+
// Open browser
|
|
289
|
+
const openCmd = process.platform === 'darwin' ? 'open' :
|
|
290
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
291
|
+
import('node:child_process').then(({ exec }) => {
|
|
292
|
+
exec(`${openCmd} "${authUrl}"`, () => {});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Timeout after 120s
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
server.close();
|
|
299
|
+
reject(new Error('Login timed out after 120s'));
|
|
300
|
+
}, 120_000);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Ensure user is authenticated, prompt login if not.
|
|
306
|
+
*/
|
|
307
|
+
async ensureAuth() {
|
|
308
|
+
if (!this.isAuthenticated()) {
|
|
309
|
+
process.stderr.write('\x1b[33mNot logged in.\x1b[0m Starting login flow...\n');
|
|
310
|
+
await this.login();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
package/src/commands/agent.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent CLI Commands — list, get, sync user-defined agents.
|
|
2
|
+
* Agent CLI Commands — list, get, sync backend-published user-defined agents.
|
|
3
3
|
*
|
|
4
4
|
* Commands:
|
|
5
5
|
* bahulam-code agent list
|
|
@@ -47,9 +47,9 @@ export async function handleAgentCommand(args) {
|
|
|
47
47
|
|
|
48
48
|
function printAgentUsage() {
|
|
49
49
|
process.stderr.write(`${BOLD}AGENT COMMANDS${RESET}\n`);
|
|
50
|
-
process.stderr.write(` ${CYAN}bahulam-code agent list${RESET} List
|
|
51
|
-
process.stderr.write(` ${CYAN}bahulam-code agent get <slug>${RESET} Show agent details\n`);
|
|
52
|
-
process.stderr.write(` ${CYAN}bahulam-code agent sync [--dir <path>]${RESET}
|
|
50
|
+
process.stderr.write(` ${CYAN}bahulam-code agent list${RESET} List backend-published agents\n`);
|
|
51
|
+
process.stderr.write(` ${CYAN}bahulam-code agent get <slug>${RESET} Show backend-published agent details\n`);
|
|
52
|
+
process.stderr.write(` ${CYAN}bahulam-code agent sync [--dir <path>]${RESET} Publish local agent YAML files for account/cloud reuse\n`);
|
|
53
53
|
process.stderr.write('\n');
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -110,11 +110,11 @@ async function handleList(args) {
|
|
|
110
110
|
|
|
111
111
|
const agents = result.agents || [];
|
|
112
112
|
if (agents.length === 0) {
|
|
113
|
-
process.stderr.write(`${DIM}No
|
|
113
|
+
process.stderr.write(`${DIM}No backend-published agents found. Local .bahulam/agents files can still be delegated in their workspace.${RESET}\n`);
|
|
114
114
|
process.exit(0);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
process.stderr.write(`${BOLD}
|
|
117
|
+
process.stderr.write(`${BOLD}Backend-published Agents:${RESET}\n`);
|
|
118
118
|
for (const a of agents) {
|
|
119
119
|
const source = a.source === 'platform' ? `${DIM}(platform)${RESET}` : `${DIM}(user)${RESET}`;
|
|
120
120
|
process.stderr.write(` ${CYAN}${a.slug}${RESET} ${a.name || a.slug} ${source}\n`);
|
|
@@ -184,7 +184,7 @@ async function handleSync(args) {
|
|
|
184
184
|
agents: selected,
|
|
185
185
|
});
|
|
186
186
|
const synced = result.synced ?? selected.length;
|
|
187
|
-
process.stderr.write(`${GREEN}✓ Synced ${synced} agent${synced === 1 ? '' : 's'} to
|
|
187
|
+
process.stderr.write(`${GREEN}✓ Synced ${synced} agent${synced === 1 ? '' : 's'} to the backend for account/cloud reuse.${RESET}\n`);
|
|
188
188
|
process.stdout.write(JSON.stringify({ synced, agents: result.agents || [] }, null, 2) + '\n');
|
|
189
189
|
} catch (err) {
|
|
190
190
|
process.stderr.write(`${RED}✗ Agent sync failed: ${err.message}${RESET}\n`);
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level `bahulam pull` and `bahulam install` commands.
|
|
3
|
+
*
|
|
4
|
+
* bahulam pull <src> — install an ingredient (currently pi:<name>).
|
|
5
|
+
* No pack scaffolding; the raw package lands in
|
|
6
|
+
* ~/.bahulam/plugins-pi/ and is only useful when
|
|
7
|
+
* referenced by a pack's spec.composes:.
|
|
8
|
+
*
|
|
9
|
+
* bahulam install <src> — install a full pack.
|
|
10
|
+
* For pi:<name>: pulls the ingredient AND
|
|
11
|
+
* scaffolds a Bahulam pack around it (composes +
|
|
12
|
+
* native state layer + agent + workspace panel),
|
|
13
|
+
* then installs the pack via preflight.
|
|
14
|
+
* For git URL / tarball URL / local path: installs
|
|
15
|
+
* as a hand-authored pack (delegates to the
|
|
16
|
+
* existing plugin-manage install path).
|
|
17
|
+
*
|
|
18
|
+
* The split lets users pull pi ingredients they only need as compose targets
|
|
19
|
+
* without generating a full pack, while giving one-command installs for
|
|
20
|
+
* users who want an agent-ready surface from a pi package.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import {
|
|
26
|
+
classifySource,
|
|
27
|
+
installFromGit,
|
|
28
|
+
installFromTarball,
|
|
29
|
+
installFromLocal,
|
|
30
|
+
installFromPi,
|
|
31
|
+
pluginTargetDir,
|
|
32
|
+
resolveComposeDependencies,
|
|
33
|
+
} from './plugin-manage.mjs';
|
|
34
|
+
import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
|
|
35
|
+
|
|
36
|
+
const RESET = '\x1b[0m';
|
|
37
|
+
const BOLD = '\x1b[1m';
|
|
38
|
+
const DIM = '\x1b[2m';
|
|
39
|
+
const CYAN = '\x1b[36m';
|
|
40
|
+
const GREEN = '\x1b[32m';
|
|
41
|
+
const YELLOW = '\x1b[33m';
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const parsed = {
|
|
45
|
+
source: null,
|
|
46
|
+
force: false,
|
|
47
|
+
global: true,
|
|
48
|
+
ref: null,
|
|
49
|
+
json: false,
|
|
50
|
+
help: false,
|
|
51
|
+
slug: null,
|
|
52
|
+
state: true,
|
|
53
|
+
workspace: true,
|
|
54
|
+
};
|
|
55
|
+
const positional = [];
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const arg = argv[i];
|
|
58
|
+
switch (arg) {
|
|
59
|
+
case '--help': case '-h': parsed.help = true; break;
|
|
60
|
+
case '--force': case '-f': parsed.force = true; break;
|
|
61
|
+
case '--project': parsed.global = false; break;
|
|
62
|
+
case '--global': parsed.global = true; break;
|
|
63
|
+
case '--ref': case '--tag': case '--branch': parsed.ref = argv[++i]; break;
|
|
64
|
+
case '--json': parsed.json = true; break;
|
|
65
|
+
case '--slug': case '--name': parsed.slug = argv[++i]; break;
|
|
66
|
+
case '--no-state': parsed.state = false; break;
|
|
67
|
+
case '--no-workspace': parsed.workspace = false; break;
|
|
68
|
+
default:
|
|
69
|
+
if (!arg.startsWith('-')) positional.push(arg);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
parsed.source = positional.shift() || null;
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `bahulam pull <src>` — ingredient only. Currently pi: sources only.
|
|
79
|
+
*/
|
|
80
|
+
export async function handlePullCommand(argv, { cwd = process.cwd() } = {}) {
|
|
81
|
+
const args = parseArgs(argv);
|
|
82
|
+
if (args.help || !args.source) {
|
|
83
|
+
process.stderr.write(`
|
|
84
|
+
${BOLD}bahulam pull <source>${RESET}
|
|
85
|
+
|
|
86
|
+
Pull an ingredient (pi package) into ~/.bahulam/plugins-pi/. The
|
|
87
|
+
ingredient is composable but not directly runnable — reference it from
|
|
88
|
+
a pack's ${CYAN}spec.composes:${RESET} block, or use ${CYAN}bahulam install pi:<name>${RESET}
|
|
89
|
+
to auto-scaffold a full pack around it.
|
|
90
|
+
|
|
91
|
+
Sources:
|
|
92
|
+
pi:<npm-package>[@<version>] Pull a pi package (e.g. pi:pi-web-access@^0.27.0)
|
|
93
|
+
|
|
94
|
+
Flags:
|
|
95
|
+
--force, -f Overwrite an existing ingredient at the same path
|
|
96
|
+
--json Machine-readable output
|
|
97
|
+
|
|
98
|
+
`);
|
|
99
|
+
if (!args.source) process.exit(1);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const classified = classifySource(args.source);
|
|
104
|
+
if (classified.kind !== 'pi') {
|
|
105
|
+
if (classified.kind === 'invalid') {
|
|
106
|
+
throw new Error(`invalid pi source: ${args.source}`);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(
|
|
109
|
+
`pull only accepts pi: sources. For ${classified.kind} sources use \`bahulam install ${args.source}\`.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const dest = await installFromPi({
|
|
115
|
+
packageName: classified.package_name,
|
|
116
|
+
versionRange: classified.version_range,
|
|
117
|
+
force: args.force,
|
|
118
|
+
});
|
|
119
|
+
if (args.json) {
|
|
120
|
+
process.stdout.write(JSON.stringify({
|
|
121
|
+
ok: true,
|
|
122
|
+
kind: 'pi',
|
|
123
|
+
package_name: classified.package_name,
|
|
124
|
+
version_range: classified.version_range,
|
|
125
|
+
directory: dest,
|
|
126
|
+
}, null, 2) + '\n');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
process.stderr.write(`\n${GREEN}✓${RESET} Pulled pi package ${BOLD}${classified.package_name}${RESET}\n`);
|
|
130
|
+
process.stderr.write(` ${DIM}location${RESET} ${dest}\n`);
|
|
131
|
+
process.stderr.write(` ${YELLOW}!${RESET} Pi packages run with your full system permissions. Bahulam does not audit pi packages.\n`);
|
|
132
|
+
process.stderr.write(` ${DIM}Wrap in a pack:${RESET} ${CYAN}bahulam install pi:${classified.package_name}${RESET}\n`);
|
|
133
|
+
process.stderr.write(` ${DIM}Compose in yours:${RESET} ${CYAN}spec.composes: [{source: pi:${classified.package_name}, expose: [...]}]${RESET}\n\n`);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
process.stderr.write(`\x1b[31m✗\x1b[0m ${err.message}\n`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `bahulam install <src>` — full pack install.
|
|
142
|
+
* pi:<name> → pull ingredient + scaffold pack + preflight-install
|
|
143
|
+
* git URL → clone + preflight-install
|
|
144
|
+
* tarball → download + preflight-install
|
|
145
|
+
* local path → copy + preflight-install
|
|
146
|
+
*/
|
|
147
|
+
export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
|
|
148
|
+
const args = parseArgs(argv);
|
|
149
|
+
if (args.help || !args.source) {
|
|
150
|
+
process.stderr.write(`
|
|
151
|
+
${BOLD}bahulam install <source>${RESET}
|
|
152
|
+
|
|
153
|
+
Install a pack. For pi sources, pulls the ingredient and scaffolds a
|
|
154
|
+
full Bahulam pack (composition + state layer + workspace + agent), then
|
|
155
|
+
installs it. For git/tarball/local sources, installs the existing pack.
|
|
156
|
+
|
|
157
|
+
Sources:
|
|
158
|
+
pi:<npm-package>[@<version>] Scaffold + install from a pi package
|
|
159
|
+
<git-url>[.git] Clone a hand-authored pack
|
|
160
|
+
<tarball-url> Download + install a pack tarball
|
|
161
|
+
<local-path> Copy + install a local pack directory
|
|
162
|
+
|
|
163
|
+
Flags:
|
|
164
|
+
--force, -f Overwrite an existing pack at the same target
|
|
165
|
+
--slug <name> Override the scaffolded pack slug (pi sources only)
|
|
166
|
+
--no-state Skip the persistent state layer (pi sources only)
|
|
167
|
+
--no-workspace Skip the reactive workspace panel (pi sources only)
|
|
168
|
+
--project Install into ./.bahulam/plugins/ instead of ~/.bahulam/plugins/
|
|
169
|
+
--ref <ref> Git branch/tag/commit (git sources only)
|
|
170
|
+
--json Machine-readable output
|
|
171
|
+
|
|
172
|
+
`);
|
|
173
|
+
if (!args.source) process.exit(1);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const classified = classifySource(args.source);
|
|
178
|
+
const targetDir = pluginTargetDir({ global: args.global, cwd });
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (classified.kind === 'pi') {
|
|
182
|
+
await installPiWithScaffolding({ classified, targetDir, cwd, args });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (classified.kind === 'invalid') {
|
|
186
|
+
throw new Error(`unrecognized source: ${args.source}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Non-pi paths reuse the plugin-manage install machinery.
|
|
190
|
+
let dest;
|
|
191
|
+
if (classified.kind === 'git') {
|
|
192
|
+
dest = await installFromGit({ url: classified.url, targetDir, ref: args.ref, force: args.force });
|
|
193
|
+
} else if (classified.kind === 'tarball') {
|
|
194
|
+
dest = await installFromTarball({ url: classified.url, targetDir, force: args.force });
|
|
195
|
+
} else if (classified.kind === 'local') {
|
|
196
|
+
dest = await installFromLocal({ src: classified.path, targetDir, force: args.force });
|
|
197
|
+
} else if (classified.kind === 'name') {
|
|
198
|
+
throw new Error(`registry lookup for bare names is not yet wired into \`bahulam install\`. Provide a git URL, tarball URL, local path, or pi: source.`);
|
|
199
|
+
} else {
|
|
200
|
+
throw new Error(`could not resolve source: ${args.source}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await preflightAndReport({ dest, args, cwd });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
process.stderr.write(`\x1b[31m✗\x1b[0m ${err.message}\n`);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function installPiWithScaffolding({ classified, targetDir, cwd, args }) {
|
|
211
|
+
const { bahulamHome } = await import('../core/paths.mjs');
|
|
212
|
+
const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
|
|
213
|
+
const { scaffoldPiPack } = await import('../plugins/pi-compat/scaffold.mjs');
|
|
214
|
+
|
|
215
|
+
const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
|
|
216
|
+
const safeName = classified.package_name.replace(/[/@]/g, '_');
|
|
217
|
+
const piDir = path.join(piBaseDir, safeName);
|
|
218
|
+
|
|
219
|
+
// Step 1: pull the ingredient if we haven't already.
|
|
220
|
+
if (!fs.existsSync(piDir)) {
|
|
221
|
+
process.stderr.write(`${DIM}pulling ${classified.package_name}…${RESET}\n`);
|
|
222
|
+
await installFromPi({
|
|
223
|
+
packageName: classified.package_name,
|
|
224
|
+
versionRange: classified.version_range,
|
|
225
|
+
force: false,
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
process.stderr.write(`${DIM}reusing existing ingredient at ${piDir}${RESET}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Step 2: ensure the tools cache is present.
|
|
232
|
+
const discovered = await discoverPiTools(piDir, { pluginName: classified.package_name });
|
|
233
|
+
|
|
234
|
+
// Step 3: generate the pack directory (composes + state + agent + panel).
|
|
235
|
+
process.stderr.write(`${DIM}scaffolding pack…${RESET}\n`);
|
|
236
|
+
const { dest, slug, namespace, exposeTools, agentSlug } = scaffoldPiPack({
|
|
237
|
+
packageName: classified.package_name,
|
|
238
|
+
versionRange: classified.version_range,
|
|
239
|
+
piDir,
|
|
240
|
+
targetDir,
|
|
241
|
+
discoveredTools: discovered,
|
|
242
|
+
state: args.state,
|
|
243
|
+
workspace: args.workspace,
|
|
244
|
+
slug: args.slug,
|
|
245
|
+
force: args.force,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Step 4: preflight the generated pack. Same rules as any other pack.
|
|
249
|
+
await preflightAndReport({ dest, args, cwd, meta: { slug, namespace, exposeTools, agentSlug, packageName: classified.package_name } });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function preflightAndReport({ dest, args, cwd, meta = null }) {
|
|
253
|
+
const preflight = await preflightPlugin(dest, {
|
|
254
|
+
existingPluginNames: () => existingInstalledNames(cwd),
|
|
255
|
+
});
|
|
256
|
+
if (!preflight.ok) {
|
|
257
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
258
|
+
const detail = preflight.errors.map(e => ` · ${e}`).join('\n');
|
|
259
|
+
throw new Error(`preflight failed — rolled back ${dest}:\n${detail}`);
|
|
260
|
+
}
|
|
261
|
+
if (preflight.warnings.length) {
|
|
262
|
+
for (const w of preflight.warnings) process.stderr.write(`${YELLOW}!${RESET} ${w}\n`);
|
|
263
|
+
}
|
|
264
|
+
const m = preflight.manifest;
|
|
265
|
+
|
|
266
|
+
// A hand-authored pack may compose pi packages we haven't pulled yet.
|
|
267
|
+
// Do it in one command; the scaffolder path already has the pi ingredient.
|
|
268
|
+
await resolveComposeDependencies(m, { targetDir: path.dirname(dest) });
|
|
269
|
+
|
|
270
|
+
if (args.json) {
|
|
271
|
+
process.stdout.write(JSON.stringify({
|
|
272
|
+
ok: true,
|
|
273
|
+
name: m.metadata.name,
|
|
274
|
+
version: m.metadata.version,
|
|
275
|
+
directory: dest,
|
|
276
|
+
scaffolded: Boolean(meta),
|
|
277
|
+
...(meta ? { pi_package: meta.packageName, namespace: meta.namespace, composed_tools: meta.exposeTools, agent: meta.agentSlug } : {}),
|
|
278
|
+
}, null, 2) + '\n');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
process.stderr.write(`\n${GREEN}✓${RESET} Installed ${BOLD}${m.metadata.name}${RESET} v${m.metadata.version}\n`);
|
|
283
|
+
process.stderr.write(` ${DIM}location${RESET} ${dest}\n`);
|
|
284
|
+
const nativeTools = (m.spec.tools || []).map(t => t.name);
|
|
285
|
+
const composedCount = (m.spec.composes || []).reduce((n, c) => n + ((c.expose || []).length || 0), 0);
|
|
286
|
+
process.stderr.write(` ${DIM}tools${RESET} ${nativeTools.length ? nativeTools.join(', ') : '(none)'}${composedCount ? ` ${DIM}+ ${composedCount} composed${RESET}` : ''}\n`);
|
|
287
|
+
process.stderr.write(` ${DIM}agents${RESET} ${(m.spec.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
|
|
288
|
+
const views = m.spec.workspace?.views || [];
|
|
289
|
+
process.stderr.write(` ${DIM}views${RESET} ${views.length ? views.map(v => v.name).join(', ') : '(none)'}\n`);
|
|
290
|
+
if (meta) {
|
|
291
|
+
process.stderr.write(` ${DIM}scaffolded${RESET} from ${CYAN}pi:${meta.packageName}${RESET} (namespace ${CYAN}${meta.namespace}${RESET}, ${meta.exposeTools.length} composed tool${meta.exposeTools.length === 1 ? '' : 's'})\n`);
|
|
292
|
+
process.stderr.write(` ${DIM}Edit the pack under ${dest} to customize.${RESET}\n`);
|
|
293
|
+
}
|
|
294
|
+
process.stderr.write(`\n ${DIM}Open with:${RESET} ${CYAN}bahulam plugin ${m.metadata.name}${RESET}\n\n`);
|
|
295
|
+
}
|