bertrand 0.18.0 → 0.19.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.
package/dist/bertrand.js CHANGED
@@ -23,20 +23,325 @@ var BERTRAND_DIR = ".bertrand", paths;
23
23
  var init_paths = __esm(() => {
24
24
  paths = {
25
25
  root: join(homedir(), BERTRAND_DIR),
26
- db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
27
26
  hooks: join(homedir(), BERTRAND_DIR, "hooks"),
28
27
  sessions: join(homedir(), BERTRAND_DIR, "sessions"),
28
+ db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
29
29
  syncEnv: join(homedir(), BERTRAND_DIR, "sync.env")
30
30
  };
31
31
  });
32
32
 
33
- // src/sync/config.ts
33
+ // src/lib/projects/registry.ts
34
34
  import {
35
+ existsSync,
36
+ mkdirSync,
35
37
  readFileSync,
38
+ readdirSync,
39
+ renameSync,
36
40
  statSync,
37
- writeFileSync,
41
+ writeFileSync
42
+ } from "fs";
43
+ import { randomBytes } from "crypto";
44
+ import { join as join2 } from "path";
45
+ function _getRegistryDir() {
46
+ return _registryDir;
47
+ }
48
+ function registryPath() {
49
+ return join2(_registryDir, "projects.json");
50
+ }
51
+ function projectsDir() {
52
+ return join2(_registryDir, "projects");
53
+ }
54
+ function readRegistry() {
55
+ const path = registryPath();
56
+ if (!existsSync(path))
57
+ return null;
58
+ try {
59
+ const raw = readFileSync(path, "utf8");
60
+ const parsed = JSON.parse(raw);
61
+ if (!isRegistry(parsed))
62
+ return null;
63
+ return parsed;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ function writeRegistry(registry) {
69
+ const path = registryPath();
70
+ mkdirSync(_registryDir, { recursive: true });
71
+ const tmp = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}-${randomBytes(4).toString("hex")}`;
72
+ writeFileSync(tmp, JSON.stringify(registry, null, 2) + `
73
+ `);
74
+ renameSync(tmp, path);
75
+ }
76
+ function recoverFromDisk() {
77
+ const dir = projectsDir();
78
+ if (!existsSync(dir))
79
+ return null;
80
+ let entries;
81
+ try {
82
+ entries = readdirSync(dir);
83
+ } catch {
84
+ return null;
85
+ }
86
+ const projects = [];
87
+ for (const slug of entries) {
88
+ const subdir = join2(dir, slug);
89
+ let mtime;
90
+ try {
91
+ const s = statSync(subdir);
92
+ if (!s.isDirectory())
93
+ continue;
94
+ mtime = new Date(s.mtimeMs);
95
+ } catch {
96
+ continue;
97
+ }
98
+ if (!existsSync(join2(subdir, "bertrand.db")))
99
+ continue;
100
+ projects.push({
101
+ slug,
102
+ name: slug,
103
+ createdAt: mtime.toISOString(),
104
+ lastUsedAt: mtime.toISOString()
105
+ });
106
+ }
107
+ if (projects.length === 0)
108
+ return null;
109
+ projects.sort((a, b) => a.slug.localeCompare(b.slug));
110
+ const envSlug = process.env.BERTRAND_PROJECT;
111
+ const hasEnv = !!envSlug && projects.some((p) => p.slug === envSlug);
112
+ const hasDefault = projects.some((p) => p.slug === DEFAULT_PROJECT_SLUG);
113
+ const activeProjectSlug = hasEnv ? envSlug : hasDefault ? DEFAULT_PROJECT_SLUG : projects[0].slug;
114
+ return { activeProjectSlug, projects };
115
+ }
116
+ function loadRegistry() {
117
+ return readRegistry() ?? recoverFromDisk();
118
+ }
119
+ function listProjects() {
120
+ return loadRegistry()?.projects ?? [];
121
+ }
122
+ function getActiveProjectSlug() {
123
+ return loadRegistry()?.activeProjectSlug ?? DEFAULT_PROJECT_SLUG;
124
+ }
125
+ function projectExists(slug) {
126
+ return listProjects().some((p) => p.slug === slug);
127
+ }
128
+ function setActiveProjectSlug(slug) {
129
+ const registry = loadRegistry();
130
+ if (!registry) {
131
+ throw new Error(`No registry to update \u2014 create a project first (no ${registryPath()} and no ${projectsDir()}/* to recover from)`);
132
+ }
133
+ if (!registry.projects.some((p) => p.slug === slug)) {
134
+ throw new Error(`Unknown project slug "${slug}"`);
135
+ }
136
+ registry.activeProjectSlug = slug;
137
+ const idx = registry.projects.findIndex((p) => p.slug === slug);
138
+ if (idx !== -1) {
139
+ registry.projects[idx].lastUsedAt = new Date().toISOString();
140
+ }
141
+ writeRegistry(registry);
142
+ }
143
+ function registerProject(opts) {
144
+ const registry = loadRegistry() ?? {
145
+ activeProjectSlug: opts.slug,
146
+ projects: []
147
+ };
148
+ if (registry.projects.some((p) => p.slug === opts.slug)) {
149
+ throw new Error(`Project "${opts.slug}" already exists`);
150
+ }
151
+ const now = new Date().toISOString();
152
+ const entry = {
153
+ slug: opts.slug,
154
+ name: opts.name,
155
+ color: opts.color,
156
+ createdAt: now,
157
+ lastUsedAt: now
158
+ };
159
+ registry.projects.push(entry);
160
+ writeRegistry(registry);
161
+ return entry;
162
+ }
163
+ function renameProject(slug, newName) {
164
+ const registry = loadRegistry();
165
+ if (!registry) {
166
+ throw new Error(`No registry to update \u2014 create a project first`);
167
+ }
168
+ const entry = registry.projects.find((p) => p.slug === slug);
169
+ if (!entry) {
170
+ throw new Error(`Unknown project slug "${slug}"`);
171
+ }
172
+ entry.name = newName;
173
+ writeRegistry(registry);
174
+ }
175
+ function removeProject(slug) {
176
+ const registry = loadRegistry();
177
+ if (!registry)
178
+ return;
179
+ registry.projects = registry.projects.filter((p) => p.slug !== slug);
180
+ if (registry.activeProjectSlug === slug) {
181
+ const survivor = [...registry.projects].sort((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt))[0];
182
+ registry.activeProjectSlug = survivor?.slug ?? DEFAULT_PROJECT_SLUG;
183
+ }
184
+ writeRegistry(registry);
185
+ }
186
+ function isRegistry(value) {
187
+ if (typeof value !== "object" || value === null)
188
+ return false;
189
+ const r = value;
190
+ if (typeof r.activeProjectSlug !== "string")
191
+ return false;
192
+ if (!Array.isArray(r.projects))
193
+ return false;
194
+ return r.projects.every((p) => {
195
+ if (typeof p !== "object" || p === null)
196
+ return false;
197
+ const e = p;
198
+ return typeof e.slug === "string" && typeof e.name === "string" && typeof e.createdAt === "string" && typeof e.lastUsedAt === "string";
199
+ });
200
+ }
201
+ var DEFAULT_PROJECT_SLUG = "default", _registryDir;
202
+ var init_registry = __esm(() => {
203
+ init_paths();
204
+ _registryDir = paths.root;
205
+ });
206
+
207
+ // src/lib/projects/paths.ts
208
+ import { join as join3 } from "path";
209
+ function projectPaths(slug) {
210
+ const root = join3(_getRegistryDir(), "projects", slug);
211
+ return {
212
+ root,
213
+ db: join3(root, "bertrand.db"),
214
+ syncEnv: join3(root, "sync.env"),
215
+ snapshots: join3(root, "snapshots")
216
+ };
217
+ }
218
+ var init_paths2 = __esm(() => {
219
+ init_registry();
220
+ });
221
+
222
+ // src/lib/projects/resolve.ts
223
+ function resolveActiveProject() {
224
+ if (_cached)
225
+ return _cached;
226
+ const envSlug = process.env.BERTRAND_PROJECT;
227
+ let slug;
228
+ if (envSlug && envSlug.trim()) {
229
+ slug = envSlug.trim();
230
+ } else {
231
+ slug = getActiveProjectSlug();
232
+ }
233
+ const entry = listProjects().find((p) => p.slug === slug);
234
+ const name = entry?.name ?? slug;
235
+ _cached = { slug, name, ...projectPaths(slug) };
236
+ return _cached;
237
+ }
238
+ function _resetActiveProjectCache() {
239
+ _cached = null;
240
+ }
241
+ var _cached = null;
242
+ var init_resolve = __esm(() => {
243
+ init_registry();
244
+ init_paths2();
245
+ });
246
+
247
+ // src/lib/lsof.ts
248
+ import { execFileSync } from "child_process";
249
+ function findHolders(path) {
250
+ try {
251
+ const out = execFileSync("lsof", ["-F", "pcn", path], {
252
+ encoding: "utf8",
253
+ stdio: ["ignore", "pipe", "ignore"]
254
+ });
255
+ const holders = [];
256
+ let pid = 0;
257
+ let command = "";
258
+ for (const line of out.split(`
259
+ `)) {
260
+ if (line.startsWith("p"))
261
+ pid = Number(line.slice(1));
262
+ else if (line.startsWith("c"))
263
+ command = line.slice(1);
264
+ else if (line.startsWith("n")) {
265
+ if (pid && pid !== process.pid && command) {
266
+ holders.push({ pid, command });
267
+ }
268
+ }
269
+ }
270
+ return holders;
271
+ } catch {
272
+ return [];
273
+ }
274
+ }
275
+ var init_lsof = () => {};
276
+
277
+ // src/lib/projects/migrate-layout.ts
278
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync as renameSync2 } from "fs";
279
+ import { join as join4 } from "path";
280
+ function migrateLegacyLayout() {
281
+ const root = _getRegistryDir();
282
+ const registryFile = join4(root, "projects.json");
283
+ const projectsDir2 = join4(root, "projects");
284
+ if (existsSync2(registryFile)) {
285
+ return { migrated: false, reason: "already-migrated" };
286
+ }
287
+ if (existsSync2(projectsDir2)) {
288
+ return { migrated: false, reason: "already-migrated" };
289
+ }
290
+ const legacyDb = join4(root, "bertrand.db");
291
+ const legacySyncEnv = join4(root, "sync.env");
292
+ if (!existsSync2(legacyDb) && !existsSync2(legacySyncEnv)) {
293
+ return { migrated: false, reason: "fresh-install" };
294
+ }
295
+ if (existsSync2(legacyDb)) {
296
+ const holders = findHolders(legacyDb);
297
+ if (holders.length > 0) {
298
+ return { migrated: false, reason: "db-held", holders };
299
+ }
300
+ }
301
+ const destPaths = projectPaths(DEFAULT_PROJECT_SLUG);
302
+ mkdirSync2(destPaths.root, { recursive: true });
303
+ const moved = [];
304
+ for (const suffix of ["", "-wal", "-shm"]) {
305
+ const src = legacyDb + suffix;
306
+ if (!existsSync2(src))
307
+ continue;
308
+ const dst = destPaths.db + suffix;
309
+ renameSync2(src, dst);
310
+ moved.push(`bertrand.db${suffix}`);
311
+ }
312
+ if (existsSync2(legacySyncEnv)) {
313
+ renameSync2(legacySyncEnv, destPaths.syncEnv);
314
+ moved.push("sync.env");
315
+ }
316
+ const now = new Date().toISOString();
317
+ writeRegistry({
318
+ activeProjectSlug: DEFAULT_PROJECT_SLUG,
319
+ projects: [
320
+ {
321
+ slug: DEFAULT_PROJECT_SLUG,
322
+ name: "Default",
323
+ createdAt: now,
324
+ lastUsedAt: now
325
+ }
326
+ ]
327
+ });
328
+ _resetActiveProjectCache();
329
+ return { migrated: true, moved };
330
+ }
331
+ var init_migrate_layout = __esm(() => {
332
+ init_lsof();
333
+ init_registry();
334
+ init_paths2();
335
+ init_resolve();
336
+ });
337
+
338
+ // src/sync/config.ts
339
+ import {
340
+ readFileSync as readFileSync2,
341
+ statSync as statSync2,
342
+ writeFileSync as writeFileSync2,
38
343
  chmodSync,
39
- existsSync
344
+ existsSync as existsSync3
40
345
  } from "fs";
41
346
  function parseEnv(contents) {
42
347
  const out = {};
@@ -58,22 +363,26 @@ function parseEnv(contents) {
58
363
  }
59
364
  return out;
60
365
  }
61
- function hasSyncConfig() {
62
- return existsSync(paths.syncEnv);
366
+ function defaultPath() {
367
+ return resolveActiveProject().syncEnv;
63
368
  }
64
- function loadSyncConfig() {
65
- if (!existsSync(paths.syncEnv))
369
+ function hasSyncConfig(syncEnvPath) {
370
+ return existsSync3(syncEnvPath ?? defaultPath());
371
+ }
372
+ function loadSyncConfig(syncEnvPath) {
373
+ const path = syncEnvPath ?? defaultPath();
374
+ if (!existsSync3(path))
66
375
  return null;
67
- const mode = statSync(paths.syncEnv).mode & 511;
376
+ const mode = statSync2(path).mode & 511;
68
377
  if (mode & 63) {
69
- console.warn(`warning: ${paths.syncEnv} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
378
+ console.warn(`warning: ${path} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
70
379
  try {
71
- chmodSync(paths.syncEnv, 384);
380
+ chmodSync(path, 384);
72
381
  } catch (e) {
73
382
  console.warn(`failed to chmod 0600: ${e instanceof Error ? e.message : String(e)}. Fix manually.`);
74
383
  }
75
384
  }
76
- const env = parseEnv(readFileSync(paths.syncEnv, "utf8"));
385
+ const env = parseEnv(readFileSync2(path, "utf8"));
77
386
  if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_KEY || !env.BERTRAND_SYNC_BUCKET || !env.BERTRAND_ENCRYPTION_KEY) {
78
387
  return null;
79
388
  }
@@ -86,7 +395,8 @@ function loadSyncConfig() {
86
395
  clientName: env.BERTRAND_CLIENT_NAME || `bertrand-${process.platform}-${process.pid}`
87
396
  };
88
397
  }
89
- function saveSyncConfig(cfg) {
398
+ function saveSyncConfig(cfg, syncEnvPath) {
399
+ const path = syncEnvPath ?? defaultPath();
90
400
  const lines = [
91
401
  "# bertrand sync configuration",
92
402
  "# Created by `bertrand sync onboard`. chmod 600.",
@@ -105,13 +415,13 @@ function saveSyncConfig(cfg) {
105
415
  `BERTRAND_CLIENT_NAME=${cfg.clientName}`,
106
416
  ""
107
417
  ];
108
- writeFileSync(paths.syncEnv, lines.join(`
418
+ writeFileSync2(path, lines.join(`
109
419
  `), { mode: 384 });
110
- chmodSync(paths.syncEnv, 384);
420
+ chmodSync(path, 384);
111
421
  }
112
422
  var KEYS;
113
423
  var init_config = __esm(() => {
114
- init_paths();
424
+ init_resolve();
115
425
  KEYS = [
116
426
  "SUPABASE_URL",
117
427
  "SUPABASE_SERVICE_KEY",
@@ -123,17 +433,21 @@ var init_config = __esm(() => {
123
433
  });
124
434
 
125
435
  // src/lib/config.ts
126
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
127
- import { join as join2 } from "path";
436
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
437
+ import { join as join5 } from "path";
438
+ function configPath() {
439
+ return join5(_getRegistryDir(), "config.json");
440
+ }
128
441
  function readConfig() {
129
442
  try {
130
- return JSON.parse(readFileSync2(CONFIG_PATH, "utf-8"));
443
+ return JSON.parse(readFileSync3(configPath(), "utf-8"));
131
444
  } catch {
132
445
  return null;
133
446
  }
134
447
  }
135
448
  function writeConfig(config) {
136
- writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2) + `
449
+ mkdirSync3(_getRegistryDir(), { recursive: true });
450
+ writeFileSync3(configPath(), JSON.stringify(config, null, 2) + `
137
451
  `);
138
452
  }
139
453
  function patchConfig(patch) {
@@ -160,10 +474,8 @@ function isPlainObject(v) {
160
474
  function isSyncEnabled() {
161
475
  return readConfig()?.sync?.enabled === true;
162
476
  }
163
- var CONFIG_PATH;
164
477
  var init_config2 = __esm(() => {
165
- init_paths();
166
- CONFIG_PATH = join2(paths.root, "config.json");
478
+ init_registry();
167
479
  });
168
480
 
169
481
  // src/sync/trigger.ts
@@ -191,15 +503,29 @@ var init_trigger = __esm(() => {
191
503
  });
192
504
 
193
505
  // src/cli/router.ts
194
- import { existsSync as existsSync2 } from "fs";
506
+ import { existsSync as existsSync4 } from "fs";
195
507
  function register(name, handler) {
196
508
  commands.set(name, handler);
197
509
  }
198
510
  function alias(from, to) {
199
511
  aliases.set(from, to);
200
512
  }
513
+ function migrateOrAbort() {
514
+ const result = migrateLegacyLayout();
515
+ if (result.migrated) {
516
+ const fileList = result.moved.join(", ");
517
+ console.log(`Migrated to per-project layout (moved: ${fileList}). Active project: ${DEFAULT_PROJECT_SLUG}.`);
518
+ return;
519
+ }
520
+ if (!result.migrated && result.reason === "db-held") {
521
+ const procs = result.holders.map((h) => `${h.command}(${h.pid})`).join(", ");
522
+ console.error(`Cannot migrate to per-project layout: legacy database is held by ${procs}.
523
+ ` + `Close all active bertrand sessions and try again.`);
524
+ process.exit(1);
525
+ }
526
+ }
201
527
  async function autoInitIfFirstRun() {
202
- if (existsSync2(paths.db))
528
+ if (existsSync4(resolveActiveProject().db))
203
529
  return;
204
530
  const init = commands.get("init");
205
531
  if (!init)
@@ -216,6 +542,9 @@ async function autoInitIfFirstRun() {
216
542
  async function route(argv) {
217
543
  const args = argv.slice(2);
218
544
  const command = args[0];
545
+ if (!command || !HOOK_COMMANDS.has(command)) {
546
+ migrateOrAbort();
547
+ }
219
548
  if (!command) {
220
549
  await autoInitIfFirstRun();
221
550
  triggerBackgroundPull();
@@ -252,17 +581,28 @@ Usage:
252
581
  bertrand log <session> View session log
253
582
  bertrand stats <session> Session statistics
254
583
  bertrand archive <name> Archive/unarchive a session
584
+ bertrand project <op> list|create|switch|current|rename|remove (see: bertrand project --help)
255
585
  bertrand update Hook-facing state writer (internal)
256
586
  bertrand serve Start dashboard HTTP server
257
587
  bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
258
588
  `.trim());
259
589
  }
260
- var commands, aliases;
590
+ var commands, aliases, HOOK_COMMANDS;
261
591
  var init_router = __esm(() => {
262
- init_paths();
592
+ init_resolve();
593
+ init_migrate_layout();
594
+ init_registry();
263
595
  init_trigger();
264
596
  commands = new Map;
265
597
  aliases = new Map;
598
+ HOOK_COMMANDS = new Set([
599
+ "update",
600
+ "snapshot",
601
+ "recap-thinking",
602
+ "assistant-message",
603
+ "notify",
604
+ "badge"
605
+ ]);
266
606
  });
267
607
 
268
608
  // src/db/schema.ts
@@ -273,17 +613,17 @@ __export(exports_schema, {
273
613
  sessionStats: () => sessionStats,
274
614
  sessionLabels: () => sessionLabels,
275
615
  labels: () => labels,
276
- groups: () => groups,
277
616
  events: () => events,
278
- conversations: () => conversations
617
+ conversations: () => conversations,
618
+ categories: () => categories
279
619
  });
280
620
  import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
281
621
  import { sql } from "drizzle-orm";
282
- var groups, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
622
+ var categories, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
283
623
  var init_schema = __esm(() => {
284
- groups = sqliteTable("groups", {
624
+ categories = sqliteTable("categories", {
285
625
  id: text("id").primaryKey(),
286
- parentId: text("parent_id").references(() => groups.id, {
626
+ parentId: text("parent_id").references(() => categories.id, {
287
627
  onDelete: "cascade"
288
628
  }),
289
629
  slug: text("slug").notNull(),
@@ -293,8 +633,8 @@ var init_schema = __esm(() => {
293
633
  color: text("color"),
294
634
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
295
635
  }, (t) => [
296
- uniqueIndex("groups_parent_slug").on(t.parentId, t.slug),
297
- index("groups_path").on(t.path)
636
+ uniqueIndex("categories_parent_slug").on(t.parentId, t.slug),
637
+ index("categories_path").on(t.path)
298
638
  ]);
299
639
  labels = sqliteTable("labels", {
300
640
  id: text("id").primaryKey(),
@@ -304,7 +644,7 @@ var init_schema = __esm(() => {
304
644
  });
305
645
  sessions = sqliteTable("sessions", {
306
646
  id: text("id").primaryKey(),
307
- groupId: text("group_id").notNull().references(() => groups.id, { onDelete: "cascade" }),
647
+ categoryId: text("category_id").notNull().references(() => categories.id, { onDelete: "cascade" }),
308
648
  slug: text("slug").notNull(),
309
649
  name: text("name").notNull(),
310
650
  status: text("status", {
@@ -317,7 +657,7 @@ var init_schema = __esm(() => {
317
657
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
318
658
  updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
319
659
  }, (t) => [
320
- uniqueIndex("sessions_group_slug").on(t.groupId, t.slug),
660
+ uniqueIndex("sessions_category_slug").on(t.categoryId, t.slug),
321
661
  index("sessions_status").on(t.status),
322
662
  index("sessions_started").on(t.startedAt)
323
663
  ]);
@@ -383,25 +723,61 @@ var init_schema = __esm(() => {
383
723
  // src/db/client.ts
384
724
  import { Database } from "bun:sqlite";
385
725
  import { drizzle } from "drizzle-orm/bun-sqlite";
386
- import { mkdirSync } from "fs";
726
+ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
727
+ import { mkdirSync as mkdirSync4 } from "fs";
387
728
  import { dirname } from "path";
388
729
  function getDb() {
389
- if (_db)
390
- return _db;
391
- mkdirSync(dirname(paths.db), { recursive: true });
392
- const sqlite = new Database(paths.db);
730
+ if (_testDb)
731
+ return _testDb;
732
+ return openDb(resolveActiveProject().db);
733
+ }
734
+ function getDbForProject(slug) {
735
+ if (_testDb)
736
+ return _testDb;
737
+ return openDb(projectPaths(slug).db);
738
+ }
739
+ function invalidateDbCache(slug) {
740
+ if (!slug) {
741
+ _cache.clear();
742
+ _migrated.clear();
743
+ return;
744
+ }
745
+ const dbPath = projectPaths(slug).db;
746
+ _cache.delete(dbPath);
747
+ _migrated.delete(dbPath);
748
+ }
749
+ function openDb(dbPath) {
750
+ const cached = _cache.get(dbPath);
751
+ if (cached)
752
+ return cached;
753
+ mkdirSync4(dirname(dbPath), { recursive: true });
754
+ const sqlite = new Database(dbPath);
393
755
  sqlite.exec("PRAGMA journal_mode = WAL");
394
756
  sqlite.exec("PRAGMA foreign_keys = ON");
395
757
  sqlite.exec("PRAGMA synchronous = NORMAL");
396
758
  sqlite.exec("PRAGMA cache_size = -8000");
397
759
  sqlite.exec("PRAGMA temp_store = MEMORY");
398
- _db = drizzle(sqlite, { schema: exports_schema });
399
- return _db;
760
+ const db = drizzle(sqlite, { schema: exports_schema });
761
+ if (!_migrated.has(dbPath)) {
762
+ try {
763
+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
764
+ } catch (err) {
765
+ sqlite.close();
766
+ throw err;
767
+ }
768
+ _migrated.add(dbPath);
769
+ }
770
+ _cache.set(dbPath, db);
771
+ return db;
400
772
  }
401
- var _db = null;
773
+ var MIGRATIONS_FOLDER, _cache, _migrated, _testDb = null;
402
774
  var init_client = __esm(() => {
403
- init_paths();
775
+ init_resolve();
776
+ init_paths2();
404
777
  init_schema();
778
+ MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
779
+ _cache = new Map;
780
+ _migrated = new Set;
405
781
  });
406
782
 
407
783
  // src/lib/id.ts
@@ -421,18 +797,18 @@ function createSession(opts) {
421
797
  function getSession(id) {
422
798
  return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
423
799
  }
424
- function getSessionByGroupSlug(groupId, slug) {
425
- return getDb().select().from(sessions).where(and(eq(sessions.groupId, groupId), eq(sessions.slug, slug))).get();
800
+ function getSessionByCategorySlug(categoryId, slug) {
801
+ return getDb().select().from(sessions).where(and(eq(sessions.categoryId, categoryId), eq(sessions.slug, slug))).get();
426
802
  }
427
- function getSessionsByGroup(groupId) {
428
- return getDb().select().from(sessions).where(eq(sessions.groupId, groupId)).all();
803
+ function getSessionsByCategory(categoryId) {
804
+ return getDb().select().from(sessions).where(eq(sessions.categoryId, categoryId)).all();
429
805
  }
430
806
  function getActiveSessions() {
431
- return getDb().select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
807
+ return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
432
808
  }
433
809
  function getAllSessions(opts) {
434
810
  const db = getDb();
435
- const query = db.select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id));
811
+ const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id));
436
812
  if (opts?.excludeArchived) {
437
813
  return query.where(inArray(sessions.status, [
438
814
  "active",
@@ -569,6 +945,16 @@ var init_conversations = __esm(() => {
569
945
 
570
946
  // src/cli/commands/update.ts
571
947
  var exports_update = {};
948
+ __export(exports_update, {
949
+ shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
950
+ });
951
+ function shouldIgnoreStatusFlip(newStatus, sessionPid) {
952
+ if (!newStatus)
953
+ return false;
954
+ if (newStatus !== "active" && newStatus !== "waiting")
955
+ return false;
956
+ return sessionPid === null;
957
+ }
572
958
  var EVENT_STATUS_MAP;
573
959
  var init_update = __esm(() => {
574
960
  init_router();
@@ -619,6 +1005,7 @@ var init_update = __esm(() => {
619
1005
  if (newStatus && newStatus === session.status) {
620
1006
  return;
621
1007
  }
1008
+ const ignoreStatusFlip = shouldIgnoreStatusFlip(newStatus, session.pid);
622
1009
  let meta;
623
1010
  if (metaJson) {
624
1011
  try {
@@ -639,7 +1026,7 @@ var init_update = __esm(() => {
639
1026
  summary: summaryArg || meta?.question || joinedAnswers || undefined,
640
1027
  meta
641
1028
  });
642
- if (newStatus) {
1029
+ if (newStatus && !ignoreStatusFlip) {
643
1030
  updateSessionStatus(sessionId, newStatus);
644
1031
  }
645
1032
  if (event === "session.waiting" && conversationId && meta?.question) {
@@ -652,7 +1039,7 @@ var init_update = __esm(() => {
652
1039
  });
653
1040
 
654
1041
  // src/lib/transcript.ts
655
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1042
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
656
1043
  function getContextWindowSize(model) {
657
1044
  for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
658
1045
  if (model.startsWith(prefix))
@@ -661,9 +1048,9 @@ function getContextWindowSize(model) {
661
1048
  return 200000;
662
1049
  }
663
1050
  function getLatestAssistantTurn(filePath) {
664
- if (!existsSync3(filePath))
1051
+ if (!existsSync5(filePath))
665
1052
  return null;
666
- const text2 = readFileSync3(filePath, "utf-8");
1053
+ const text2 = readFileSync4(filePath, "utf-8");
667
1054
  const lines = text2.split(`
668
1055
  `);
669
1056
  const assistantEntries = [];
@@ -721,9 +1108,9 @@ function getLatestAssistantTurn(filePath) {
721
1108
  };
722
1109
  }
723
1110
  function getContextSnapshot(filePath) {
724
- if (!existsSync3(filePath))
1111
+ if (!existsSync5(filePath))
725
1112
  return null;
726
- const text2 = readFileSync3(filePath, "utf-8");
1113
+ const text2 = readFileSync4(filePath, "utf-8");
727
1114
  const lines = text2.split(`
728
1115
  `);
729
1116
  for (let i = lines.length - 1;i >= 0; i--) {
@@ -990,8 +1377,8 @@ class NoopAdapter {
990
1377
  }
991
1378
 
992
1379
  // src/terminal/index.ts
993
- import { readFileSync as readFileSync4 } from "fs";
994
- import { join as join3 } from "path";
1380
+ import { readFileSync as readFileSync5 } from "fs";
1381
+ import { join as join6 } from "path";
995
1382
  function getTerminalAdapter() {
996
1383
  if (cachedAdapter)
997
1384
  return cachedAdapter;
@@ -1010,7 +1397,7 @@ function getTerminalAdapter() {
1010
1397
  }
1011
1398
  function readConfigTerminal() {
1012
1399
  try {
1013
- const config = JSON.parse(readFileSync4(join3(paths.root, "config.json"), "utf-8"));
1400
+ const config = JSON.parse(readFileSync5(join6(paths.root, "config.json"), "utf-8"));
1014
1401
  return config.terminal ?? null;
1015
1402
  } catch {
1016
1403
  return null;
@@ -1349,7 +1736,7 @@ function archiveAllPaused() {
1349
1736
  const archived = [];
1350
1737
  for (const row of paused) {
1351
1738
  const updated = updateSessionStatus(row.session.id, "archived");
1352
- archived.push({ session: updated, groupPath: row.groupPath });
1739
+ archived.push({ session: updated, categoryPath: row.categoryPath });
1353
1740
  }
1354
1741
  return { archived };
1355
1742
  }
@@ -1361,8 +1748,8 @@ var init_session_archive = __esm(() => {
1361
1748
 
1362
1749
  // src/server/index.ts
1363
1750
  import { execFile } from "child_process";
1364
- import { existsSync as existsSync4 } from "fs";
1365
- import { join as join4 } from "path";
1751
+ import { existsSync as existsSync6 } from "fs";
1752
+ import { join as join7 } from "path";
1366
1753
  function liveStats(sessionId) {
1367
1754
  return {
1368
1755
  sessionId,
@@ -1376,6 +1763,24 @@ function archiveResponse(result) {
1376
1763
  const meta = ARCHIVE_ERROR[result.reason] ?? { status: 400, message: "Operation failed" };
1377
1764
  return Response.json({ error: meta.message, reason: result.reason }, { status: meta.status });
1378
1765
  }
1766
+ async function handleSwitchProject(req) {
1767
+ let body;
1768
+ try {
1769
+ body = await req.json();
1770
+ } catch {
1771
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
1772
+ }
1773
+ const slug = body.slug;
1774
+ if (typeof slug !== "string") {
1775
+ return Response.json({ error: "slug must be a string" }, { status: 400 });
1776
+ }
1777
+ if (!projectExists(slug)) {
1778
+ return Response.json({ error: `Unknown project: ${slug}` }, { status: 404 });
1779
+ }
1780
+ setActiveProjectSlug(slug);
1781
+ queueMicrotask(() => process.exit(0));
1782
+ return Response.json({ ok: true, slug, willRestart: true });
1783
+ }
1379
1784
  async function handleOpen(req) {
1380
1785
  let body;
1381
1786
  try {
@@ -1415,11 +1820,11 @@ function match(pathname, url) {
1415
1820
  }
1416
1821
  function findDashboardDir() {
1417
1822
  const candidates = [
1418
- join4(import.meta.dir, "dashboard"),
1419
- join4(import.meta.dir, "..", "dashboard")
1823
+ join7(import.meta.dir, "dashboard"),
1824
+ join7(import.meta.dir, "..", "dashboard")
1420
1825
  ];
1421
1826
  for (const dir of candidates) {
1422
- if (existsSync4(join4(dir, "index.html")))
1827
+ if (existsSync6(join7(dir, "index.html")))
1423
1828
  return dir;
1424
1829
  }
1425
1830
  return null;
@@ -1428,13 +1833,13 @@ async function serveDashboard(pathname) {
1428
1833
  if (!DASHBOARD_DIR)
1429
1834
  return null;
1430
1835
  const requested = pathname === "/" ? "/index.html" : pathname;
1431
- const filePath = join4(DASHBOARD_DIR, requested);
1836
+ const filePath = join7(DASHBOARD_DIR, requested);
1432
1837
  if (!filePath.startsWith(DASHBOARD_DIR))
1433
1838
  return null;
1434
1839
  const file = Bun.file(filePath);
1435
1840
  if (await file.exists())
1436
1841
  return new Response(file);
1437
- return new Response(Bun.file(join4(DASHBOARD_DIR, "index.html")));
1842
+ return new Response(Bun.file(join7(DASHBOARD_DIR, "index.html")));
1438
1843
  }
1439
1844
  function startServer(port = PORT) {
1440
1845
  const server = Bun.serve({
@@ -1455,6 +1860,11 @@ function startServer(port = PORT) {
1455
1860
  r.headers.set("Access-Control-Allow-Origin", "*");
1456
1861
  return r;
1457
1862
  }
1863
+ if (req.method === "POST" && url.pathname === "/api/active-project") {
1864
+ const r = await handleSwitchProject(req);
1865
+ r.headers.set("Access-Control-Allow-Origin", "*");
1866
+ return r;
1867
+ }
1458
1868
  if (req.method === "POST") {
1459
1869
  const archiveMatch = /^\/api\/sessions\/([^/]+)\/archive$/.exec(url.pathname);
1460
1870
  if (archiveMatch) {
@@ -1517,7 +1927,18 @@ var PORT, listSessions = (_params, url) => {
1517
1927
  return getSessionStats(sessionId) ?? liveStats(sessionId);
1518
1928
  }, getEngagement = ({
1519
1929
  sessionId
1520
- }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1930
+ }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), listAllProjects = () => {
1931
+ const active = resolveActiveProject();
1932
+ return listProjects().map((p) => ({
1933
+ slug: p.slug,
1934
+ name: p.name,
1935
+ active: p.slug === active.slug,
1936
+ lastUsedAt: p.lastUsedAt
1937
+ }));
1938
+ }, getActiveProjectMeta = () => {
1939
+ const active = resolveActiveProject();
1940
+ return { slug: active.slug, name: active.name };
1941
+ }, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1521
1942
  var init_server = __esm(() => {
1522
1943
  init_sessions();
1523
1944
  init_events();
@@ -1525,6 +1946,8 @@ var init_server = __esm(() => {
1525
1946
  init_timing();
1526
1947
  init_engagement_stats();
1527
1948
  init_session_archive();
1949
+ init_registry();
1950
+ init_resolve();
1528
1951
  PORT = Number(process.env.BERTRAND_PORT ?? 5200);
1529
1952
  routes = [
1530
1953
  [/^\/api\/sessions$/, listSessions],
@@ -1533,7 +1956,9 @@ var init_server = __esm(() => {
1533
1956
  [/^\/api\/stats$/, listAllStats],
1534
1957
  [/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
1535
1958
  [/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
1536
- [/^\/api\/recaps$/, listRecaps]
1959
+ [/^\/api\/recaps$/, listRecaps],
1960
+ [/^\/api\/projects$/, listAllProjects],
1961
+ [/^\/api\/active-project$/, getActiveProjectMeta]
1537
1962
  ];
1538
1963
  ARCHIVE_ERROR = {
1539
1964
  "not-found": { status: 404, message: "Session not found" },
@@ -1557,36 +1982,41 @@ var init_serve = __esm(() => {
1557
1982
 
1558
1983
  // src/sync/snapshot.ts
1559
1984
  import { Database as Database2 } from "bun:sqlite";
1560
- import { existsSync as existsSync5, unlinkSync } from "fs";
1985
+ import { existsSync as existsSync7, unlinkSync } from "fs";
1986
+ function snapshotPathFor(dbPath) {
1987
+ return `${dbPath}.sync-snapshot`;
1988
+ }
1561
1989
  function takeSnapshot() {
1562
1990
  cleanupSnapshot();
1563
- const src = new Database2(paths.db, { readonly: true });
1991
+ const dbPath = resolveActiveProject().db;
1992
+ const target = snapshotPathFor(dbPath);
1993
+ const src = new Database2(dbPath, { readonly: true });
1564
1994
  try {
1565
- src.exec(`VACUUM INTO '${SNAPSHOT_PATH.replace(/'/g, "''")}'`);
1995
+ src.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
1566
1996
  } finally {
1567
1997
  src.close();
1568
1998
  }
1569
- return SNAPSHOT_PATH;
1999
+ return target;
1570
2000
  }
1571
2001
  function cleanupSnapshot() {
2002
+ const base = snapshotPathFor(resolveActiveProject().db);
1572
2003
  for (const suffix of SIDECAR_SUFFIXES) {
1573
- const p = SNAPSHOT_PATH + suffix;
1574
- if (existsSync5(p)) {
2004
+ const p = base + suffix;
2005
+ if (existsSync7(p)) {
1575
2006
  try {
1576
2007
  unlinkSync(p);
1577
2008
  } catch {}
1578
2009
  }
1579
2010
  }
1580
2011
  }
1581
- var SIDECAR_SUFFIXES, SNAPSHOT_PATH;
2012
+ var SIDECAR_SUFFIXES;
1582
2013
  var init_snapshot2 = __esm(() => {
1583
- init_paths();
2014
+ init_resolve();
1584
2015
  SIDECAR_SUFFIXES = ["", "-wal", "-shm"];
1585
- SNAPSHOT_PATH = `${paths.db}.sync-snapshot`;
1586
2016
  });
1587
2017
 
1588
2018
  // src/sync/crypto.ts
1589
- import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
2019
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
1590
2020
  function parseKey(base64Key) {
1591
2021
  const buf = Buffer.from(base64Key, "base64");
1592
2022
  if (buf.length !== 32) {
@@ -1595,11 +2025,11 @@ function parseKey(base64Key) {
1595
2025
  return buf;
1596
2026
  }
1597
2027
  function generateKeyBase64() {
1598
- return randomBytes(32).toString("base64");
2028
+ return randomBytes2(32).toString("base64");
1599
2029
  }
1600
2030
  function encrypt(plaintext, base64Key) {
1601
2031
  const key = parseKey(base64Key);
1602
- const iv = randomBytes(IV_LEN);
2032
+ const iv = randomBytes2(IV_LEN);
1603
2033
  const cipher = createCipheriv(ALGO, key, iv);
1604
2034
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
1605
2035
  const tag = cipher.getAuthTag();
@@ -1627,9 +2057,8 @@ var init_crypto = __esm(() => {
1627
2057
 
1628
2058
  // src/sync/engine.ts
1629
2059
  import { createClient } from "@supabase/supabase-js";
1630
- import { readFileSync as readFileSync5, renameSync, statSync as statSync2, openSync, writeSync, fsyncSync, closeSync } from "fs";
2060
+ import { readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
1631
2061
  import { dirname as dirname2 } from "path";
1632
- import { execFileSync } from "child_process";
1633
2062
  function client(cfg) {
1634
2063
  if (!cfg)
1635
2064
  return null;
@@ -1658,7 +2087,7 @@ async function push() {
1658
2087
  error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
1659
2088
  };
1660
2089
  }
1661
- const plaintext = readFileSync5(snapshotPath);
2090
+ const plaintext = readFileSync6(snapshotPath);
1662
2091
  const ciphertext = encrypt(plaintext, cfg.encryptionKey);
1663
2092
  const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
1664
2093
  contentType: "application/octet-stream",
@@ -1688,17 +2117,18 @@ async function pull(opts = {}) {
1688
2117
  if (!cfg || !supabase) {
1689
2118
  return { ok: false, operation: "pull", error: "sync config incomplete" };
1690
2119
  }
1691
- const holders = findHolders(paths.db);
2120
+ const dbPath = resolveActiveProject().db;
2121
+ const holders = findHolders(dbPath);
1692
2122
  if (holders.length > 0) {
1693
2123
  const procs = holders.map((h) => `${h.command}(${h.pid})`).join(", ");
1694
2124
  if (!opts.force) {
1695
2125
  return {
1696
2126
  ok: false,
1697
2127
  operation: "pull",
1698
- error: `${paths.db} is held by ${procs} \u2014 close active bertrand sessions before pulling, ` + `or pass --force to overwrite anyway (risks corrupting the running session).`
2128
+ error: `${dbPath} is held by ${procs} \u2014 close active bertrand sessions before pulling, ` + `or pass --force to overwrite anyway (risks corrupting the running session).`
1699
2129
  };
1700
2130
  }
1701
- console.warn(`warning: --force pulling while ${procs} hold ${paths.db}. The running process may crash on next file access.`);
2131
+ console.warn(`warning: --force pulling while ${procs} hold ${dbPath}. The running process may crash on next file access.`);
1702
2132
  }
1703
2133
  const started = performance.now();
1704
2134
  try {
@@ -1717,7 +2147,7 @@ async function pull(opts = {}) {
1717
2147
  }
1718
2148
  const ciphertext = Buffer.from(await data.arrayBuffer());
1719
2149
  const plaintext = decrypt(ciphertext, cfg.encryptionKey);
1720
- const tmp = `${paths.db}.pull-${process.pid}`;
2150
+ const tmp = `${dbPath}.pull-${process.pid}`;
1721
2151
  const fd = openSync(tmp, "w");
1722
2152
  try {
1723
2153
  writeSync(fd, plaintext);
@@ -1725,8 +2155,8 @@ async function pull(opts = {}) {
1725
2155
  } finally {
1726
2156
  closeSync(fd);
1727
2157
  }
1728
- renameSync(tmp, paths.db);
1729
- const dirFd = openSync(dirname2(paths.db), "r");
2158
+ renameSync3(tmp, dbPath);
2159
+ const dirFd = openSync(dirname2(dbPath), "r");
1730
2160
  try {
1731
2161
  fsyncSync(dirFd);
1732
2162
  } finally {
@@ -1747,9 +2177,10 @@ async function status() {
1747
2177
  const cfg = loadSyncConfig();
1748
2178
  if (!cfg)
1749
2179
  return { configured: false, local: null, remote: null };
2180
+ const dbPath = resolveActiveProject().db;
1750
2181
  let local = null;
1751
2182
  try {
1752
- const s = statSync2(paths.db);
2183
+ const s = statSync3(dbPath);
1753
2184
  local = { size: s.size, modifiedAt: new Date(s.mtimeMs) };
1754
2185
  } catch {
1755
2186
  local = null;
@@ -1774,48 +2205,25 @@ async function status() {
1774
2205
  }
1775
2206
  return { configured: true, local, remote };
1776
2207
  }
1777
- function findHolders(path) {
1778
- try {
1779
- const out = execFileSync("lsof", ["-F", "pcn", path], {
1780
- encoding: "utf8",
1781
- stdio: ["ignore", "pipe", "ignore"]
1782
- });
1783
- const holders = [];
1784
- let pid = 0;
1785
- let command = "";
1786
- for (const line of out.split(`
1787
- `)) {
1788
- if (line.startsWith("p"))
1789
- pid = Number(line.slice(1));
1790
- else if (line.startsWith("c"))
1791
- command = line.slice(1);
1792
- else if (line.startsWith("n")) {
1793
- if (pid && pid !== process.pid && command) {
1794
- holders.push({ pid, command });
1795
- }
1796
- }
1797
- }
1798
- return holders;
1799
- } catch {
1800
- return [];
1801
- }
1802
- }
1803
2208
  var init_engine = __esm(() => {
1804
- init_paths();
2209
+ init_resolve();
2210
+ init_lsof();
1805
2211
  init_config();
1806
2212
  init_snapshot2();
1807
2213
  init_crypto();
1808
2214
  });
1809
2215
 
1810
2216
  // src/sync/invite.ts
1811
- function encodeInvite(cfg) {
2217
+ function encodeInvite(cfg, project) {
1812
2218
  const bundle = {
1813
2219
  v: VERSION,
1814
2220
  url: cfg.supabaseUrl,
1815
2221
  key: cfg.supabaseServiceKey,
1816
2222
  bucket: cfg.bucket,
1817
2223
  obj: cfg.objectKey,
1818
- ek: cfg.encryptionKey
2224
+ ek: cfg.encryptionKey,
2225
+ psl: project.slug,
2226
+ pn: project.name
1819
2227
  };
1820
2228
  return SCHEME + Buffer.from(JSON.stringify(bundle), "utf8").toString("base64url");
1821
2229
  }
@@ -1839,22 +2247,124 @@ function decodeInvite(invite) {
1839
2247
  }
1840
2248
  const bundle = parsed;
1841
2249
  if (bundle.v !== VERSION) {
1842
- throw new Error(`invite version ${String(bundle.v)} is not supported by this bertrand (expected v${VERSION}). ` + `Upgrade or downgrade so both machines run the same version.`);
2250
+ throw new Error(`invite version ${String(bundle.v)} is not supported (expected v${VERSION}). ` + `Both machines must run a per-project-aware bertrand. ` + `Upgrade the source machine and regenerate the invite.`);
1843
2251
  }
1844
- for (const field of ["url", "key", "bucket", "obj", "ek"]) {
2252
+ for (const field of ["url", "key", "bucket", "obj", "ek", "psl", "pn"]) {
1845
2253
  if (!bundle[field] || typeof bundle[field] !== "string") {
1846
2254
  throw new Error(`invite is missing required field: ${field}`);
1847
2255
  }
1848
2256
  }
1849
2257
  return {
1850
- supabaseUrl: bundle.url,
1851
- supabaseServiceKey: bundle.key,
1852
- bucket: bundle.bucket,
1853
- objectKey: bundle.obj,
1854
- encryptionKey: bundle.ek
2258
+ config: {
2259
+ supabaseUrl: bundle.url,
2260
+ supabaseServiceKey: bundle.key,
2261
+ bucket: bundle.bucket,
2262
+ objectKey: bundle.obj,
2263
+ encryptionKey: bundle.ek
2264
+ },
2265
+ project: {
2266
+ slug: bundle.psl,
2267
+ name: bundle.pn
2268
+ }
2269
+ };
2270
+ }
2271
+ var SCHEME = "bertrand-sync://", VERSION = 2;
2272
+
2273
+ // src/db/migrate.ts
2274
+ import { Database as Database3 } from "bun:sqlite";
2275
+ import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
2276
+ import { migrate as migrate2 } from "drizzle-orm/bun-sqlite/migrator";
2277
+ import { mkdirSync as mkdirSync5 } from "fs";
2278
+ import { dirname as dirname3 } from "path";
2279
+ function runMigrations(dbPath) {
2280
+ const target = dbPath ?? resolveActiveProject().db;
2281
+ mkdirSync5(dirname3(target), { recursive: true });
2282
+ const sqlite = new Database3(target);
2283
+ sqlite.exec("PRAGMA journal_mode = WAL");
2284
+ sqlite.exec("PRAGMA foreign_keys = ON");
2285
+ const db = drizzle2(sqlite);
2286
+ migrate2(db, { migrationsFolder: MIGRATIONS_FOLDER2 });
2287
+ sqlite.close();
2288
+ }
2289
+ var MIGRATIONS_FOLDER2;
2290
+ var init_migrate = __esm(() => {
2291
+ init_resolve();
2292
+ MIGRATIONS_FOLDER2 = import.meta.dir + "/migrations";
2293
+ if (false) {}
2294
+ });
2295
+
2296
+ // src/lib/projects/create.ts
2297
+ import { mkdirSync as mkdirSync6 } from "fs";
2298
+ function createProject(opts) {
2299
+ registerProject({ slug: opts.slug, name: opts.name ?? opts.slug });
2300
+ try {
2301
+ const paths2 = projectPaths(opts.slug);
2302
+ mkdirSync6(paths2.root, { recursive: true });
2303
+ runMigrations(paths2.db);
2304
+ } catch (err) {
2305
+ try {
2306
+ removeProject(opts.slug);
2307
+ } catch {}
2308
+ throw err;
2309
+ }
2310
+ }
2311
+ var init_create = __esm(() => {
2312
+ init_migrate();
2313
+ init_registry();
2314
+ init_paths2();
2315
+ });
2316
+
2317
+ // src/sync/bootstrap.ts
2318
+ import { hostname } from "os";
2319
+ async function bootstrapFromInvite(invite) {
2320
+ let decoded;
2321
+ try {
2322
+ decoded = decodeInvite(invite);
2323
+ } catch (e) {
2324
+ return {
2325
+ ok: false,
2326
+ reason: "decode-failed",
2327
+ error: e instanceof Error ? e.message : String(e)
2328
+ };
2329
+ }
2330
+ const { config: cfg, project } = decoded;
2331
+ if (listProjects().some((p) => p.slug === project.slug)) {
2332
+ return {
2333
+ ok: false,
2334
+ reason: "slug-collision",
2335
+ error: `Project "${project.slug}" already exists on this machine. Remove it first (\`bertrand project remove ${project.slug} --purge\`) or rename it before importing.`
2336
+ };
2337
+ }
2338
+ createProject({ slug: project.slug, name: project.name });
2339
+ setActiveProjectSlug(project.slug);
2340
+ _resetActiveProjectCache();
2341
+ resolveActiveProject();
2342
+ saveSyncConfig({ ...cfg, clientName: `bertrand-${hostname()}` });
2343
+ patchConfig({ sync: { enabled: true } });
2344
+ const result = await pull();
2345
+ if (!result.ok) {
2346
+ return {
2347
+ ok: false,
2348
+ reason: "pull-failed",
2349
+ error: result.error
2350
+ };
2351
+ }
2352
+ return {
2353
+ ok: true,
2354
+ project,
2355
+ pulled: result.pulled ?? false,
2356
+ bytes: result.bytes ?? 0,
2357
+ durationMs: result.durationMs ?? 0
1855
2358
  };
1856
2359
  }
1857
- var SCHEME = "bertrand-sync://", VERSION = 1;
2360
+ var init_bootstrap = __esm(() => {
2361
+ init_config();
2362
+ init_engine();
2363
+ init_registry();
2364
+ init_create();
2365
+ init_resolve();
2366
+ init_config2();
2367
+ });
1858
2368
 
1859
2369
  // src/lib/format.ts
1860
2370
  function formatDuration(ms) {
@@ -1912,32 +2422,65 @@ var init_format = __esm(() => {
1912
2422
 
1913
2423
  // src/cli/commands/sync.ts
1914
2424
  var exports_sync = {};
1915
- import { hostname } from "os";
2425
+ import { hostname as hostname2 } from "os";
1916
2426
  function printUsage2() {
1917
2427
  console.log(`
1918
- bertrand sync \u2014 replicate your local DB to Supabase Storage (opt-in)
2428
+ bertrand sync \u2014 replicate a project's local DB to Supabase Storage (opt-in)
1919
2429
 
1920
2430
  Usage:
1921
- bertrand sync onboard Interactive: configure Supabase + bucket + encryption key
1922
- bertrand sync invite Print a paste-able bundle for another machine
1923
- bertrand sync <bundle> One-liner: decode bundle, save config, pull
1924
- bertrand sync push VACUUM \u2192 encrypt \u2192 upload
1925
- bertrand sync pull [--force] Download \u2192 decrypt \u2192 atomic-replace (refuses if active)
1926
- bertrand sync status Local + remote object size and timestamps
1927
- bertrand sync enable Turn auto-triggers on (assumes onboard already ran)
1928
- bertrand sync disable Turn auto-triggers off without deleting credentials
2431
+ bertrand sync onboard [--project <slug>] Interactive setup for a project
2432
+ bertrand sync invite [--project <slug>] Print a paste-able bundle
2433
+ bertrand sync <bundle> Decode bundle, create project, save config, pull
2434
+ bertrand sync push [--project <slug>] VACUUM \u2192 encrypt \u2192 upload
2435
+ bertrand sync pull [--force] [--project <slug>]
2436
+ Download \u2192 decrypt \u2192 atomic-replace
2437
+ bertrand sync status [--project <slug>] Local + remote object size and timestamps
2438
+ bertrand sync enable [--project <slug>] Turn auto-triggers on
2439
+ bertrand sync disable [--project <slug>] Turn auto-triggers off
2440
+
2441
+ All subcommands default to the active project. Pass --project to operate
2442
+ on a different one without switching active.
1929
2443
 
1930
2444
  Cross-machine setup:
1931
2445
  Machine A: bertrand sync invite
1932
2446
  \u2192 bertrand-sync://eyJ\u2026 (transmit via Signal/iMessage/AirDrop \u2014 sensitive)
1933
2447
  Machine B: bertrand sync bertrand-sync://eyJ\u2026
1934
- \u2192 onboards, pulls, ready
2448
+ \u2192 creates the project, onboards, pulls, ready
1935
2449
  `.trim());
1936
2450
  }
1937
2451
  function abort(reason) {
1938
2452
  console.error(`Aborted: ${reason}`);
1939
2453
  process.exit(1);
1940
2454
  }
2455
+ function extractProjectFlag(args) {
2456
+ const rest = [];
2457
+ let slug;
2458
+ for (let i = 0;i < args.length; i++) {
2459
+ const a = args[i];
2460
+ if (a === "--project") {
2461
+ slug = args[i + 1];
2462
+ i++;
2463
+ continue;
2464
+ }
2465
+ if (a.startsWith("--project=")) {
2466
+ slug = a.slice("--project=".length);
2467
+ continue;
2468
+ }
2469
+ rest.push(a);
2470
+ }
2471
+ return { slug, rest };
2472
+ }
2473
+ function applyProjectOverride(slug) {
2474
+ if (!slug)
2475
+ return;
2476
+ const known = listProjects().some((p) => p.slug === slug);
2477
+ if (!known) {
2478
+ abort(`Unknown project: ${slug}. Run \`bertrand project list\` to see registered projects.`);
2479
+ return;
2480
+ }
2481
+ process.env.BERTRAND_PROJECT = slug;
2482
+ _resetActiveProjectCache();
2483
+ }
1941
2484
  function prompt(label, opts = {}) {
1942
2485
  const suffix = opts.default ? ` [${opts.default}]` : "";
1943
2486
  process.stdout.write(`${label}${suffix}: `);
@@ -1962,9 +2505,10 @@ function prompt(label, opts = {}) {
1962
2505
  });
1963
2506
  }
1964
2507
  async function runOnboard() {
2508
+ const active = resolveActiveProject();
1965
2509
  const existing = loadSyncConfig();
1966
2510
  if (existing) {
1967
- console.log(`Existing config at ${paths.syncEnv}:`);
2511
+ console.log(`Existing config for project "${active.slug}" at ${active.syncEnv}:`);
1968
2512
  console.log(` SUPABASE_URL: ${existing.supabaseUrl}`);
1969
2513
  console.log(` SUPABASE_SERVICE_KEY: ${existing.supabaseServiceKey.slice(0, 12)}\u2026`);
1970
2514
  console.log(` BERTRAND_SYNC_BUCKET: ${existing.bucket}`);
@@ -1972,10 +2516,10 @@ async function runOnboard() {
1972
2516
  console.log(` BERTRAND_ENCRYPTION_KEY: (set, redacted)`);
1973
2517
  console.log(` BERTRAND_CLIENT_NAME: ${existing.clientName}`);
1974
2518
  console.log(`
1975
- Delete ${paths.syncEnv} and re-run to start over.`);
2519
+ Delete ${active.syncEnv} and re-run to start over.`);
1976
2520
  return;
1977
2521
  }
1978
- console.log(`Bertrand sync setup \u2014 Supabase Storage
2522
+ console.log(`Bertrand sync setup for project "${active.slug}" (${active.name})
1979
2523
  `);
1980
2524
  console.log(`In the Supabase dashboard (app.supabase.com):`);
1981
2525
  console.log(` 1. Create or pick a project`);
@@ -1985,7 +2529,7 @@ Delete ${paths.syncEnv} and re-run to start over.`);
1985
2529
  console.log(` is below it and warns "keep secret")`);
1986
2530
  console.log(` 4. Storage \u2192 New bucket: name it "bertrand", PRIVATE (uncheck "Public bucket")
1987
2531
  `);
1988
- const projectId = await prompt("Project ID");
2532
+ const projectId = await prompt("Supabase Project ID");
1989
2533
  if (!projectId)
1990
2534
  return abort("no project ID provided");
1991
2535
  if (!/^[a-z0-9]{15,30}$/i.test(projectId)) {
@@ -2000,8 +2544,9 @@ Delete ${paths.syncEnv} and re-run to start over.`);
2000
2544
  return abort("service key must be a JWT starting with eyJ \u2014 did you paste the URL by mistake?");
2001
2545
  }
2002
2546
  const bucket = await prompt("Storage bucket name", { default: "bertrand" });
2003
- const objectKey = await prompt("Object key", { default: "bertrand.db.enc" });
2004
- const defaultName = `bertrand-${hostname()}`;
2547
+ const defaultObject = `projects/${active.slug}/bertrand.db.enc`;
2548
+ const objectKey = await prompt("Object key", { default: defaultObject });
2549
+ const defaultName = `bertrand-${hostname2()}`;
2005
2550
  const clientName = await prompt("Client name", { default: defaultName });
2006
2551
  console.log(`
2007
2552
  Encryption key`);
@@ -2022,7 +2567,7 @@ Encryption key`);
2022
2567
  encryptionKey = generateKeyBase64();
2023
2568
  console.log(`
2024
2569
  Generated a new key. Save this somewhere safe \u2014 you'll need it on every`);
2025
- console.log(`other machine that should be able to pull this DB:
2570
+ console.log(`other machine that should be able to pull this project:
2026
2571
  `);
2027
2572
  console.log(` ${encryptionKey}
2028
2573
  `);
@@ -2031,18 +2576,16 @@ Generated a new key. Save this somewhere safe \u2014 you'll need it on every`);
2031
2576
  }
2032
2577
  saveSyncConfig({ supabaseUrl, supabaseServiceKey, bucket, objectKey, encryptionKey, clientName });
2033
2578
  patchConfig({ sync: { enabled: true } });
2034
- console.log(`Wrote ${paths.syncEnv} (mode 0600). Sync auto-triggers enabled.`);
2579
+ console.log(`Wrote ${active.syncEnv} (mode 0600). Sync auto-triggers enabled.`);
2035
2580
  console.log(`
2036
2581
  Next:`);
2037
- console.log(` bertrand sync push Send your current local DB to Supabase`);
2582
+ console.log(` bertrand sync push Send this project's DB to Supabase`);
2583
+ console.log(` bertrand sync invite Generate a bundle to import on another machine`);
2038
2584
  console.log(` bertrand sync status See sizes and timestamps`);
2039
- console.log(`
2040
- On another machine: paste the same SUPABASE_URL, a fresh SERVICE_KEY (you can reuse),`);
2041
- console.log(`the same bucket and object key, and the SAME encryption key. Then \`bertrand sync pull\`.`);
2042
2585
  }
2043
2586
  async function runPush() {
2044
2587
  if (!hasSyncConfig())
2045
- return abort("no sync config \u2014 run `bertrand sync onboard`");
2588
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
2046
2589
  process.stdout.write("Snapshot \u2192 encrypt \u2192 upload\u2026 ");
2047
2590
  const result = await push();
2048
2591
  if (result.ok) {
@@ -2055,7 +2598,7 @@ async function runPush() {
2055
2598
  }
2056
2599
  async function runPull(args) {
2057
2600
  if (!hasSyncConfig())
2058
- return abort("no sync config \u2014 run `bertrand sync onboard`");
2601
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
2059
2602
  const force = args.includes("--force");
2060
2603
  process.stdout.write("Download \u2192 decrypt \u2192 replace\u2026 ");
2061
2604
  const result = await pull({ force });
@@ -2072,11 +2615,12 @@ async function runPull(args) {
2072
2615
  }
2073
2616
  }
2074
2617
  async function runStatus() {
2618
+ const active = resolveActiveProject();
2075
2619
  if (!hasSyncConfig()) {
2076
- console.log("Sync is not configured. Run `bertrand sync onboard` to set up.");
2620
+ console.log(`Sync is not configured for project "${active.slug}". Run \`bertrand sync onboard\` to set up.`);
2077
2621
  return;
2078
2622
  }
2079
- console.log(`Sync status (${paths.syncEnv}):`);
2623
+ console.log(`Sync status for project "${active.slug}" (${active.syncEnv}):`);
2080
2624
  console.log(` Auto-triggers: ${isSyncEnabled() ? "enabled" : "disabled"}`);
2081
2625
  const s = await status();
2082
2626
  if (s.local) {
@@ -2092,7 +2636,7 @@ async function runStatus() {
2092
2636
  }
2093
2637
  function runEnable() {
2094
2638
  if (!hasSyncConfig()) {
2095
- return abort("no sync config \u2014 run `bertrand sync onboard` first");
2639
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
2096
2640
  }
2097
2641
  patchConfig({ sync: { enabled: true } });
2098
2642
  console.log("Sync auto-triggers enabled. Pull-on-launch and push-on-session-end will fire.");
@@ -2100,44 +2644,39 @@ function runEnable() {
2100
2644
  function runInvite() {
2101
2645
  const cfg = loadSyncConfig();
2102
2646
  if (!cfg) {
2103
- return abort("no sync config \u2014 run `bertrand sync onboard` first");
2647
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
2104
2648
  }
2105
- const invite = encodeInvite(cfg);
2106
- console.log("Sensitive: this bundle contains a Supabase service_role token AND your DB encryption key.");
2649
+ const active = resolveActiveProject();
2650
+ const invite = encodeInvite(cfg, { slug: active.slug, name: active.name });
2651
+ console.log("Sensitive: this bundle contains a Supabase service_role token AND the project's encryption key.");
2107
2652
  console.log(`Transmit only over a secure channel (Signal, iMessage, AirDrop). Treat like an SSH private key.
2653
+ `);
2654
+ console.log(`Project: ${active.slug} (${active.name})
2108
2655
  `);
2109
2656
  console.log(invite);
2110
2657
  console.log(`
2111
2658
  On the other machine:`);
2112
2659
  console.log(` bertrand sync ${invite.slice(0, 32)}\u2026`);
2660
+ console.log(` (creates the project locally, saves config, runs first pull)`);
2113
2661
  }
2114
2662
  async function runBootstrap(invite) {
2115
- if (hasSyncConfig()) {
2116
- return abort(`sync config already exists at ${paths.syncEnv}. ` + `Delete it first if you really want to overwrite: \`rm ${paths.syncEnv}\``);
2117
- }
2118
- let decoded;
2119
- try {
2120
- decoded = decodeInvite(invite);
2121
- } catch (e) {
2122
- return abort(e instanceof Error ? e.message : String(e));
2123
- }
2124
- const clientName = `bertrand-${hostname()}`;
2125
- saveSyncConfig({ ...decoded, clientName });
2126
- patchConfig({ sync: { enabled: true } });
2127
- console.log(`Wrote ${paths.syncEnv} (mode 0600). Auto-triggers enabled.`);
2128
- console.log(`Pulling\u2026`);
2129
- const result = await pull();
2130
- if (result.ok) {
2131
- if (result.pulled) {
2132
- console.log(`\u2713 Done (${formatBytes(result.bytes)} in ${result.durationMs}ms). Run \`bertrand\` to start.`);
2133
- } else {
2134
- console.log(`\u2713 Config saved, but no remote object yet \u2014 nothing to pull.`);
2135
- console.log(` Run \`bertrand sync push\` on the source machine first.`);
2663
+ console.log("Importing project from invite\u2026");
2664
+ const result = await bootstrapFromInvite(invite);
2665
+ if (!result.ok) {
2666
+ if (result.reason === "pull-failed") {
2667
+ console.error(`Pull failed: ${result.error}`);
2668
+ console.error(`(Config is saved, so you can fix and re-run \`bertrand sync pull\`.)`);
2669
+ process.exit(1);
2136
2670
  }
2671
+ return abort(result.error);
2672
+ }
2673
+ const active = resolveActiveProject();
2674
+ console.log(`Created project "${result.project.slug}" (${result.project.name}) and wrote ${active.syncEnv}.`);
2675
+ if (result.pulled) {
2676
+ console.log(`\u2713 Done (${formatBytes(result.bytes)} in ${result.durationMs}ms). Run \`bertrand\` to start.`);
2137
2677
  } else {
2138
- console.error(`Pull failed: ${result.error}`);
2139
- console.error(`(Config is saved, so you can fix and re-run \`bertrand sync pull\`.)`);
2140
- process.exit(1);
2678
+ console.log(`\u2713 Config saved, but no remote object yet \u2014 nothing to pull.`);
2679
+ console.log(` Run \`bertrand sync push\` on the source machine first.`);
2141
2680
  }
2142
2681
  }
2143
2682
  function runDisable() {
@@ -2157,7 +2696,9 @@ var init_sync = __esm(() => {
2157
2696
  init_config();
2158
2697
  init_engine();
2159
2698
  init_crypto();
2160
- init_paths();
2699
+ init_bootstrap();
2700
+ init_registry();
2701
+ init_resolve();
2161
2702
  init_format();
2162
2703
  init_config2();
2163
2704
  register("sync", async (args) => {
@@ -2166,12 +2707,14 @@ var init_sync = __esm(() => {
2166
2707
  await runBootstrap(sub);
2167
2708
  return;
2168
2709
  }
2710
+ const { slug: projectOverride, rest } = extractProjectFlag(args.slice(1));
2711
+ applyProjectOverride(projectOverride);
2169
2712
  switch (sub) {
2170
2713
  case "push":
2171
2714
  await runPush();
2172
2715
  return;
2173
2716
  case "pull":
2174
- await runPull(args.slice(1));
2717
+ await runPull(rest);
2175
2718
  return;
2176
2719
  case "status":
2177
2720
  await runStatus();
@@ -2201,48 +2744,48 @@ var init_sync = __esm(() => {
2201
2744
  });
2202
2745
  });
2203
2746
 
2204
- // src/db/queries/groups.ts
2747
+ // src/db/queries/categories.ts
2205
2748
  import { eq as eq6, like, or, isNull } from "drizzle-orm";
2206
- function createGroup(opts) {
2749
+ function createCategory(opts) {
2207
2750
  const db = getDb();
2208
2751
  const id = createId();
2209
2752
  let path = opts.slug;
2210
2753
  let depth = 0;
2211
2754
  if (opts.parentId) {
2212
- const parent = db.select().from(groups).where(eq6(groups.id, opts.parentId)).get();
2755
+ const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
2213
2756
  if (!parent)
2214
- throw new Error(`Parent group ${opts.parentId} not found`);
2757
+ throw new Error(`Parent category ${opts.parentId} not found`);
2215
2758
  path = `${parent.path}/${opts.slug}`;
2216
2759
  depth = parent.depth + 1;
2217
2760
  }
2218
- return db.insert(groups).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
2761
+ return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
2219
2762
  }
2220
- function getGroup(id) {
2221
- return getDb().select().from(groups).where(eq6(groups.id, id)).get();
2763
+ function getCategory(id) {
2764
+ return getDb().select().from(categories).where(eq6(categories.id, id)).get();
2222
2765
  }
2223
- function getGroupByPath(path) {
2224
- return getDb().select().from(groups).where(eq6(groups.path, path)).get();
2766
+ function getCategoryByPath(path) {
2767
+ return getDb().select().from(categories).where(eq6(categories.path, path)).get();
2225
2768
  }
2226
- function getOrCreateGroupPath(path) {
2227
- const existing = getGroupByPath(path);
2769
+ function getOrCreateCategoryPath(path) {
2770
+ const existing = getCategoryByPath(path);
2228
2771
  if (existing)
2229
2772
  return existing.id;
2230
2773
  const segments = path.split("/");
2231
2774
  let parentId;
2232
2775
  for (let i = 0;i < segments.length; i++) {
2233
2776
  const partialPath = segments.slice(0, i + 1).join("/");
2234
- const group = getGroupByPath(partialPath);
2235
- if (group) {
2236
- parentId = group.id;
2777
+ const category = getCategoryByPath(partialPath);
2778
+ if (category) {
2779
+ parentId = category.id;
2237
2780
  } else {
2238
2781
  const slug = segments[i];
2239
- const created = createGroup({ slug, name: slug, parentId });
2782
+ const created = createCategory({ slug, name: slug, parentId });
2240
2783
  parentId = created.id;
2241
2784
  }
2242
2785
  }
2243
2786
  return parentId;
2244
2787
  }
2245
- var init_groups = __esm(() => {
2788
+ var init_categories = __esm(() => {
2246
2789
  init_client();
2247
2790
  init_schema();
2248
2791
  init_id();
@@ -2299,19 +2842,19 @@ var init_template2 = __esm(() => {
2299
2842
  });
2300
2843
 
2301
2844
  // src/contract/context.ts
2302
- function buildSiblingContext(groupId, groupPath, currentSessionId) {
2303
- const siblings = getSessionsByGroup(groupId).filter((s) => s.id !== currentSessionId);
2845
+ function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
2846
+ const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
2304
2847
  if (siblings.length === 0)
2305
2848
  return "";
2306
2849
  const lines = siblings.map((s) => {
2307
2850
  const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
2308
2851
  const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
2309
- return `- ${groupPath}/${s.slug}: ${s.status}${summary} (${ago})`;
2852
+ return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
2310
2853
  });
2311
2854
  const guidance = [
2312
2855
  "",
2313
2856
  "To inspect any sibling session's full record, run:",
2314
- " bertrand log <group>/<slug> --json",
2857
+ " bertrand log <category>/<slug> --json",
2315
2858
  "Returns session metadata, stats, conversations, and the full event timeline.",
2316
2859
  "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
2317
2860
  ].join(`
@@ -2336,13 +2879,16 @@ function launchClaude(opts) {
2336
2879
  args.push("--session-id", opts.claudeId);
2337
2880
  }
2338
2881
  args.push("--append-system-prompt", opts.contract);
2882
+ const active = resolveActiveProject();
2339
2883
  const env = {
2340
2884
  ...process.env,
2341
2885
  BERTRAND_PID: String(process.pid),
2342
2886
  BERTRAND_CLAUDE_ID: opts.claudeId,
2343
2887
  BERTRAND_SESSION: opts.sessionId,
2344
2888
  BERTRAND_SESSION_NAME: opts.sessionName,
2345
- BERTRAND_SESSION_SLUG: opts.sessionSlug
2889
+ BERTRAND_SESSION_SLUG: opts.sessionSlug,
2890
+ BERTRAND_PROJECT: active.slug,
2891
+ BERTRAND_PROJECT_DB: active.db
2346
2892
  };
2347
2893
  return new Promise((resolve, reject) => {
2348
2894
  const child = spawn2("claude", args, {
@@ -2372,8 +2918,13 @@ function launchClaude(opts) {
2372
2918
  });
2373
2919
  });
2374
2920
  }
2921
+ function isClaudeRunning() {
2922
+ return activeChild !== null;
2923
+ }
2375
2924
  var activeChild = null;
2376
- var init_process = () => {};
2925
+ var init_process = __esm(() => {
2926
+ init_resolve();
2927
+ });
2377
2928
 
2378
2929
  // src/engine/spawn-context.ts
2379
2930
  import { execFile as execFile2 } from "child_process";
@@ -2444,11 +2995,11 @@ var init_spawn_context = __esm(() => {
2444
2995
 
2445
2996
  // src/lib/server-lifecycle.ts
2446
2997
  import { spawn as spawn3 } from "child_process";
2447
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2 } from "fs";
2448
- import { join as join5 } from "path";
2998
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
2999
+ import { join as join8 } from "path";
2449
3000
  function readPidFile() {
2450
3001
  try {
2451
- const pid = Number(readFileSync6(deps.pidFile, "utf-8").trim());
3002
+ const pid = Number(readFileSync7(deps.pidFile, "utf-8").trim());
2452
3003
  return Number.isFinite(pid) && pid > 0 ? pid : null;
2453
3004
  } catch {
2454
3005
  return null;
@@ -2495,7 +3046,7 @@ async function ensureServerStarted() {
2495
3046
  });
2496
3047
  child.unref();
2497
3048
  if (child.pid)
2498
- writeFileSync3(deps.pidFile, String(child.pid));
3049
+ writeFileSync4(deps.pidFile, String(child.pid));
2499
3050
  }
2500
3051
  function stopServerIfIdle() {
2501
3052
  if (deps.getActiveCount() > 0)
@@ -2513,11 +3064,11 @@ var init_server_lifecycle = __esm(() => {
2513
3064
  init_paths();
2514
3065
  init_sessions();
2515
3066
  defaultDeps = {
2516
- pidFile: join5(paths.root, "server.pid"),
3067
+ pidFile: join8(paths.root, "server.pid"),
2517
3068
  port: Number(process.env.BERTRAND_PORT ?? 5200),
2518
3069
  resolveBin() {
2519
3070
  try {
2520
- const config = JSON.parse(readFileSync6(join5(paths.root, "config.json"), "utf-8"));
3071
+ const config = JSON.parse(readFileSync7(join8(paths.root, "config.json"), "utf-8"));
2521
3072
  return typeof config?.bin === "string" ? config.bin : null;
2522
3073
  } catch {
2523
3074
  return null;
@@ -2530,17 +3081,52 @@ var init_server_lifecycle = __esm(() => {
2530
3081
 
2531
3082
  // src/engine/session.ts
2532
3083
  import { randomUUID } from "crypto";
3084
+ function forceFinalizeLive() {
3085
+ if (!liveSession)
3086
+ return;
3087
+ const session = getSession(liveSession.sessionId);
3088
+ if (!session) {
3089
+ liveSession = null;
3090
+ return;
3091
+ }
3092
+ if (session.status !== "active" && session.status !== "waiting") {
3093
+ liveSession = null;
3094
+ return;
3095
+ }
3096
+ try {
3097
+ updateSession(liveSession.sessionId, {
3098
+ status: "paused",
3099
+ pid: null,
3100
+ endedAt: new Date().toISOString()
3101
+ });
3102
+ } catch {}
3103
+ liveSession = null;
3104
+ }
3105
+ function installExitHandlers() {
3106
+ if (exitHandlersInstalled)
3107
+ return;
3108
+ exitHandlersInstalled = true;
3109
+ process.on("exit", forceFinalizeLive);
3110
+ const onSignal = (signal) => {
3111
+ if (isClaudeRunning())
3112
+ return;
3113
+ process.exit(signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143);
3114
+ };
3115
+ process.on("SIGINT", onSignal);
3116
+ process.on("SIGTERM", onSignal);
3117
+ process.on("SIGHUP", onSignal);
3118
+ }
2533
3119
  async function launch(opts) {
2534
- const existingGroup = getGroupByPath(opts.groupPath);
2535
- if (existingGroup) {
2536
- const existing = getSessionByGroupSlug(existingGroup.id, opts.slug);
3120
+ const existingCategory = getCategoryByPath(opts.categoryPath);
3121
+ if (existingCategory) {
3122
+ const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
2537
3123
  if (existing) {
2538
- throw new Error(`Session "${opts.slug}" already exists in group "${opts.groupPath}"`);
3124
+ throw new Error(`Session "${opts.slug}" already exists in category "${opts.categoryPath}"`);
2539
3125
  }
2540
3126
  }
2541
- const groupId = getOrCreateGroupPath(opts.groupPath);
3127
+ const categoryId = getOrCreateCategoryPath(opts.categoryPath);
2542
3128
  const session = createSession({
2543
- groupId,
3129
+ categoryId,
2544
3130
  slug: opts.slug,
2545
3131
  name: opts.name ?? opts.slug
2546
3132
  });
@@ -2554,15 +3140,17 @@ async function launch(opts) {
2554
3140
  sessionId: session.id
2555
3141
  });
2556
3142
  updateSession(session.id, { status: "active", pid: process.pid });
3143
+ liveSession = { sessionId: session.id, claudeId };
3144
+ installExitHandlers();
2557
3145
  await ensureServerStarted();
2558
3146
  const spawnContext = await captureSpawnContext();
2559
- const sessionName = `${opts.groupPath}/${opts.slug}`;
3147
+ const sessionName = `${opts.categoryPath}/${opts.slug}`;
2560
3148
  insertEvent({
2561
3149
  sessionId: session.id,
2562
3150
  conversationId: claudeId,
2563
3151
  event: "session.started",
2564
3152
  meta: {
2565
- group_path: opts.groupPath,
3153
+ category_path: opts.categoryPath,
2566
3154
  session_name: opts.name ?? opts.slug,
2567
3155
  session_slug: opts.slug,
2568
3156
  labels: opts.labelNames ?? [],
@@ -2581,7 +3169,7 @@ async function launch(opts) {
2581
3169
  cwd: spawnContext.cwd
2582
3170
  }
2583
3171
  });
2584
- const siblingContext = buildSiblingContext(groupId, opts.groupPath, session.id);
3172
+ const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
2585
3173
  const contract = buildContract(sessionName, siblingContext);
2586
3174
  const exitCode = await launchClaude({
2587
3175
  sessionId: session.id,
@@ -2597,9 +3185,11 @@ async function resume(opts) {
2597
3185
  const session = getSession(opts.sessionId);
2598
3186
  if (!session)
2599
3187
  throw new Error(`Session not found: ${opts.sessionId}`);
2600
- const group = getGroup(session.groupId);
2601
- const sessionName = group ? `${group.path}/${session.slug}` : session.name;
3188
+ const category = getCategory(session.categoryId);
3189
+ const sessionName = category ? `${category.path}/${session.slug}` : session.name;
2602
3190
  updateSession(session.id, { status: "active", pid: process.pid });
3191
+ liveSession = { sessionId: session.id, claudeId: opts.conversationId };
3192
+ installExitHandlers();
2603
3193
  await ensureServerStarted();
2604
3194
  insertEvent({
2605
3195
  sessionId: session.id,
@@ -2607,8 +3197,8 @@ async function resume(opts) {
2607
3197
  event: "session.resumed",
2608
3198
  meta: { claude_id: opts.conversationId }
2609
3199
  });
2610
- const groupPath = group?.path ?? "";
2611
- const siblingContext = buildSiblingContext(session.groupId, groupPath, session.id);
3200
+ const categoryPath = category?.path ?? "";
3201
+ const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
2612
3202
  const contract = buildContract(sessionName, siblingContext);
2613
3203
  const exitCode = await launchClaude({
2614
3204
  sessionId: session.id,
@@ -2640,6 +3230,8 @@ function finalizeSession(sessionId, conversationId, exitCode) {
2640
3230
  pid: null,
2641
3231
  endedAt: new Date().toISOString()
2642
3232
  });
3233
+ if (liveSession?.sessionId === sessionId)
3234
+ liveSession = null;
2643
3235
  insertEvent({
2644
3236
  sessionId,
2645
3237
  event: "session.end"
@@ -2647,11 +3239,12 @@ function finalizeSession(sessionId, conversationId, exitCode) {
2647
3239
  computeAndPersist(sessionId);
2648
3240
  stopServerIfIdle();
2649
3241
  }
3242
+ var liveSession = null, exitHandlersInstalled = false;
2650
3243
  var init_session = __esm(() => {
2651
3244
  init_sessions();
2652
3245
  init_conversations();
2653
3246
  init_events();
2654
- init_groups();
3247
+ init_categories();
2655
3248
  init_labels();
2656
3249
  init_template2();
2657
3250
  init_context();
@@ -2663,8 +3256,8 @@ var init_session = __esm(() => {
2663
3256
 
2664
3257
  // src/tui/app.tsx
2665
3258
  import { spawn as spawn4 } from "child_process";
2666
- import { existsSync as existsSync6, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
2667
- import { join as join6 } from "path";
3259
+ import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
3260
+ import { join as join9 } from "path";
2668
3261
  import { randomUUID as randomUUID2 } from "crypto";
2669
3262
  async function runScreen(screen, ...args) {
2670
3263
  const tmpFile = `/tmp/bertrand-tui-${process.pid}-${Date.now()}.json`;
@@ -2677,13 +3270,16 @@ async function runScreen(screen, ...args) {
2677
3270
  if (exitCode !== 0) {
2678
3271
  throw new Error(`TUI screen "${screen}" exited with code ${exitCode}`);
2679
3272
  }
2680
- const result = JSON.parse(readFileSync7(tmpFile, "utf-8"));
3273
+ const result = JSON.parse(readFileSync8(tmpFile, "utf-8"));
2681
3274
  unlinkSync3(tmpFile);
2682
3275
  return result;
2683
3276
  }
2684
3277
  async function startLaunchTui() {
2685
3278
  return runScreen("launch");
2686
3279
  }
3280
+ async function startProjectPickerTui() {
3281
+ return runScreen("project-picker");
3282
+ }
2687
3283
  async function startExitTui(sessionId) {
2688
3284
  return runScreen("exit", sessionId);
2689
3285
  }
@@ -2732,26 +3328,58 @@ async function runSessionLoop(sessionId) {
2732
3328
  }
2733
3329
  }
2734
3330
  }
2735
- async function startTui() {
3331
+ function shouldShowProjectPicker() {
3332
+ if (process.env.BERTRAND_PROJECT)
3333
+ return false;
3334
+ const projects = listProjects();
3335
+ return projects.length !== 1;
3336
+ }
3337
+ function activateProject(slug) {
3338
+ setActiveProjectSlug(slug);
3339
+ _resetActiveProjectCache();
3340
+ }
3341
+ async function runLaunchCycle() {
2736
3342
  const selection = await startLaunchTui();
2737
3343
  switch (selection.type) {
2738
3344
  case "quit":
2739
- break;
3345
+ return;
2740
3346
  case "create": {
2741
3347
  const sessionId = await launch(selection);
2742
3348
  await runSessionLoop(sessionId);
2743
- break;
3349
+ return;
2744
3350
  }
2745
3351
  case "pick": {
2746
3352
  const conversationId = await resolveConversationForResume(selection.sessionId);
2747
3353
  if (!conversationId)
2748
- break;
3354
+ return;
2749
3355
  const sessionId = await resume({
2750
3356
  sessionId: selection.sessionId,
2751
3357
  conversationId
2752
3358
  });
2753
3359
  await runSessionLoop(sessionId);
2754
- break;
3360
+ return;
3361
+ }
3362
+ }
3363
+ }
3364
+ async function startTui() {
3365
+ if (!shouldShowProjectPicker()) {
3366
+ await runLaunchCycle();
3367
+ return;
3368
+ }
3369
+ const projectSelection = await startProjectPickerTui();
3370
+ switch (projectSelection.type) {
3371
+ case "quit":
3372
+ return;
3373
+ case "select": {
3374
+ activateProject(projectSelection.slug);
3375
+ await runLaunchCycle();
3376
+ return;
3377
+ }
3378
+ case "create": {
3379
+ createProject({ slug: projectSelection.slug });
3380
+ activateProject(projectSelection.slug);
3381
+ await runLaunchCycle();
3382
+ return;
2755
3383
  }
2756
3384
  }
2757
3385
  }
@@ -2761,9 +3389,12 @@ var init_app = __esm(() => {
2761
3389
  init_conversations();
2762
3390
  init_session_archive();
2763
3391
  init_session();
3392
+ init_registry();
3393
+ init_create();
3394
+ init_resolve();
2764
3395
  SCREEN_ENTRY = (() => {
2765
- const built = join6(import.meta.dir, "run-screen.js");
2766
- return existsSync6(built) ? built : join6(import.meta.dir, "run-screen.tsx");
3396
+ const built = join9(import.meta.dir, "run-screen.js");
3397
+ return existsSync8(built) ? built : join9(import.meta.dir, "run-screen.tsx");
2767
3398
  })();
2768
3399
  });
2769
3400
 
@@ -2775,7 +3406,7 @@ function parseSessionName(input) {
2775
3406
  }
2776
3407
  const segments = trimmed.split("/").filter(Boolean);
2777
3408
  if (segments.length < 2) {
2778
- throw new Error(`Session name must include at least one group: "group/session" (got "${trimmed}")`);
3409
+ throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
2779
3410
  }
2780
3411
  for (const segment of segments) {
2781
3412
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {
@@ -2783,8 +3414,8 @@ function parseSessionName(input) {
2783
3414
  }
2784
3415
  }
2785
3416
  const slug = segments[segments.length - 1];
2786
- const groupPath = segments.slice(0, -1).join("/");
2787
- return { groupPath, slug };
3417
+ const categoryPath = segments.slice(0, -1).join("/");
3418
+ return { categoryPath, slug };
2788
3419
  }
2789
3420
 
2790
3421
  // src/engine/recovery.ts
@@ -2832,8 +3463,8 @@ var init_launch = __esm(() => {
2832
3463
  recoverStaleSessions();
2833
3464
  const sessionName = args[0];
2834
3465
  if (sessionName) {
2835
- const { groupPath, slug } = parseSessionName(sessionName);
2836
- const sessionId = await launch({ groupPath, slug });
3466
+ const { categoryPath, slug } = parseSessionName(sessionName);
3467
+ const sessionId = await launch({ categoryPath, slug });
2837
3468
  await runSessionLoop(sessionId);
2838
3469
  return;
2839
3470
  }
@@ -2841,28 +3472,6 @@ var init_launch = __esm(() => {
2841
3472
  });
2842
3473
  });
2843
3474
 
2844
- // src/db/migrate.ts
2845
- import { Database as Database3 } from "bun:sqlite";
2846
- import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
2847
- import { migrate } from "drizzle-orm/bun-sqlite/migrator";
2848
- import { mkdirSync as mkdirSync2 } from "fs";
2849
- import { dirname as dirname3 } from "path";
2850
- function runMigrations() {
2851
- mkdirSync2(dirname3(paths.db), { recursive: true });
2852
- const sqlite = new Database3(paths.db);
2853
- sqlite.exec("PRAGMA journal_mode = WAL");
2854
- sqlite.exec("PRAGMA foreign_keys = ON");
2855
- const db = drizzle2(sqlite);
2856
- migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
2857
- sqlite.close();
2858
- }
2859
- var MIGRATIONS_FOLDER;
2860
- var init_migrate = __esm(() => {
2861
- init_paths();
2862
- MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
2863
- if (false) {}
2864
- });
2865
-
2866
3475
  // src/hooks/scripts.ts
2867
3476
  function waitingScript(bin) {
2868
3477
  const BIN = bin;
@@ -3136,13 +3745,13 @@ var init_scripts = __esm(() => {
3136
3745
  });
3137
3746
 
3138
3747
  // src/hooks/install.ts
3139
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, chmodSync as chmodSync2 } from "fs";
3140
- import { join as join7 } from "path";
3748
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
3749
+ import { join as join10 } from "path";
3141
3750
  function installHookScripts(bin) {
3142
- mkdirSync3(paths.hooks, { recursive: true });
3751
+ mkdirSync7(paths.hooks, { recursive: true });
3143
3752
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
3144
- const filePath = join7(paths.hooks, filename);
3145
- writeFileSync4(filePath, scriptFn(bin));
3753
+ const filePath = join10(paths.hooks, filename);
3754
+ writeFileSync5(filePath, scriptFn(bin));
3146
3755
  chmodSync2(filePath, 493);
3147
3756
  }
3148
3757
  console.log(`Installed ${Object.keys(HOOK_SCRIPTS).length} hook scripts to ${paths.hooks}`);
@@ -3153,8 +3762,8 @@ var init_install = __esm(() => {
3153
3762
  });
3154
3763
 
3155
3764
  // src/hooks/settings.ts
3156
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4 } from "fs";
3157
- import { join as join8, dirname as dirname4 } from "path";
3765
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
3766
+ import { join as join11, dirname as dirname4 } from "path";
3158
3767
  import { homedir as homedir2 } from "os";
3159
3768
  function isBertrandGroup(group) {
3160
3769
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -3162,7 +3771,7 @@ function isBertrandGroup(group) {
3162
3771
  function installHookSettings() {
3163
3772
  let settings = {};
3164
3773
  try {
3165
- settings = JSON.parse(readFileSync8(SETTINGS_PATH, "utf-8"));
3774
+ settings = JSON.parse(readFileSync9(SETTINGS_PATH, "utf-8"));
3166
3775
  } catch {}
3167
3776
  const existingHooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
3168
3777
  const merged = { ...existingHooks };
@@ -3171,15 +3780,15 @@ function installHookSettings() {
3171
3780
  merged[eventType] = [...existing, ...bertrandGroups];
3172
3781
  }
3173
3782
  settings.hooks = merged;
3174
- mkdirSync4(dirname4(SETTINGS_PATH), { recursive: true });
3175
- writeFileSync5(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
3783
+ mkdirSync8(dirname4(SETTINGS_PATH), { recursive: true });
3784
+ writeFileSync6(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
3176
3785
  `);
3177
3786
  console.log(`Updated ${SETTINGS_PATH} with bertrand hooks`);
3178
3787
  }
3179
3788
  var SETTINGS_PATH, BERTRAND_HOOKS;
3180
3789
  var init_settings = __esm(() => {
3181
3790
  init_paths();
3182
- SETTINGS_PATH = join8(homedir2(), ".claude", "settings.json");
3791
+ SETTINGS_PATH = join11(homedir2(), ".claude", "settings.json");
3183
3792
  BERTRAND_HOOKS = {
3184
3793
  PreToolUse: [
3185
3794
  {
@@ -3223,8 +3832,8 @@ var init_settings = __esm(() => {
3223
3832
  });
3224
3833
 
3225
3834
  // src/lib/completions.ts
3226
- import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5 } from "fs";
3227
- import { join as join9 } from "path";
3835
+ import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
3836
+ import { join as join12 } from "path";
3228
3837
  function bashCompletion() {
3229
3838
  return `# bertrand bash completion
3230
3839
  _bertrand() {
@@ -3254,11 +3863,11 @@ function fishCompletion() {
3254
3863
  `;
3255
3864
  }
3256
3865
  function generateCompletions() {
3257
- const dir = join9(paths.root, "completions");
3258
- mkdirSync5(dir, { recursive: true });
3259
- writeFileSync6(join9(dir, "bertrand.bash"), bashCompletion());
3260
- writeFileSync6(join9(dir, "_bertrand"), zshCompletion());
3261
- writeFileSync6(join9(dir, "bertrand.fish"), fishCompletion());
3866
+ const dir = join12(paths.root, "completions");
3867
+ mkdirSync9(dir, { recursive: true });
3868
+ writeFileSync7(join12(dir, "bertrand.bash"), bashCompletion());
3869
+ writeFileSync7(join12(dir, "_bertrand"), zshCompletion());
3870
+ writeFileSync7(join12(dir, "bertrand.fish"), fishCompletion());
3262
3871
  console.log(`Shell completions written to ${dir}`);
3263
3872
  console.log(" Add to your shell config:");
3264
3873
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -3274,8 +3883,10 @@ var init_completions = __esm(() => {
3274
3883
  "log",
3275
3884
  "stats",
3276
3885
  "archive",
3886
+ "project",
3277
3887
  "update",
3278
3888
  "serve",
3889
+ "sync",
3279
3890
  "import",
3280
3891
  "completion",
3281
3892
  "badge",
@@ -3285,9 +3896,9 @@ var init_completions = __esm(() => {
3285
3896
 
3286
3897
  // src/cli/commands/init.ts
3287
3898
  var exports_init = {};
3288
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync7, chmodSync as chmodSync3 } from "fs";
3899
+ import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
3289
3900
  import { execSync as execSync2 } from "child_process";
3290
- import { join as join10 } from "path";
3901
+ import { join as join13 } from "path";
3291
3902
  function detectTerminal() {
3292
3903
  try {
3293
3904
  execSync2("which wsh", { stdio: "ignore" });
@@ -3302,10 +3913,10 @@ function resolveBin() {
3302
3913
  return onPath;
3303
3914
  const entry = process.argv[1];
3304
3915
  if (entry && SOURCE_ENTRY.test(entry)) {
3305
- const launcherDir = join10(paths.root, "bin");
3306
- const launcher = join10(launcherDir, "bertrand-dev");
3307
- mkdirSync6(launcherDir, { recursive: true });
3308
- writeFileSync7(launcher, `#!/usr/bin/env bash
3916
+ const launcherDir = join13(paths.root, "bin");
3917
+ const launcher = join13(launcherDir, "bertrand-dev");
3918
+ mkdirSync10(launcherDir, { recursive: true });
3919
+ writeFileSync8(launcher, `#!/usr/bin/env bash
3309
3920
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
3310
3921
  `);
3311
3922
  chmodSync3(launcher, 493);
@@ -3320,16 +3931,18 @@ var init_init = __esm(() => {
3320
3931
  init_install();
3321
3932
  init_settings();
3322
3933
  init_paths();
3934
+ init_resolve();
3323
3935
  init_completions();
3324
3936
  init_config2();
3325
3937
  SOURCE_ENTRY = /\/src\/index\.tsx?$/;
3326
3938
  register("init", async () => {
3327
3939
  console.log(`bertrand init
3328
3940
  `);
3329
- mkdirSync6(paths.root, { recursive: true });
3330
- mkdirSync6(paths.hooks, { recursive: true });
3331
- runMigrations();
3332
- console.log(` Database: ${paths.db}`);
3941
+ mkdirSync10(paths.root, { recursive: true });
3942
+ mkdirSync10(paths.hooks, { recursive: true });
3943
+ const active = resolveActiveProject();
3944
+ runMigrations(active.db);
3945
+ console.log(` Database: ${active.db}`);
3333
3946
  const bin = resolveBin();
3334
3947
  if (!bin) {
3335
3948
  console.error(`
@@ -3363,7 +3976,7 @@ function buildRows(sessions2) {
3363
3976
  return sessions2.sort((a, b) => new Date(b.session.updatedAt).getTime() - new Date(a.session.updatedAt).getTime()).map((row) => {
3364
3977
  const stats = getSessionStats(row.session.id);
3365
3978
  return {
3366
- name: `${row.groupPath}/${row.session.slug}`,
3979
+ name: `${row.categoryPath}/${row.session.slug}`,
3367
3980
  status: row.session.status,
3368
3981
  updatedAt: row.session.updatedAt,
3369
3982
  conversations: stats?.conversationCount ?? 0,
@@ -3403,7 +4016,7 @@ var STATUS_DOTS;
3403
4016
  var init_list = __esm(() => {
3404
4017
  init_router();
3405
4018
  init_sessions();
3406
- init_groups();
4019
+ init_categories();
3407
4020
  init_stats();
3408
4021
  init_format();
3409
4022
  STATUS_DOTS = {
@@ -3416,17 +4029,17 @@ var init_list = __esm(() => {
3416
4029
  register("list", async (args) => {
3417
4030
  const isJson = args.includes("--json");
3418
4031
  const showAll = args.includes("--all") || args.includes("-a");
3419
- const groupFlag = args.indexOf("--group");
3420
- const groupPath = groupFlag !== -1 ? args[groupFlag + 1] : undefined;
4032
+ const categoryFlag = args.indexOf("--category");
4033
+ const categoryPath = categoryFlag !== -1 ? args[categoryFlag + 1] : undefined;
3421
4034
  let sessionRows;
3422
- if (groupPath) {
3423
- const group = getGroupByPath(groupPath);
3424
- if (!group) {
3425
- console.error(`Group not found: ${groupPath}`);
4035
+ if (categoryPath) {
4036
+ const category = getCategoryByPath(categoryPath);
4037
+ if (!category) {
4038
+ console.error(`Category not found: ${categoryPath}`);
3426
4039
  process.exit(1);
3427
4040
  }
3428
- const groupSessions = getSessionsByGroup(group.id);
3429
- sessionRows = groupSessions.map((s) => ({ session: s, groupPath: group.path }));
4041
+ const categorySessions = getSessionsByCategory(category.id);
4042
+ sessionRows = categorySessions.map((s) => ({ session: s, categoryPath: category.path }));
3430
4043
  if (!showAll) {
3431
4044
  sessionRows = sessionRows.filter((r) => r.session.status !== "archived");
3432
4045
  }
@@ -3673,10 +4286,10 @@ function showAllSessions() {
3673
4286
  console.log("No sessions.");
3674
4287
  return;
3675
4288
  }
3676
- const maxName = Math.max(...rows.map((r) => `${r.groupPath}/${r.session.slug}`.length), 4);
4289
+ const maxName = Math.max(...rows.map((r) => `${r.categoryPath}/${r.session.slug}`.length), 4);
3677
4290
  console.log(`${DIM}${" "} ${"NAME".padEnd(maxName)} ${"STATUS".padEnd(10)} ${"EVENTS".padEnd(6)} LAST ACTIVE${RESET}`);
3678
4291
  for (const row of rows) {
3679
- const name = `${row.groupPath}/${row.session.slug}`;
4292
+ const name = `${row.categoryPath}/${row.session.slug}`;
3680
4293
  const dot = STATUS_DOTS2[row.session.status] ?? "?";
3681
4294
  const stats = getSessionStats(row.session.id);
3682
4295
  const eventCount = String(stats?.eventCount ?? 0).padEnd(6);
@@ -3833,7 +4446,7 @@ var init_log = __esm(() => {
3833
4446
  init_router();
3834
4447
  init_sessions();
3835
4448
  init_events();
3836
- init_groups();
4449
+ init_categories();
3837
4450
  init_stats();
3838
4451
  init_conversations();
3839
4452
  init_catalog();
@@ -3854,18 +4467,18 @@ var init_log = __esm(() => {
3854
4467
  showAllSessions();
3855
4468
  return;
3856
4469
  }
3857
- const { groupPath, slug } = parseSessionName(target);
3858
- const group = getGroupByPath(groupPath);
3859
- if (!group) {
3860
- console.error(`Group not found: ${groupPath}`);
4470
+ const { categoryPath, slug } = parseSessionName(target);
4471
+ const category = getCategoryByPath(categoryPath);
4472
+ if (!category) {
4473
+ console.error(`Category not found: ${categoryPath}`);
3861
4474
  process.exit(1);
3862
4475
  }
3863
- const session = getSessionByGroupSlug(group.id, slug);
4476
+ const session = getSessionByCategorySlug(category.id, slug);
3864
4477
  if (!session) {
3865
4478
  console.error(`Session not found: ${target}`);
3866
4479
  process.exit(1);
3867
4480
  }
3868
- showSessionLog(session, `${groupPath}/${slug}`, isJson);
4481
+ showSessionLog(session, `${categoryPath}/${slug}`, isJson);
3869
4482
  });
3870
4483
  });
3871
4484
 
@@ -3938,7 +4551,7 @@ function renderGlobal(metrics, isJson) {
3938
4551
  console.log(` PRs: ${totals.prs}`);
3939
4552
  console.log(` Interactions: ${totals.interactions}`);
3940
4553
  }
3941
- function renderGroup(metrics, groupPath, isJson) {
4554
+ function renderCategory(metrics, categoryPath, isJson) {
3942
4555
  if (isJson) {
3943
4556
  console.log(JSON.stringify(metrics, null, 2));
3944
4557
  return;
@@ -3948,7 +4561,7 @@ function renderGroup(metrics, groupPath, isJson) {
3948
4561
  const reset = "\x1B[0m";
3949
4562
  const sorted = [...metrics].sort((a, b) => b.durationS - a.durationS);
3950
4563
  const maxName = Math.max(...sorted.map((m) => m.name.length), 4);
3951
- console.log(`${bold}${groupPath}${reset}
4564
+ console.log(`${bold}${categoryPath}${reset}
3952
4565
  `);
3953
4566
  console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} ${"CONVOS".padEnd(6)} PRS${reset}`);
3954
4567
  for (const m of sorted) {
@@ -3989,7 +4602,7 @@ var init_stats2 = __esm(() => {
3989
4602
  init_router();
3990
4603
  init_sessions();
3991
4604
  init_stats();
3992
- init_groups();
4605
+ init_categories();
3993
4606
  init_events();
3994
4607
  init_timing();
3995
4608
  init_format();
@@ -4000,34 +4613,34 @@ var init_stats2 = __esm(() => {
4000
4613
  const target = filteredArgs[0];
4001
4614
  if (!target) {
4002
4615
  const rows = getAllSessions();
4003
- const metrics = rows.map((r) => getMetrics(r.session.id, `${r.groupPath}/${r.session.slug}`, r.session.status));
4616
+ const metrics = rows.map((r) => getMetrics(r.session.id, `${r.categoryPath}/${r.session.slug}`, r.session.status));
4004
4617
  renderGlobal(metrics, isJson);
4005
4618
  return;
4006
4619
  }
4007
4620
  if (target.endsWith("/")) {
4008
- const groupPath2 = target.replace(/\/+$/, "");
4009
- const group2 = getGroupByPath(groupPath2);
4010
- if (!group2) {
4011
- console.error(`Group not found: ${groupPath2}`);
4621
+ const categoryPath2 = target.replace(/\/+$/, "");
4622
+ const category2 = getCategoryByPath(categoryPath2);
4623
+ if (!category2) {
4624
+ console.error(`Category not found: ${categoryPath2}`);
4012
4625
  process.exit(1);
4013
4626
  }
4014
- const groupSessions = getSessionsByGroup(group2.id);
4015
- const metrics = groupSessions.map((s) => getMetrics(s.id, s.slug, s.status));
4016
- renderGroup(metrics, groupPath2, isJson);
4627
+ const categorySessions = getSessionsByCategory(category2.id);
4628
+ const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
4629
+ renderCategory(metrics, categoryPath2, isJson);
4017
4630
  return;
4018
4631
  }
4019
- const { groupPath, slug } = parseSessionName(target);
4020
- const group = getGroupByPath(groupPath);
4021
- if (!group) {
4022
- console.error(`Group not found: ${groupPath}`);
4632
+ const { categoryPath, slug } = parseSessionName(target);
4633
+ const category = getCategoryByPath(categoryPath);
4634
+ if (!category) {
4635
+ console.error(`Category not found: ${categoryPath}`);
4023
4636
  process.exit(1);
4024
4637
  }
4025
- const session = getSessionByGroupSlug(group.id, slug);
4638
+ const session = getSessionByCategorySlug(category.id, slug);
4026
4639
  if (!session) {
4027
4640
  console.error(`Session not found: ${target}`);
4028
4641
  process.exit(1);
4029
4642
  }
4030
- const m = getMetrics(session.id, `${groupPath}/${slug}`, session.status);
4643
+ const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
4031
4644
  renderSession(m, isJson);
4032
4645
  });
4033
4646
  });
@@ -4042,9 +4655,9 @@ var init_backfill_stats = __esm(() => {
4042
4655
  const includeArchived = args.includes("--include-archived");
4043
4656
  const rows = getAllSessions({ excludeArchived: !includeArchived });
4044
4657
  console.log(`Backfilling stats for ${rows.length} session(s)${includeArchived ? "" : " (excluding archived)"}...`);
4045
- for (const { session, groupPath } of rows) {
4658
+ for (const { session, categoryPath } of rows) {
4046
4659
  computeAndPersist(session.id);
4047
- console.log(` \u2713 ${groupPath}/${session.slug}`);
4660
+ console.log(` \u2713 ${categoryPath}/${session.slug}`);
4048
4661
  }
4049
4662
  console.log("Done.");
4050
4663
  });
@@ -4053,23 +4666,23 @@ var init_backfill_stats = __esm(() => {
4053
4666
  // src/cli/commands/archive.ts
4054
4667
  var exports_archive = {};
4055
4668
  function resolveSession(name) {
4056
- const { groupPath, slug } = parseSessionName(name);
4057
- const group = getGroupByPath(groupPath);
4058
- if (!group) {
4059
- console.error(`Group not found: ${groupPath}`);
4669
+ const { categoryPath, slug } = parseSessionName(name);
4670
+ const category = getCategoryByPath(categoryPath);
4671
+ if (!category) {
4672
+ console.error(`Category not found: ${categoryPath}`);
4060
4673
  process.exit(1);
4061
4674
  }
4062
- const session = getSessionByGroupSlug(group.id, slug);
4675
+ const session = getSessionByCategorySlug(category.id, slug);
4063
4676
  if (!session) {
4064
4677
  console.error(`Session not found: ${name}`);
4065
4678
  process.exit(1);
4066
4679
  }
4067
- return { session, groupPath };
4680
+ return { session, categoryPath };
4068
4681
  }
4069
4682
  var init_archive = __esm(() => {
4070
4683
  init_router();
4071
4684
  init_sessions();
4072
- init_groups();
4685
+ init_categories();
4073
4686
  init_session_archive();
4074
4687
  register("archive", async (args) => {
4075
4688
  const isUndo = args.includes("--undo");
@@ -4082,8 +4695,8 @@ var init_archive = __esm(() => {
4082
4695
  console.log("No paused sessions to archive.");
4083
4696
  return;
4084
4697
  }
4085
- for (const { session: session2, groupPath: groupPath2 } of archived) {
4086
- console.log(` archived ${groupPath2}/${session2.slug}`);
4698
+ for (const { session: session2, categoryPath: categoryPath2 } of archived) {
4699
+ console.log(` archived ${categoryPath2}/${session2.slug}`);
4087
4700
  }
4088
4701
  console.log(`
4089
4702
  Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
@@ -4095,8 +4708,8 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
4095
4708
  console.error(" bertrand archive --all-paused");
4096
4709
  process.exit(1);
4097
4710
  }
4098
- const { session, groupPath } = resolveSession(sessionName);
4099
- const fullName = `${groupPath}/${session.slug}`;
4711
+ const { session, categoryPath } = resolveSession(sessionName);
4712
+ const fullName = `${categoryPath}/${session.slug}`;
4100
4713
  if (isUndo) {
4101
4714
  const result2 = unarchiveSession(session.id);
4102
4715
  if (!result2.ok) {
@@ -4129,6 +4742,302 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
4129
4742
  });
4130
4743
  });
4131
4744
 
4745
+ // src/cli/commands/project.ts
4746
+ var exports_project = {};
4747
+ __export(exports_project, {
4748
+ switchSubcommand: () => switchSubcommand,
4749
+ renameSubcommand: () => renameSubcommand,
4750
+ removeSubcommand: () => removeSubcommand,
4751
+ listSubcommand: () => listSubcommand,
4752
+ importSubcommand: () => importSubcommand,
4753
+ currentSubcommand: () => currentSubcommand,
4754
+ createSubcommand: () => createSubcommand,
4755
+ _UsageError: () => _UsageError
4756
+ });
4757
+ import { existsSync as existsSync9, rmSync } from "fs";
4758
+ function countSessions(slug) {
4759
+ const dbFile = projectPaths(slug).db;
4760
+ if (!existsSync9(dbFile))
4761
+ return { total: 0, active: 0 };
4762
+ try {
4763
+ const db = getDbForProject(slug);
4764
+ const all = db.select({ status: sessions.status }).from(sessions).all();
4765
+ return {
4766
+ total: all.length,
4767
+ active: all.filter((s) => s.status === "active" || s.status === "waiting").length
4768
+ };
4769
+ } catch {
4770
+ return UNREADABLE_COUNTS;
4771
+ }
4772
+ }
4773
+ function validateSlug(slug) {
4774
+ if (!slug) {
4775
+ throw new UsageError("Slug required.");
4776
+ }
4777
+ if (!SLUG_PATTERN.test(slug)) {
4778
+ throw new UsageError(`Invalid slug "${slug}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes.`);
4779
+ }
4780
+ }
4781
+ function flagKey(token) {
4782
+ if (!token.startsWith("--"))
4783
+ return null;
4784
+ const eq8 = token.indexOf("=");
4785
+ return eq8 === -1 ? token.slice(2) : token.slice(2, eq8);
4786
+ }
4787
+ function flagInlineValue(token) {
4788
+ if (!token.startsWith("--"))
4789
+ return null;
4790
+ const eq8 = token.indexOf("=");
4791
+ return eq8 === -1 ? null : token.slice(eq8 + 1);
4792
+ }
4793
+ function parseFlag(args, name) {
4794
+ for (let i = 0;i < args.length; i++) {
4795
+ const key = flagKey(args[i]);
4796
+ if (key !== name)
4797
+ continue;
4798
+ const inline = flagInlineValue(args[i]);
4799
+ if (inline !== null)
4800
+ return inline;
4801
+ return args[i + 1];
4802
+ }
4803
+ return;
4804
+ }
4805
+ function hasFlag(args, name) {
4806
+ return args.some((a) => flagKey(a) === name);
4807
+ }
4808
+ function positional(args) {
4809
+ const out = [];
4810
+ for (let i = 0;i < args.length; i++) {
4811
+ const a = args[i];
4812
+ if (a.startsWith("--")) {
4813
+ if (a.includes("="))
4814
+ continue;
4815
+ const next = args[i + 1];
4816
+ if (next && !next.startsWith("--"))
4817
+ i++;
4818
+ continue;
4819
+ }
4820
+ out.push(a);
4821
+ }
4822
+ return out;
4823
+ }
4824
+ function listSubcommand(args) {
4825
+ const isJson = hasFlag(args, "json");
4826
+ const projects = listProjects();
4827
+ const activeSlug = getActiveProjectSlug();
4828
+ const rows = projects.map((p) => ({
4829
+ slug: p.slug,
4830
+ name: p.name,
4831
+ active: p.slug === activeSlug,
4832
+ sessions: countSessions(p.slug),
4833
+ lastUsedAt: p.lastUsedAt
4834
+ }));
4835
+ if (isJson) {
4836
+ console.log(JSON.stringify(rows, null, 2));
4837
+ return;
4838
+ }
4839
+ if (rows.length === 0) {
4840
+ console.log("No projects registered yet.");
4841
+ return;
4842
+ }
4843
+ const dim = "\x1B[2m";
4844
+ const reset = "\x1B[0m";
4845
+ const bold = "\x1B[1m";
4846
+ const maxSlug = Math.max(...rows.map((r) => r.slug.length), 4);
4847
+ const maxName = Math.max(...rows.map((r) => r.name.length), 4);
4848
+ console.log(`${dim} ${"SLUG".padEnd(maxSlug)} ${"NAME".padEnd(maxName)} ${"SESSIONS".padEnd(10)} LAST USED${reset}`);
4849
+ for (const r of rows) {
4850
+ const marker = r.active ? `${bold}*${reset}` : " ";
4851
+ const sessionStr = r.sessions.unreadable ? "?".padEnd(10) : `${r.sessions.total} (${r.sessions.active} active)`.padEnd(10);
4852
+ const ago = formatAgo(r.lastUsedAt);
4853
+ console.log(`${marker} ${r.slug.padEnd(maxSlug)} ${r.name.padEnd(maxName)} ${sessionStr} ${ago}`);
4854
+ }
4855
+ }
4856
+ function createSubcommand(args) {
4857
+ const [slug] = positional(args);
4858
+ validateSlug(slug ?? "");
4859
+ const customName = parseFlag(args, "name");
4860
+ const activate = hasFlag(args, "activate");
4861
+ if (listProjects().some((p) => p.slug === slug)) {
4862
+ throw new UsageError(`Project "${slug}" already exists.`);
4863
+ }
4864
+ createProject({ slug, name: customName });
4865
+ if (activate) {
4866
+ setActiveProjectSlug(slug);
4867
+ _resetActiveProjectCache();
4868
+ }
4869
+ console.log(`Created project "${slug}"${activate ? " (now active)" : ""}.`);
4870
+ }
4871
+ function switchSubcommand(args) {
4872
+ const [slug] = positional(args);
4873
+ if (!slug) {
4874
+ throw new UsageError("Usage: bertrand project switch <slug>");
4875
+ }
4876
+ const projects = listProjects();
4877
+ if (!projects.some((p) => p.slug === slug)) {
4878
+ throw new UsageError(`Unknown project slug "${slug}".`);
4879
+ }
4880
+ const currentSlug = getActiveProjectSlug();
4881
+ if (currentSlug !== slug) {
4882
+ const counts = countSessions(currentSlug);
4883
+ if (counts.active > 0) {
4884
+ throw new UsageError(`Cannot switch: project "${currentSlug}" has ${counts.active} active/waiting session(s). Pause them first.`);
4885
+ }
4886
+ }
4887
+ setActiveProjectSlug(slug);
4888
+ _resetActiveProjectCache();
4889
+ console.log(`Switched active project to "${slug}".`);
4890
+ }
4891
+ function currentSubcommand(args) {
4892
+ const isJson = hasFlag(args, "json");
4893
+ const active = resolveActiveProject();
4894
+ if (isJson) {
4895
+ console.log(JSON.stringify(active, null, 2));
4896
+ return;
4897
+ }
4898
+ console.log(`Active project: ${active.slug} (${active.name})`);
4899
+ console.log(` Root: ${active.root}`);
4900
+ console.log(` DB: ${active.db}`);
4901
+ console.log(` SyncEnv: ${active.syncEnv}`);
4902
+ }
4903
+ function renameSubcommand(args) {
4904
+ const [slug, ...rest] = positional(args);
4905
+ const newName = rest.join(" ");
4906
+ if (!slug || !newName) {
4907
+ throw new UsageError("Usage: bertrand project rename <slug> <new-name>");
4908
+ }
4909
+ renameProject(slug, newName);
4910
+ console.log(`Renamed "${slug}" to "${newName}".`);
4911
+ }
4912
+ function removeSubcommand(args) {
4913
+ const [slug] = positional(args);
4914
+ if (!slug) {
4915
+ throw new UsageError("Usage: bertrand project remove <slug> [--force] [--purge]");
4916
+ }
4917
+ validateSlug(slug);
4918
+ const force = hasFlag(args, "force");
4919
+ const purge = hasFlag(args, "purge");
4920
+ const projects = listProjects();
4921
+ const entry = projects.find((p) => p.slug === slug);
4922
+ if (!entry) {
4923
+ throw new UsageError(`Unknown project slug "${slug}".`);
4924
+ }
4925
+ if (slug === getActiveProjectSlug()) {
4926
+ throw new UsageError(`Cannot remove the active project "${slug}". Switch to another project first.`);
4927
+ }
4928
+ if (!force) {
4929
+ const counts = countSessions(slug);
4930
+ if (counts.total > 0) {
4931
+ throw new UsageError(`Project "${slug}" has ${counts.total} session(s). Pass --force to remove anyway.`);
4932
+ }
4933
+ }
4934
+ removeProject(slug);
4935
+ invalidateDbCache(slug);
4936
+ if (purge) {
4937
+ rmSync(projectPaths(slug).root, { recursive: true, force: true });
4938
+ }
4939
+ console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
4940
+ }
4941
+ async function importSubcommand(args) {
4942
+ const [bundle] = positional(args);
4943
+ if (!bundle) {
4944
+ throw new UsageError("Usage: bertrand project import <bundle>");
4945
+ }
4946
+ if (!isInvite(bundle)) {
4947
+ throw new UsageError(`Argument doesn't look like an invite bundle (expected to start with bertrand-sync://). ` + `Generate one on the source machine via \`bertrand sync invite\`.`);
4948
+ }
4949
+ console.log("Importing project from invite\u2026");
4950
+ const result = await bootstrapFromInvite(bundle);
4951
+ if (!result.ok) {
4952
+ throw new UsageError(result.error);
4953
+ }
4954
+ console.log(`\u2713 Created project "${result.project.slug}" (${result.project.name}) and activated it.`);
4955
+ if (result.pulled) {
4956
+ console.log(` Pulled ${result.bytes} bytes in ${result.durationMs}ms.`);
4957
+ } else {
4958
+ console.log(` No remote object yet \u2014 run \`bertrand sync push\` on the source machine first.`);
4959
+ }
4960
+ }
4961
+ function printProjectUsage() {
4962
+ console.log(`
4963
+ bertrand project \u2014 manage projects
4964
+
4965
+ Usage:
4966
+ bertrand project list [--json] List all projects
4967
+ bertrand project create <slug> [--name "..."] [--activate]
4968
+ Create a new project
4969
+ bertrand project switch <slug> Set the active project
4970
+ bertrand project current [--json] Show the active project
4971
+ bertrand project rename <slug> <new-name> Rename a project (display name only)
4972
+ bertrand project remove <slug> [--force] [--purge]
4973
+ Remove a project entry
4974
+ bertrand project import <bundle> Import a project from a \`bertrand sync invite\` bundle
4975
+ `.trim());
4976
+ }
4977
+ var SLUG_PATTERN, UsageError, UNREADABLE_COUNTS, KNOWN_SUBS, _UsageError;
4978
+ var init_project = __esm(() => {
4979
+ init_router();
4980
+ init_client();
4981
+ init_schema();
4982
+ init_registry();
4983
+ init_paths2();
4984
+ init_create();
4985
+ init_resolve();
4986
+ init_bootstrap();
4987
+ init_format();
4988
+ SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
4989
+ UsageError = class UsageError extends Error {
4990
+ };
4991
+ UNREADABLE_COUNTS = { total: 0, active: 0, unreadable: true };
4992
+ KNOWN_SUBS = new Set([
4993
+ "list",
4994
+ "create",
4995
+ "switch",
4996
+ "current",
4997
+ "rename",
4998
+ "remove",
4999
+ "import"
5000
+ ]);
5001
+ register("project", async (args) => {
5002
+ const sub = args[0];
5003
+ try {
5004
+ switch (sub) {
5005
+ case "list":
5006
+ return listSubcommand(args.slice(1));
5007
+ case "create":
5008
+ return createSubcommand(args.slice(1));
5009
+ case "switch":
5010
+ return switchSubcommand(args.slice(1));
5011
+ case "current":
5012
+ return currentSubcommand(args.slice(1));
5013
+ case "rename":
5014
+ return renameSubcommand(args.slice(1));
5015
+ case "remove":
5016
+ return removeSubcommand(args.slice(1));
5017
+ case "import":
5018
+ return await importSubcommand(args.slice(1));
5019
+ case undefined:
5020
+ case "--help":
5021
+ case "-h":
5022
+ printProjectUsage();
5023
+ return;
5024
+ default:
5025
+ throw new UsageError(`Unknown subcommand: ${sub}`);
5026
+ }
5027
+ } catch (err) {
5028
+ if (err instanceof UsageError) {
5029
+ console.error(err.message);
5030
+ if (sub && !KNOWN_SUBS.has(sub)) {
5031
+ printProjectUsage();
5032
+ }
5033
+ process.exit(1);
5034
+ }
5035
+ throw err;
5036
+ }
5037
+ });
5038
+ _UsageError = UsageError;
5039
+ });
5040
+
4132
5041
  // src/index.ts
4133
5042
  init_router();
4134
5043
  var command = process.argv[2];
@@ -4160,7 +5069,8 @@ if (command && command in hotPath) {
4160
5069
  Promise.resolve().then(() => (init_serve(), exports_serve)),
4161
5070
  Promise.resolve().then(() => (init_badge(), exports_badge)),
4162
5071
  Promise.resolve().then(() => (init_notify(), exports_notify)),
4163
- Promise.resolve().then(() => (init_sync(), exports_sync))
5072
+ Promise.resolve().then(() => (init_sync(), exports_sync)),
5073
+ Promise.resolve().then(() => (init_project(), exports_project))
4164
5074
  ]);
4165
5075
  }
4166
5076
  await route(process.argv);