@bahulam/code 2.6.15 → 2.6.17
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 +5 -5
- package/src/auth/tarang-auth.mjs +6 -0
- package/src/config/cli-args.mjs +7 -1
- package/src/config/model-catalog.mjs +57 -0
- package/src/core/bundled-runtime.mjs +12 -0
- package/src/core/error-guidance.mjs +8 -4
- package/src/core/local-agent.mjs +3 -3
- package/src/index.mjs +7 -3
- package/src/permissions/command-classifier.mjs +50 -2
- package/src/telemetry/index.mjs +97 -71
- package/src/terminal/main.mjs +40 -0
- package/src/terminal/repl-format.mjs +64 -4
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +152 -26
- package/src/terminal/repl-resume.mjs +27 -12
- package/src/terminal/repl-state.mjs +15 -0
- package/src/terminal/repl.mjs +489 -21
- package/src/ui/approval.mjs +22 -5
- package/src/ui/input-dock.mjs +67 -5
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +2 -0
- package/src/ui/sub-agent.mjs +17 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bahulam/code",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.17",
|
|
4
4
|
"description": "Bahulam Code \u2014 abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"start": "node src/terminal/main.mjs",
|
|
17
|
-
"test": "BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-backend-url.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-agent-history.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-sse-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-tool-executor.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-project-artifacts.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-work-scope.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-skills.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-callback.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-rate-limit-display.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-preflight.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-formatter.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-terminal-rendering.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-input-dock.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-live-steering-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-slash-commands.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval-log.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-kepler-contract.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-session-manager.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-safety.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-jsonl-writer.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-analytics.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-stagnation.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-attachments.mjs"
|
|
17
|
+
"test": "BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-backend-url.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-agent-history.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-sse-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-tool-executor.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-project-artifacts.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-work-scope.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-skills.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-callback.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-rate-limit-display.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-preflight.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-formatter.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-terminal-rendering.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-render-queue.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-input-dock.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-live-steering-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-slash-commands.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval-log.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-kepler-contract.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-session-manager.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-safety.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-jsonl-writer.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-analytics.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-stagnation.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-attachments.mjs"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
20
|
"node": ">=18.0.0"
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"web-tree-sitter": "^0.26.9"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@bahulam/runtime-darwin-arm64": "2.6.
|
|
53
|
-
"@bahulam/runtime-linux-x64": "2.6.
|
|
54
|
-
"@bahulam/runtime-win32-x64": "2.6.
|
|
52
|
+
"@bahulam/runtime-darwin-arm64": "2.6.17",
|
|
53
|
+
"@bahulam/runtime-linux-x64": "2.6.17",
|
|
54
|
+
"@bahulam/runtime-win32-x64": "2.6.17"
|
|
55
55
|
}
|
|
56
56
|
}
|
package/src/auth/tarang-auth.mjs
CHANGED
|
@@ -76,6 +76,12 @@ export class TarangAuth {
|
|
|
76
76
|
models: this._config.models || {},
|
|
77
77
|
configuredProviders: this._config.configured_providers || [],
|
|
78
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,
|
|
79
85
|
};
|
|
80
86
|
}
|
|
81
87
|
|
package/src/config/cli-args.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export function parseArgs(args) {
|
|
|
29
29
|
timeout: null,
|
|
30
30
|
allowedTools: null,
|
|
31
31
|
disallowedTools: null,
|
|
32
|
+
route: null,
|
|
32
33
|
resume: false,
|
|
33
34
|
resumeSessionId: null,
|
|
34
35
|
headless: false,
|
|
@@ -49,6 +50,10 @@ export function parseArgs(args) {
|
|
|
49
50
|
result.model = args[++i];
|
|
50
51
|
break;
|
|
51
52
|
|
|
53
|
+
case '--route':
|
|
54
|
+
result.route = args[++i];
|
|
55
|
+
break;
|
|
56
|
+
|
|
52
57
|
case '--permission-mode':
|
|
53
58
|
result.permissionMode = args[++i];
|
|
54
59
|
break;
|
|
@@ -169,7 +174,8 @@ export function getUsageText() {
|
|
|
169
174
|
Usage: occ [options] [prompt]
|
|
170
175
|
|
|
171
176
|
Options:
|
|
172
|
-
--model, -m <
|
|
177
|
+
--model, -m <id|role=id> Session model override (also named modes: fast|thinking|extra|max)
|
|
178
|
+
--route <platform|byok> Model route; platform validates against the curated catalog
|
|
173
179
|
--permission-mode <mode> Permission mode (bypassPermissions, acceptEdits, plan, auto, dontAsk)
|
|
174
180
|
--print, -p <prompt> Non-interactive mode: run prompt and exit
|
|
175
181
|
--output-format <fmt> Output format: text, json, stream-json
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped model catalog snapshot — Node side of PRD-076 W7.
|
|
3
|
+
*
|
|
4
|
+
* The canonical source is `codekepler-backend/app/services/
|
|
5
|
+
* model_catalog_snapshot.json` (admin dashboard edits it and publishes to
|
|
6
|
+
* Supabase). `stage-runtime-app.sh` copies that file into every runtime
|
|
7
|
+
* wheel at `runtime/app/services/model_catalog_snapshot.json`, so when
|
|
8
|
+
* `@bahulam/runtime-<platform>-<arch>` is installed as a sibling optional
|
|
9
|
+
* dep of `@bahulam/code`, the file is already on disk — we read from
|
|
10
|
+
* there instead of maintaining a third copy in the CLI package.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import { runtimeSnapshotPath } from '../core/bundled-runtime.mjs';
|
|
15
|
+
|
|
16
|
+
let _cache = null;
|
|
17
|
+
|
|
18
|
+
// Snapshot rows are camelCase (matches how the backend delivers the
|
|
19
|
+
// static file); the /api/models response is snake_case. Normalize once
|
|
20
|
+
// here so downstream consumers can treat both sources identically.
|
|
21
|
+
function normalizeSnapshotRow(row) {
|
|
22
|
+
const id = row.value || row.id;
|
|
23
|
+
if (!id) return null;
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
provider: row.provider || (id.includes('/') ? id.split('/', 1)[0] : 'unknown'),
|
|
27
|
+
label: row.label || id,
|
|
28
|
+
input_cost_usd_per_m: row.inputCost ?? row.input_cost_usd_per_m ?? null,
|
|
29
|
+
output_cost_usd_per_m: row.outputCost ?? row.output_cost_usd_per_m ?? null,
|
|
30
|
+
context_length: row.context ?? row.context_length ?? null,
|
|
31
|
+
max_output: row.maxOutput ?? row.max_output ?? null,
|
|
32
|
+
supports_tools: row.supportsTools ?? row.supports_tools ?? false,
|
|
33
|
+
supports_reasoning: row.supportsReasoning ?? row.supports_reasoning ?? false,
|
|
34
|
+
harness_validated: row.harnessValidated ?? row.harness_validated ?? false,
|
|
35
|
+
cache_profile: row.cacheProfile ?? row.cache_profile ?? null,
|
|
36
|
+
platform_access_tier: row.platformAccessTier ?? row.platform_access_tier ?? [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read the shipped catalog snapshot from the installed runtime bundle.
|
|
42
|
+
* Returns an array of normalized rows (API-shape) or null if the runtime
|
|
43
|
+
* isn't installed / the file is unreadable. Cached after first success.
|
|
44
|
+
*/
|
|
45
|
+
export function readShippedCatalog() {
|
|
46
|
+
if (_cache !== null) return _cache;
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(runtimeSnapshotPath(), 'utf-8');
|
|
49
|
+
const data = JSON.parse(raw);
|
|
50
|
+
const rows = Array.isArray(data?.models) ? data.models : [];
|
|
51
|
+
_cache = rows.map(normalizeSnapshotRow).filter(Boolean);
|
|
52
|
+
return _cache;
|
|
53
|
+
} catch {
|
|
54
|
+
_cache = null;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -364,6 +364,18 @@ export function isRuntimeInstalled() {
|
|
|
364
364
|
return fs.existsSync(_runtimeBin());
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Absolute path to the shipped model catalog snapshot inside the runtime
|
|
369
|
+
* bundle. Backend edits `app/services/model_catalog_snapshot.json` as the
|
|
370
|
+
* canonical source (admin dashboard publishes it to Supabase);
|
|
371
|
+
* stage-runtime-app.sh copies that same file into every runtime wheel at
|
|
372
|
+
* `runtime/app/services/model_catalog_snapshot.json`. The CLI reads from
|
|
373
|
+
* there so there is no third copy to keep in sync.
|
|
374
|
+
*/
|
|
375
|
+
export function runtimeSnapshotPath() {
|
|
376
|
+
return path.join(_runtimeRoot(), 'runtime', 'app', 'services', 'model_catalog_snapshot.json');
|
|
377
|
+
}
|
|
378
|
+
|
|
367
379
|
/** Diagnostic snapshot for `bahulam doctor`. */
|
|
368
380
|
export function runtimeInfo() {
|
|
369
381
|
return {
|
|
@@ -251,12 +251,16 @@ export function formatAgentErrorGuidance(data = {}) {
|
|
|
251
251
|
lines.push('Upgrade your plan for a larger 5-hour message window, or switch to BYOK if available.');
|
|
252
252
|
if (pricingUrl) lines.push(`Open ${pricingUrl} to upgrade.`);
|
|
253
253
|
} else if (phase === 'gateway' || code.includes('gateway')) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
// Generic first: on the platform route the user has NO provider key —
|
|
255
|
+
// telling them to "check your DeepSeek API key" is wrong and alarming.
|
|
256
|
+
// Provider-key guidance only applies when we know the route is BYOK.
|
|
257
|
+
const label = providerGuidance?.label || compact(data.provider) || 'model provider';
|
|
258
|
+
lines.push(`The upstream ${label} request failed before the agent could respond — usually a transient provider-side error.`);
|
|
259
|
+
lines.push('Retry the turn. If it persists, switch models with /model and try again.');
|
|
260
|
+
if (compact(data.route) === 'byok' && providerGuidance) {
|
|
257
261
|
lines.push(...providerGuidance.lines);
|
|
258
262
|
} else {
|
|
259
|
-
lines.push('
|
|
263
|
+
lines.push('Using your own key (BYOK)? Verify it is saved and active in settings, then run /login so provider settings sync.');
|
|
260
264
|
}
|
|
261
265
|
} else if (/authentication|token/i.test(message)) {
|
|
262
266
|
lines.push('Run /login to re-authenticate.');
|
package/src/core/local-agent.mjs
CHANGED
|
@@ -21,11 +21,11 @@ const MAX_ITERATIONS = 50;
|
|
|
21
21
|
const TOOL_SCHEMAS = [
|
|
22
22
|
{
|
|
23
23
|
name: 'shell',
|
|
24
|
-
description: 'Run one non-interactive shell command and return stdout/stderr.
|
|
24
|
+
description: 'Run one non-interactive shell command and return stdout/stderr. Command substitution (backticks, $()) is allowed but triggers a user-approval prompt; prefer plain commands when equivalent.',
|
|
25
25
|
input_schema: {
|
|
26
26
|
type: 'object',
|
|
27
27
|
properties: {
|
|
28
|
-
command: { type: 'string', description: 'The command to execute.
|
|
28
|
+
command: { type: 'string', description: 'The command to execute. Backticks/$() are allowed (user approval required); use them when splitting would be awkward.' },
|
|
29
29
|
timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000)' },
|
|
30
30
|
},
|
|
31
31
|
required: ['command'],
|
|
@@ -478,7 +478,7 @@ export class LocalAgent {
|
|
|
478
478
|
'You are Bahulam Code, Bahulam\'s AI coding agent running in local mode.',
|
|
479
479
|
'You have access to tools for reading, writing, and executing code.',
|
|
480
480
|
'Use tools to accomplish the user\'s request. Be concise and direct.',
|
|
481
|
-
'
|
|
481
|
+
'Shell command substitution (backticks, $()) is allowed but requires user approval — use it when it is the natural expression; otherwise prefer plain commands.',
|
|
482
482
|
];
|
|
483
483
|
if (context.cwd) parts.push(`Working directory: ${context.cwd}`);
|
|
484
484
|
if (context.gitBranch) parts.push(`Git branch: ${context.gitBranch}`);
|
package/src/index.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { printBanner, printProjectInfo, printHints, printAuthStatus, printStyled
|
|
|
35
35
|
import { ContextRetriever } from './context/retriever.mjs';
|
|
36
36
|
import { loadSettings } from './config/settings.mjs';
|
|
37
37
|
|
|
38
|
-
const VERSION = '2.6.
|
|
38
|
+
const VERSION = '2.6.17';
|
|
39
39
|
|
|
40
40
|
// ── Arg Parsing (consolidated from index.mjs + cli-args.mjs) ──
|
|
41
41
|
|
|
@@ -50,6 +50,7 @@ function parseArgs(argv) {
|
|
|
50
50
|
// Config subcommand flags
|
|
51
51
|
showConfig: false, openRouterKey: null, anthropicKey: null,
|
|
52
52
|
backendUrl: null, mode: null,
|
|
53
|
+
model: null, route: null,
|
|
53
54
|
// Extended flags (from cli-args.mjs)
|
|
54
55
|
permissionMode: null,
|
|
55
56
|
outputFormat: null,
|
|
@@ -96,7 +97,8 @@ function parseArgs(argv) {
|
|
|
96
97
|
case '--google-key': args.googleKey = argv[++i]; break;
|
|
97
98
|
case '--gateway': args.gateway = argv[++i]; break;
|
|
98
99
|
// --backend-url removed: use TARANG_ENV
|
|
99
|
-
|
|
100
|
+
case '--model': case '-m': args.model = argv[++i]; break;
|
|
101
|
+
case '--route': args.route = argv[++i]; break;
|
|
100
102
|
// Extended flags
|
|
101
103
|
case '--permission-mode': args.permissionMode = argv[++i]; break;
|
|
102
104
|
case '--print': case '-p': case '--instruction': case '--input': args.instruction = argv[++i]; break;
|
|
@@ -175,9 +177,11 @@ function printUsage() {
|
|
|
175
177
|
process.stderr.write(` ${D}(default: auto-select based on task complexity)${R}\n`);
|
|
176
178
|
process.stderr.write('\n');
|
|
177
179
|
process.stderr.write(`${B}MODEL FLAGS${R}\n`);
|
|
180
|
+
process.stderr.write(` ${G}--model, -m <id|role=id>${R} Session model override ${D}(also: fast|thinking|extra|max)${R}\n`);
|
|
181
|
+
process.stderr.write(` ${G}--route <platform|byok>${R} Model route ${D}(platform validates against curated catalog)${R}\n`);
|
|
178
182
|
process.stderr.write(` ${G}--system-prompt <text>${R} Override system prompt\n`);
|
|
179
183
|
process.stderr.write(` ${G}--max-turns <n>${R} Maximum conversation turns\n`);
|
|
180
|
-
process.stderr.write(` ${D}
|
|
184
|
+
process.stderr.write(` ${D}Persistent defaults are configured via: bahulam configure${R}\n`);
|
|
181
185
|
process.stderr.write('\n');
|
|
182
186
|
process.stderr.write(`${B}PERMISSION FLAGS${R}\n`);
|
|
183
187
|
process.stderr.write(` ${G}--yes, -y${R} Auto-approve all operations\n`);
|
|
@@ -132,6 +132,32 @@ function containsUnquotedExpansion(command) {
|
|
|
132
132
|
/**
|
|
133
133
|
* Detect command substitution patterns.
|
|
134
134
|
*/
|
|
135
|
+
/**
|
|
136
|
+
* Extract the inner text of every `$( … )` (nesting-aware) and
|
|
137
|
+
* `` ` … ` `` region so each body can be classified on its own.
|
|
138
|
+
*/
|
|
139
|
+
function extractSubstitutionBodies(command) {
|
|
140
|
+
const bodies = [];
|
|
141
|
+
const s = String(command || '');
|
|
142
|
+
for (const m of s.match(/`([^`]*)`/g) || []) {
|
|
143
|
+
bodies.push(m.slice(1, -1));
|
|
144
|
+
}
|
|
145
|
+
for (let i = 0; i < s.length; i++) {
|
|
146
|
+
if (s[i] === '$' && s[i + 1] === '(') {
|
|
147
|
+
let depth = 1;
|
|
148
|
+
let j = i + 2;
|
|
149
|
+
while (j < s.length && depth > 0) {
|
|
150
|
+
if (s[j] === '(') depth++;
|
|
151
|
+
else if (s[j] === ')') depth--;
|
|
152
|
+
j++;
|
|
153
|
+
}
|
|
154
|
+
bodies.push(s.slice(i + 2, depth === 0 ? j - 1 : j));
|
|
155
|
+
i = j - 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return bodies.map(b => b.trim()).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
135
161
|
function containsCommandSubstitution(command) {
|
|
136
162
|
// Backticks
|
|
137
163
|
if (/`/.test(command)) return true;
|
|
@@ -505,9 +531,31 @@ export function classifyCommand(command) {
|
|
|
505
531
|
}
|
|
506
532
|
}
|
|
507
533
|
|
|
508
|
-
// ── Step 2:
|
|
534
|
+
// ── Step 2: Command substitution → approval-gated, not hard-blocked ──
|
|
535
|
+
// Backticks / $() are legitimate shell (lsof -ti:8000 | xargs kill,
|
|
536
|
+
// $(git rev-parse HEAD), etc). Hard-blocking them forced the model
|
|
537
|
+
// into awkward multi-step workarounds even after the user approved.
|
|
538
|
+
// The substitution BODY is classified recursively — `echo $(rm -rf /)`
|
|
539
|
+
// stays hard-blocked because the inner command is; a benign body
|
|
540
|
+
// surfaces as an explicit HITL approval (contained + highRisk), same
|
|
541
|
+
// as process cleanup above. (Step 1's whole-string regexes alone are
|
|
542
|
+
// not enough: `rm -rf /` inside $() terminates with `)` which the
|
|
543
|
+
// \s|$ terminators never match.)
|
|
509
544
|
if (containsCommandSubstitution(trimmed)) {
|
|
510
|
-
|
|
545
|
+
for (const inner of extractSubstitutionBodies(trimmed)) {
|
|
546
|
+
const innerResult = classifyCommand(inner);
|
|
547
|
+
if (innerResult.classification === 'blocked') {
|
|
548
|
+
return {
|
|
549
|
+
classification: 'blocked',
|
|
550
|
+
reason: `Substitution body is blocked: ${inner.slice(0, 50)}`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
classification: 'contained',
|
|
556
|
+
reason: 'Uses command substitution (backticks or $()); requires approval',
|
|
557
|
+
highRisk: true,
|
|
558
|
+
};
|
|
511
559
|
}
|
|
512
560
|
|
|
513
561
|
if (containsUnsafeOutputRedirection(trimmed)) {
|
package/src/telemetry/index.mjs
CHANGED
|
@@ -1,96 +1,122 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telemetry
|
|
2
|
+
* Telemetry — Funnel event emitter (PRD-076 W3).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Buffers funnel events and flushes to {backend}/api/telemetry/funnel
|
|
5
|
+
* on a timer and on process exit. Device ID is generated once per install
|
|
6
|
+
* for anon-to-logged-in correlation.
|
|
7
|
+
*
|
|
8
|
+
* Opt-out: BAHULAM_DISABLE_TELEMETRY=1 or CLAUDE_CODE_DISABLE_TELEMETRY=1
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import * as crypto from 'node:crypto';
|
|
14
|
+
import { bahulamHome } from '../core/paths.mjs';
|
|
15
|
+
|
|
16
|
+
const BUF_LIMIT = 500;
|
|
17
|
+
const FLUSH_INTERVAL_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
let events = [];
|
|
9
20
|
let enabled = true;
|
|
21
|
+
let flushTimer = null;
|
|
22
|
+
let _backendUrl = null;
|
|
23
|
+
let _token = null;
|
|
24
|
+
let _deviceId = null;
|
|
10
25
|
|
|
11
|
-
|
|
12
|
-
* Track a telemetry event.
|
|
13
|
-
* @param {string} event - event name
|
|
14
|
-
* @param {object} [properties] - event properties
|
|
15
|
-
*/
|
|
16
|
-
export function track(event, properties = {}) {
|
|
17
|
-
if (!enabled) return;
|
|
18
|
-
if (process.env.CLAUDE_CODE_DISABLE_TELEMETRY === '1') return;
|
|
26
|
+
/* ── Configuration ── */
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
28
|
+
export function disable() {
|
|
29
|
+
enabled = false;
|
|
30
|
+
if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
|
|
31
|
+
}
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
export function configure(backendUrl, token) {
|
|
34
|
+
_backendUrl = backendUrl;
|
|
35
|
+
_token = token;
|
|
36
|
+
}
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
if (events.length > 1000) {
|
|
30
|
-
events.splice(0, events.length - 1000);
|
|
31
|
-
}
|
|
38
|
+
/* ── Device ID ── */
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
function readOrCreateDeviceId() {
|
|
41
|
+
const dir = bahulamHome();
|
|
42
|
+
const idPath = path.join(dir, 'device_id');
|
|
43
|
+
try {
|
|
44
|
+
if (fs.existsSync(idPath)) {
|
|
45
|
+
return fs.readFileSync(idPath, 'utf-8').trim();
|
|
35
46
|
}
|
|
47
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
|
+
const id = crypto.randomUUID();
|
|
49
|
+
fs.writeFileSync(idPath, id, { mode: 0o600 });
|
|
50
|
+
return id;
|
|
51
|
+
} catch { return 'unknown'; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getDeviceId() {
|
|
55
|
+
if (!_deviceId) _deviceId = readOrCreateDeviceId();
|
|
56
|
+
return _deviceId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ── Tracking ── */
|
|
60
|
+
|
|
61
|
+
export function track(event, properties = {}) {
|
|
62
|
+
if (!enabled) return;
|
|
63
|
+
if (process.env.BAHULAM_DISABLE_TELEMETRY === '1') return;
|
|
64
|
+
if (process.env.CLAUDE_CODE_DISABLE_TELEMETRY === '1') return;
|
|
65
|
+
|
|
66
|
+
const entry = { event, properties: { ...properties }, device_id: getDeviceId(), timestamp: Date.now() };
|
|
67
|
+
events.push(entry);
|
|
68
|
+
if (events.length > BUF_LIMIT) events.splice(0, events.length - BUF_LIMIT);
|
|
69
|
+
|
|
70
|
+
if (process.env.BAHULAM_DEBUG_TELEMETRY) {
|
|
71
|
+
process.stderr.write(`[telemetry] ${event} ${JSON.stringify(properties).slice(0, 200)}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!flushTimer) {
|
|
75
|
+
flushTimer = setInterval(flush, FLUSH_INTERVAL_MS);
|
|
76
|
+
flushTimer.unref();
|
|
77
|
+
}
|
|
36
78
|
}
|
|
37
79
|
|
|
38
|
-
/**
|
|
39
|
-
* Track a timing event.
|
|
40
|
-
* @param {string} event - event name
|
|
41
|
-
* @param {number} durationMs - duration in milliseconds
|
|
42
|
-
* @param {object} [properties] - additional properties
|
|
43
|
-
*/
|
|
44
80
|
export function trackTiming(event, durationMs, properties = {}) {
|
|
45
|
-
|
|
81
|
+
track(event, { ...properties, durationMs });
|
|
46
82
|
}
|
|
47
83
|
|
|
48
|
-
/**
|
|
49
|
-
* Track an error.
|
|
50
|
-
* @param {string} event - error context
|
|
51
|
-
* @param {Error} error - the error
|
|
52
|
-
*/
|
|
53
84
|
export function trackError(event, error) {
|
|
54
|
-
|
|
55
|
-
message: error.message,
|
|
56
|
-
stack: error.stack?.split('\n').slice(0, 3).join('\n'),
|
|
57
|
-
});
|
|
85
|
+
track(`error.${event}`, { message: error.message, stack: error.stack?.split('\n').slice(0, 3).join('\n') });
|
|
58
86
|
}
|
|
59
87
|
|
|
60
|
-
|
|
61
|
-
* Get collected events (for debugging).
|
|
62
|
-
* @returns {Array}
|
|
63
|
-
*/
|
|
64
|
-
export function getEvents() {
|
|
65
|
-
return [...events];
|
|
66
|
-
}
|
|
88
|
+
/* ── Transport ── */
|
|
67
89
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
export async function flush() {
|
|
91
|
+
if (!enabled || events.length === 0 || !_backendUrl) return;
|
|
92
|
+
const batch = events.splice(0);
|
|
93
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
94
|
+
if (_token) headers['Authorization'] = `Bearer ${_token}`;
|
|
95
|
+
try {
|
|
96
|
+
const resp = await fetch(`${_backendUrl}/api/telemetry/funnel`, {
|
|
97
|
+
method: 'POST', headers,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
events: batch,
|
|
100
|
+
user_id: _token ? undefined : undefined, // backend derives from token if present
|
|
101
|
+
source: 'cli',
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
if (!resp.ok && resp.status >= 500) events.unshift(...batch);
|
|
105
|
+
} catch { events.unshift(...batch); if (events.length > BUF_LIMIT * 2) events.splice(0, events.length - BUF_LIMIT); }
|
|
73
106
|
}
|
|
74
107
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*/
|
|
79
|
-
export function setEnabled(value) {
|
|
80
|
-
enabled = value;
|
|
108
|
+
export async function shutdown() {
|
|
109
|
+
if (flushTimer) { clearInterval(flushTimer); flushTimer = null; }
|
|
110
|
+
await flush();
|
|
81
111
|
}
|
|
82
112
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
/* ── Debug ── */
|
|
114
|
+
|
|
115
|
+
export function getEvents() { return [...events]; }
|
|
116
|
+
export function clear() { events.length = 0; }
|
|
117
|
+
export function setEnabled(value) { enabled = value; }
|
|
86
118
|
export function getStats() {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
totalEvents: events.length,
|
|
93
|
-
enabled,
|
|
94
|
-
eventCounts: counts,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
119
|
+
const counts = {};
|
|
120
|
+
for (const e of events) counts[e.event] = (counts[e.event] || 0) + 1;
|
|
121
|
+
return { totalEvents: events.length, enabled, eventCounts: counts };
|
|
122
|
+
}
|
package/src/terminal/main.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Zero React. Zero Ink. Zero flickering.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
7
9
|
import { startTerminalRepl } from './repl.mjs';
|
|
8
10
|
import {
|
|
9
11
|
runSessionsCommand,
|
|
@@ -11,6 +13,9 @@ import {
|
|
|
11
13
|
runHistoryCommand,
|
|
12
14
|
} from './analytics.mjs';
|
|
13
15
|
import { parseArgs } from '../config/cli-args.mjs';
|
|
16
|
+
import * as telemetry from '../telemetry/index.mjs';
|
|
17
|
+
import { bahulamHome } from '../core/paths.mjs';
|
|
18
|
+
import { TarangAuth as Auth } from '../auth/tarang-auth.mjs';
|
|
14
19
|
|
|
15
20
|
// ── Subcommands ──
|
|
16
21
|
|
|
@@ -83,7 +88,40 @@ function parseKeplerSubcommandArgs(command, argv) {
|
|
|
83
88
|
return parsed;
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
function detectInstallFirstRun() {
|
|
92
|
+
const markerPath = path.join(bahulamHome(), '.install_marker');
|
|
93
|
+
try {
|
|
94
|
+
if (fs.existsSync(markerPath)) return false;
|
|
95
|
+
fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
|
|
96
|
+
return true;
|
|
97
|
+
} catch { return false; }
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
async function main() {
|
|
101
|
+
// ── Telemetry bootstrap ──
|
|
102
|
+
const _auth = new Auth();
|
|
103
|
+
const _creds = _auth.loadCredentials();
|
|
104
|
+
telemetry.configure(_creds.backendUrl, _creds.token);
|
|
105
|
+
|
|
106
|
+
// Fire install_first_run on first ever CLI invocation
|
|
107
|
+
if (detectInstallFirstRun()) {
|
|
108
|
+
telemetry.track('install_first_run', { version: process.env.npm_package_version || '' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check day7_return — fire once per install
|
|
112
|
+
const configDir_ = bahulamHome();
|
|
113
|
+
const installMarker_ = path.join(configDir_, '.install_marker');
|
|
114
|
+
const returnFiredPath_ = path.join(configDir_, '.return_fired');
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(installMarker_) && !fs.existsSync(returnFiredPath_)) {
|
|
117
|
+
const installTime = parseInt(fs.readFileSync(installMarker_, 'utf-8').trim(), 10);
|
|
118
|
+
if (!isNaN(installTime) && Date.now() - installTime >= 7 * 24 * 60 * 60 * 1000) {
|
|
119
|
+
telemetry.track('day7_return', {});
|
|
120
|
+
fs.writeFileSync(returnFiredPath_, '1', { mode: 0o600 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
|
|
87
125
|
if (subcommand === 'dashboard') {
|
|
88
126
|
// Launch Bahulam Pulse Next.js dashboard
|
|
89
127
|
const { spawn } = await import('node:child_process');
|
|
@@ -134,7 +172,9 @@ async function main() {
|
|
|
134
172
|
const { TarangAuth } = await import('../auth/tarang-auth.mjs');
|
|
135
173
|
const auth = new TarangAuth();
|
|
136
174
|
try {
|
|
175
|
+
telemetry.track('login_shown', { method: 'cli_subcommand' });
|
|
137
176
|
await auth.login();
|
|
177
|
+
telemetry.track('login_completed', { method: 'cli_subcommand' });
|
|
138
178
|
process.stderr.write('\x1b[32m✓ Login successful!\x1b[0m\n');
|
|
139
179
|
return;
|
|
140
180
|
} catch (err) {
|