@bike4mind/cli 3.4.0 → 4.0.0

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.
@@ -0,0 +1,455 @@
1
+ #!/usr/bin/env node
2
+ import os from "os";
3
+ import path from "path";
4
+ import fs from "fs/promises";
5
+ //#region src/config/commands.ts
6
+ const COMMANDS = [
7
+ {
8
+ name: "help",
9
+ description: "Show this help message"
10
+ },
11
+ {
12
+ name: "exit",
13
+ description: "Exit the CLI",
14
+ aliases: ["quit"]
15
+ },
16
+ {
17
+ name: "login",
18
+ description: "Authenticate with your B4M account"
19
+ },
20
+ {
21
+ name: "logout",
22
+ description: "Clear authentication and sign out"
23
+ },
24
+ {
25
+ name: "whoami",
26
+ description: "Show current authenticated user"
27
+ },
28
+ {
29
+ name: "save",
30
+ description: "Save current session",
31
+ args: "<name>"
32
+ },
33
+ {
34
+ name: "resume",
35
+ description: "List and resume saved sessions",
36
+ aliases: ["sessions"]
37
+ },
38
+ {
39
+ name: "config",
40
+ description: "Open interactive configuration editor"
41
+ },
42
+ {
43
+ name: "model",
44
+ description: "Switch the active model (opens a picker, or matches an id/name argument)",
45
+ args: "[id-or-name]"
46
+ },
47
+ {
48
+ name: "set-api",
49
+ description: "Connect to self-hosted Bike4Mind instance",
50
+ args: "<url>"
51
+ },
52
+ {
53
+ name: "reset-api",
54
+ description: "Reset to Bike4Mind main service"
55
+ },
56
+ {
57
+ name: "api-info",
58
+ description: "Show current API configuration"
59
+ },
60
+ {
61
+ name: "trust",
62
+ description: "Trust a tool, or `folder` to trust this project's repo config/agents/skills/MCP",
63
+ args: "<tool-name|folder>"
64
+ },
65
+ {
66
+ name: "untrust",
67
+ description: "Remove a tool from the trusted list, or `folder` to revoke project trust",
68
+ args: "<tool-name|folder>"
69
+ },
70
+ {
71
+ name: "trusted",
72
+ description: "Show folder-trust status and list all trusted tools"
73
+ },
74
+ {
75
+ name: "usage",
76
+ description: "Show credit usage and balance"
77
+ },
78
+ {
79
+ name: "clear",
80
+ description: "Start a new session",
81
+ aliases: ["new"]
82
+ },
83
+ {
84
+ name: "rewind",
85
+ description: "Rewind conversation to a previous point"
86
+ },
87
+ {
88
+ name: "undo",
89
+ description: "Undo the last file change"
90
+ },
91
+ {
92
+ name: "checkpoints",
93
+ description: "List available file restore points"
94
+ },
95
+ {
96
+ name: "restore",
97
+ description: "Restore files to a specific checkpoint",
98
+ args: "<number>"
99
+ },
100
+ {
101
+ name: "diff",
102
+ description: "Show diff between current state and a checkpoint",
103
+ args: "[number]"
104
+ },
105
+ {
106
+ name: "project-config",
107
+ description: "Show merged project configuration"
108
+ },
109
+ {
110
+ name: "commands",
111
+ description: "List all custom commands"
112
+ },
113
+ {
114
+ name: "commands:new",
115
+ description: "Create a new custom command",
116
+ args: "<name>"
117
+ },
118
+ {
119
+ name: "commands:reload",
120
+ description: "Reload custom commands from disk"
121
+ },
122
+ {
123
+ name: "mcp",
124
+ description: "Show MCP server status and connected tools",
125
+ aliases: ["mcp:list"]
126
+ },
127
+ {
128
+ name: "agents",
129
+ description: "List all available agents",
130
+ aliases: ["agents:list"]
131
+ },
132
+ {
133
+ name: "agents:new",
134
+ description: "Create a new agent definition",
135
+ args: "<name>"
136
+ },
137
+ {
138
+ name: "agents:reload",
139
+ description: "Reload agent definitions from disk"
140
+ },
141
+ {
142
+ name: "context",
143
+ description: "Show context window usage"
144
+ },
145
+ {
146
+ name: "compact",
147
+ description: "Compact conversation into new session",
148
+ args: "[instructions]"
149
+ },
150
+ {
151
+ name: "sandbox",
152
+ description: "Show sandbox status and configuration"
153
+ },
154
+ {
155
+ name: "sandbox:enable",
156
+ description: "Enable OS-level sandbox for bash commands"
157
+ },
158
+ {
159
+ name: "sandbox:disable",
160
+ description: "Disable sandbox mode"
161
+ },
162
+ {
163
+ name: "sandbox:mode",
164
+ description: "Set sandbox mode (auto-allow or permissions)",
165
+ args: "<auto-allow|permissions>"
166
+ },
167
+ {
168
+ name: "sandbox:network",
169
+ description: "Toggle sandbox network egress (off is fail-closed default)",
170
+ args: "<on|off>"
171
+ },
172
+ {
173
+ name: "sandbox:trust-domain",
174
+ description: "Add domain(s) to the network proxy allowlist",
175
+ args: "<domain> [...]"
176
+ },
177
+ {
178
+ name: "sandbox:domains",
179
+ description: "Show network proxy allowed domains"
180
+ },
181
+ {
182
+ name: "sandbox:violations",
183
+ description: "Show recent sandbox violations",
184
+ args: "[count]"
185
+ },
186
+ {
187
+ name: "sandbox:violations:clear",
188
+ description: "Clear all recorded sandbox violations"
189
+ },
190
+ {
191
+ name: "terminal-setup",
192
+ description: "Configure Shift+Enter for multi-line input"
193
+ },
194
+ {
195
+ name: "add-dir",
196
+ description: "Add a directory for file access",
197
+ args: "<path>"
198
+ },
199
+ {
200
+ name: "remove-dir",
201
+ description: "Remove a directory from file access",
202
+ args: "<path>"
203
+ },
204
+ {
205
+ name: "dirs",
206
+ description: "List all accessible directories"
207
+ },
208
+ {
209
+ name: "workflow",
210
+ description: "Show workflow overview or a specific section (decisions, blockers, handoff, review-gates; `gates` alias accepted)",
211
+ args: "[decisions|blockers|handoff|review-gates]"
212
+ },
213
+ {
214
+ name: "decisions",
215
+ description: "Show decision log for current session (alias for /workflow decisions)"
216
+ },
217
+ {
218
+ name: "blockers",
219
+ description: "Show tracked blockers for current session (alias for /workflow blockers)"
220
+ },
221
+ {
222
+ name: "review-gates",
223
+ description: "Show review gates for current session (alias for /workflow review-gates)"
224
+ },
225
+ {
226
+ name: "handoff",
227
+ description: "Show or generate the session handoff for cross-session continuity. Use --local for an LLM-free snapshot (works when rate-limited or offline). Alias for /workflow handoff.",
228
+ args: "[generate|--local]"
229
+ }
230
+ ];
231
+ /**
232
+ * Get all command names including aliases
233
+ */
234
+ function getAllCommandNames() {
235
+ const names = [];
236
+ for (const cmd of COMMANDS) {
237
+ names.push(cmd.name);
238
+ if (cmd.aliases) names.push(...cmd.aliases);
239
+ }
240
+ return names;
241
+ }
242
+ /**
243
+ * Checks if a command name is a built-in command
244
+ * @param name - Command name to check
245
+ * @returns true if the command is built-in
246
+ */
247
+ function isBuiltInCommand(name) {
248
+ return getAllCommandNames().includes(name);
249
+ }
250
+ /**
251
+ * First-party feature-module slash commands (FeatureModuleRegistry). These are
252
+ * registered at runtime, not part of COMMANDS, so isBuiltInCommand misses them -
253
+ * yet a remote/custom skill that shadows one would hijack the feature. Kept as a
254
+ * static list because RemoteSkillSource filters at fetch time, before any registry
255
+ * is built. MUST STAY IN SYNC with the feature modules' getCommands() (tavern,
256
+ * hearth); a drift guard in commands.test.ts derives the names from those modules
257
+ * and fails if this list drifts.
258
+ */
259
+ const RESERVED_FEATURE_COMMANDS = [
260
+ "tavern",
261
+ "quest",
262
+ "hearth"
263
+ ];
264
+ /**
265
+ * Checks if a command name is reserved (a built-in OR a first-party feature command).
266
+ * Use this, not isBuiltInCommand, when deciding whether an external skill may register.
267
+ *
268
+ * `featureCommandNames` carries the LIVE feature/plugin command names from the
269
+ * runtime FeatureModuleRegistry - a dynamic superset of the static
270
+ * RESERVED_FEATURE_COMMANDS. Pass it so load, display, and dispatch share one
271
+ * reserved-name source; a repo-planted command whose name matches a runtime
272
+ * plugin command is otherwise hidden from display yet still loads and executes.
273
+ */
274
+ function isReservedCommandName(name, featureCommandNames) {
275
+ return isBuiltInCommand(name) || RESERVED_FEATURE_COMMANDS.includes(name) || (featureCommandNames?.has(name) ?? false);
276
+ }
277
+ /**
278
+ * Point a command store's reserved-name gate at a feature registry's live
279
+ * command names, then prune any project command that loaded (pre-registry)
280
+ * under a name the registry now owns. Both index.tsx wiring sites (bootstrap and
281
+ * plugin hot-reload) call this, so load, display, and dispatch share one
282
+ * reserved-name source and the wiring is unit-testable in isolation.
283
+ */
284
+ function wireReservedCommandNames(store, registry) {
285
+ store.setReservedNameSource(() => new Set(registry.getAllCommands().map((c) => c.name)));
286
+ store.pruneReservedProjectCommands();
287
+ }
288
+ /**
289
+ * Re-wire a CLI state's command store to a feature registry. Both index.tsx
290
+ * wiring sites (bootstrap and plugin hot-reload) call this with the live `state`,
291
+ * so the store-extraction step is pinned by a unit test instead of living
292
+ * untested inline at each call site (no test imports index.tsx).
293
+ */
294
+ function rewireReservedNames(state, registry) {
295
+ wireReservedCommandNames(state.customCommandStore, registry);
296
+ }
297
+ /**
298
+ * Converts a CustomCommand to a CommandDefinition for unified handling
299
+ * @param customCommand - Custom command to convert
300
+ * @returns CommandDefinition compatible with autocomplete and help
301
+ */
302
+ function customCommandToDefinition(customCommand) {
303
+ return {
304
+ name: customCommand.name,
305
+ description: customCommand.description,
306
+ args: customCommand.argumentHint,
307
+ source: customCommand.source
308
+ };
309
+ }
310
+ /**
311
+ * Merges custom commands and feature module commands with built-in commands.
312
+ * Built-in commands always take precedence (custom commands cannot override them).
313
+ *
314
+ * @param customCommands - Array of custom commands to merge
315
+ * @param featureCommands - Optional array of feature module command definitions
316
+ * @returns Combined array of all command definitions
317
+ */
318
+ function mergeCommands(customCommands, featureCommands) {
319
+ const builtInCommands = COMMANDS.map((cmd) => ({
320
+ ...cmd,
321
+ source: "built-in"
322
+ }));
323
+ const featureNames = new Set((featureCommands ?? []).map((cmd) => cmd.name));
324
+ const isReserved = (name) => isReservedCommandName(name, featureNames);
325
+ const customDefinitions = customCommands.filter((cmd) => !isReserved(cmd.name)).map(customCommandToDefinition);
326
+ const conflicts = customCommands.filter((cmd) => isReserved(cmd.name));
327
+ if (conflicts.length > 0) console.warn("Warning: The following custom commands have reserved names (a built-in or feature/plugin command) and will be ignored:", conflicts.map((cmd) => cmd.name).join(", "));
328
+ return [
329
+ ...builtInCommands,
330
+ ...featureCommands ?? [],
331
+ ...customDefinitions
332
+ ];
333
+ }
334
+ //#endregion
335
+ //#region src/storage/RemoteSkillSource.ts
336
+ const DEFAULT_FRESH_TTL_MS = 3e5;
337
+ const DEFAULT_PAGE_LIMIT = 100;
338
+ var RemoteSkillSource = class {
339
+ constructor(apiClient, options = {}) {
340
+ this.apiClient = apiClient;
341
+ this.forceNext = false;
342
+ this.cacheFilePath = options.cacheFilePath ?? path.join(os.homedir(), ".bike4mind", "cache", "remote-skills.json");
343
+ this.freshTtlMs = options.freshTtlMs ?? DEFAULT_FRESH_TTL_MS;
344
+ this.pageLimit = options.pageLimit ?? DEFAULT_PAGE_LIMIT;
345
+ this.includeImports = options.includeImports ?? false;
346
+ }
347
+ /** Make the next `fetchSkills` go to the network even inside the TTL. The cache stays as the
348
+ * offline fallback. For a host that just changed the account's skills itself. */
349
+ invalidate() {
350
+ this.forceNext = true;
351
+ }
352
+ /**
353
+ * Returns the user's remote skills mapped to `CustomCommand`. Uses an
354
+ * in-memory + on-disk cache to avoid a network round-trip on every
355
+ * `/commands` invocation and to keep working offline.
356
+ */
357
+ async fetchSkills() {
358
+ const cached = await this.readCache();
359
+ const forced = this.forceNext;
360
+ this.forceNext = false;
361
+ if (cached && !forced && this.isFresh(cached)) return this.mapAndFilter(cached.skills);
362
+ try {
363
+ const skills = (await this.apiClient.get(`/api/skills?limit=${this.pageLimit}`))?.data ?? [];
364
+ if (skills.length > 0 || !cached || cached.skills.length === 0) await this.writeCache({
365
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
366
+ skills
367
+ });
368
+ return this.mapAndFilter(skills);
369
+ } catch (error) {
370
+ if (process.env.BIKE4MIND_CLI_DEBUG) console.warn("[RemoteSkillSource] fetch failed, falling back to cache:", error instanceof Error ? error.message : String(error));
371
+ if (cached) return this.mapAndFilter(cached.skills);
372
+ return [];
373
+ }
374
+ }
375
+ /**
376
+ * Map server skills to `CustomCommand`, dropping any that must not register:
377
+ * - a name that shadows a built-in OR a first-party feature slash command
378
+ * (tavern/quest/hearth), which would otherwise hijack the command, and
379
+ * - a foreign-owned skill (a `userId` other than the current user's), which is
380
+ * only visible cross-account via `isGlobalRead`, and
381
+ * - a Skill Market import: the user owns the copy but someone else wrote it, and local commands run
382
+ * as trusted instructions next to shell/file tools (the web app wraps it as untrusted instead).
383
+ * With `includeImports` it is kept as `untrusted: true`, for a host that wraps it the same way.
384
+ * Own, system (no `userId`), and org-scoped (server-gated, no `userId`) skills pass.
385
+ *
386
+ * Unauthenticated contract (fail-closed, intentional): when getCurrentUser()
387
+ * returns null, currentUserId is undefined and every skill carrying a userId -
388
+ * including the user's OWN - is dropped. This over-restricts rather than risk
389
+ * registering a foreign skill against an unverifiable identity; own skills
390
+ * reappear once authenticated.
391
+ */
392
+ async mapAndFilter(skills) {
393
+ const currentUserId = (await this.apiClient.getCurrentUser?.())?.id;
394
+ return skills.filter((skill) => {
395
+ if (isReservedCommandName(skill.name)) return false;
396
+ if (skill.userId && skill.userId !== currentUserId) return false;
397
+ if (skill.importedFrom && !this.includeImports) return false;
398
+ return true;
399
+ }).map((skill) => this.toCustomCommand(skill));
400
+ }
401
+ /** For tests and the `/commands:reload` handler - clears the on-disk cache. */
402
+ async clearCache() {
403
+ try {
404
+ await fs.unlink(this.cacheFilePath);
405
+ } catch (error) {
406
+ if (error.code !== "ENOENT") throw error;
407
+ }
408
+ }
409
+ isFresh(cache) {
410
+ const fetchedAt = Date.parse(cache.fetchedAt);
411
+ if (Number.isNaN(fetchedAt)) return false;
412
+ return Date.now() - fetchedAt < this.freshTtlMs;
413
+ }
414
+ async readCache() {
415
+ try {
416
+ const raw = await fs.readFile(this.cacheFilePath, "utf-8");
417
+ const parsed = JSON.parse(raw);
418
+ if (parsed && typeof parsed === "object" && "fetchedAt" in parsed && "skills" in parsed && Array.isArray(parsed.skills)) return parsed;
419
+ return null;
420
+ } catch (error) {
421
+ if (error.code === "ENOENT") return null;
422
+ if (process.env.BIKE4MIND_CLI_DEBUG) console.warn("[RemoteSkillSource] cache read failed:", error instanceof Error ? error.message : String(error));
423
+ return null;
424
+ }
425
+ }
426
+ async writeCache(snapshot) {
427
+ try {
428
+ await fs.mkdir(path.dirname(this.cacheFilePath), {
429
+ recursive: true,
430
+ mode: 448
431
+ });
432
+ await fs.writeFile(this.cacheFilePath, JSON.stringify(snapshot, null, 2), {
433
+ encoding: "utf-8",
434
+ mode: 384
435
+ });
436
+ } catch (error) {
437
+ if (process.env.BIKE4MIND_CLI_DEBUG) console.warn("[RemoteSkillSource] cache write failed:", error instanceof Error ? error.message : String(error));
438
+ }
439
+ }
440
+ toCustomCommand(skill) {
441
+ return {
442
+ name: skill.name,
443
+ description: skill.description,
444
+ body: skill.body,
445
+ argumentHint: skill.argumentHint,
446
+ allowedTools: skill.allowedTools,
447
+ disableModelInvocation: skill.disableModelInvocation,
448
+ ...skill.importedFrom ? { untrusted: true } : {},
449
+ source: "remote",
450
+ filePath: `b4m:/api/skills/${skill.id}`
451
+ };
452
+ }
453
+ };
454
+ //#endregion
455
+ export { rewireReservedNames as a, mergeCommands as i, COMMANDS as n, isReservedCommandName as r, RemoteSkillSource as t };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { It as tokenEstimateMultiplier } from "./ConfigStore-CbVFLXMH.mjs";
2
+ import { Vt as tokenEstimateMultiplier } from "./ConfigStore-BwqsQ0U9.mjs";
3
3
  import { v4 } from "uuid";
4
4
  import { get_encoding } from "tiktoken";
5
5
  //#region ../../b4m-core/utils/dist/calibratedTokenizer.mjs