forge-cw 0.1.0 → 0.1.2

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/index.js CHANGED
@@ -260,8 +260,17 @@ var init_runner = __esm({
260
260
  const proc = spawn("sh", ["-c", command], {
261
261
  cwd: options.cwd,
262
262
  env: { ...process.env, ...options.env },
263
- stdio: ["ignore", "pipe", "pipe"]
263
+ stdio: ["ignore", "pipe", "pipe"],
264
+ detached: true
264
265
  });
266
+ const stop = () => {
267
+ try {
268
+ if (proc.pid)
269
+ process.kill(-proc.pid, "SIGTERM");
270
+ } catch {
271
+ proc.kill("SIGTERM");
272
+ }
273
+ };
265
274
  const handleData = (data) => {
266
275
  const str2 = data.toString();
267
276
  output += str2;
@@ -270,14 +279,12 @@ var init_runner = __esm({
270
279
  proc.stdout.on("data", handleData);
271
280
  proc.stderr.on("data", handleData);
272
281
  if (options.signal) {
273
- options.signal.addEventListener("abort", () => {
274
- proc.kill("SIGTERM");
275
- }, { once: true });
282
+ options.signal.addEventListener("abort", stop, { once: true });
276
283
  }
277
284
  if (options.timeout) {
278
285
  timeoutId = setTimeout(() => {
279
286
  timedOut = true;
280
- proc.kill("SIGTERM");
287
+ stop();
281
288
  }, options.timeout);
282
289
  }
283
290
  proc.on("close", (code) => {
@@ -630,45 +637,46 @@ var init_cw_reader = __esm({
630
637
  getSkillDir(scope, scopeRef, name) {
631
638
  return join2(this.getSkillConfigDir(scope, scopeRef), "skills", name);
632
639
  }
633
- getSkills(account, project) {
640
+ // global, every account and every registered project
641
+ getSkills() {
642
+ return [
643
+ ...this.scanSkills(join2(this.getSkillConfigDir("global", "global"), "skills"), "global", "global"),
644
+ ...this.getAccounts().flatMap((a) => this.scanSkills(join2(this.getSkillConfigDir("account", a), "skills"), "account", a)),
645
+ ...Object.entries(this.getProjects()).flatMap(([name, p]) => this.scanSkills(join2(p.path, ".claude", "skills"), "project", name))
646
+ ];
647
+ }
648
+ scanSkills(dir, scope, scopeRef) {
634
649
  const results = [];
635
- const scanDir = (dir, scope, scopeRef) => {
636
- if (!existsSync2(dir))
637
- return;
638
- const entries = readdirSync2(dir, { withFileTypes: true });
639
- for (const entry of entries) {
640
- if (!entry.isDirectory())
641
- continue;
642
- if (entry.name.startsWith("acct--"))
643
- continue;
644
- const fullPath = join2(dir, entry.name);
645
- const skillMd = join2(fullPath, "SKILL.md");
646
- if (!existsSync2(skillMd))
647
- continue;
648
- try {
649
- const content = readFileSync2(skillMd, "utf-8");
650
- const { frontmatter } = this.parseFrontmatter(content);
651
- const refsDir = join2(fullPath, "references");
652
- const hasReferences = existsSync2(refsDir) && readdirSync2(refsDir).some((f) => f.endsWith(".md"));
653
- results.push({
654
- name: String(frontmatter["name"] ?? entry.name),
655
- dirName: entry.name,
656
- scope,
657
- scopeRef,
658
- description: String(frontmatter["description"] ?? ""),
659
- domain: frontmatter["domain"] ? String(frontmatter["domain"]) : void 0,
660
- triggers: frontmatter["triggers"] ? String(frontmatter["triggers"]) : void 0,
661
- hasReferences
662
- });
663
- } catch {
664
- }
650
+ if (!existsSync2(dir))
651
+ return results;
652
+ const entries = readdirSync2(dir, { withFileTypes: true });
653
+ for (const entry of entries) {
654
+ if (!entry.isDirectory())
655
+ continue;
656
+ if (entry.name.startsWith("acct--"))
657
+ continue;
658
+ const fullPath = join2(dir, entry.name);
659
+ const skillMd = join2(fullPath, "SKILL.md");
660
+ if (!existsSync2(skillMd))
661
+ continue;
662
+ try {
663
+ const content = readFileSync2(skillMd, "utf-8");
664
+ const { frontmatter } = this.parseFrontmatter(content);
665
+ const refsDir = join2(fullPath, "references");
666
+ const hasReferences = existsSync2(refsDir) && readdirSync2(refsDir).some((f) => f.endsWith(".md"));
667
+ results.push({
668
+ name: String(frontmatter["name"] ?? entry.name),
669
+ dirName: entry.name,
670
+ scope,
671
+ scopeRef,
672
+ description: String(frontmatter["description"] ?? ""),
673
+ domain: frontmatter["domain"] ? String(frontmatter["domain"]) : void 0,
674
+ triggers: frontmatter["triggers"] ? String(frontmatter["triggers"]) : void 0,
675
+ hasReferences
676
+ });
677
+ } catch {
665
678
  }
666
- };
667
- scanDir(join2(this.getSkillConfigDir("global", "global"), "skills"), "global", "global");
668
- if (account)
669
- scanDir(join2(this.getSkillConfigDir("account", account), "skills"), "account", account);
670
- if (project)
671
- scanDir(join2(this.getSkillConfigDir("project", project), "skills"), "project", project);
679
+ }
672
680
  return results;
673
681
  }
674
682
  getSkill(scope, scopeRef, name) {
@@ -2528,7 +2536,7 @@ var init_plugins = __esm({
2528
2536
 
2529
2537
  // ../core/dist/skill-routes.js
2530
2538
  import { Hono as Hono2 } from "hono";
2531
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, rmSync as rmSync2, existsSync as existsSync7, unlinkSync } from "node:fs";
2539
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, rmSync as rmSync2, existsSync as existsSync7, unlinkSync, cpSync } from "node:fs";
2532
2540
  import { dirname as dirname2, join as join9 } from "node:path";
2533
2541
  function installFailure(result2) {
2534
2542
  try {
@@ -2552,10 +2560,32 @@ function skillRoutes(reader, options = {}) {
2552
2560
  const remoteOf = options.remoteOf ?? createRemoteLookup(runCommand);
2553
2561
  const app = new Hono2();
2554
2562
  const updating = /* @__PURE__ */ new Set();
2555
- app.get("/", (c) => {
2556
- const account = c.req.query("account");
2557
- const project = c.req.query("project");
2558
- return c.json(reader.getSkills(account, project));
2563
+ const refOf = (scope, scopeRef) => scope === "global" ? "global" : scopeRef ?? "";
2564
+ const knownScope = (scope, ref) => scope === "global" || (scope === "account" ? reader.getAccounts().includes(ref) : scope === "project" && ref in reader.getProjects());
2565
+ app.get("/", (c) => c.json(reader.getSkills()));
2566
+ app.post("/copy", async (c) => {
2567
+ const { from, to } = await c.req.json();
2568
+ const fromRef = refOf(from.scope, from.scopeRef);
2569
+ const toRef = refOf(to.scope, to.scopeRef);
2570
+ if (!SKILL_NAME_RE.test(from.name))
2571
+ return c.json({ error: "Invalid skill name" }, 400);
2572
+ if (!knownScope(from.scope, fromRef))
2573
+ return c.json({ error: `Unknown ${from.scope}: ${fromRef}` }, 404);
2574
+ if (!knownScope(to.scope, toRef))
2575
+ return c.json({ error: `Unknown ${to.scope}: ${toRef}` }, 404);
2576
+ const source = reader.getSkillDir(from.scope, fromRef, from.name);
2577
+ const target = reader.getSkillDir(to.scope, toRef, from.name);
2578
+ if (!existsSync7(join9(source, "SKILL.md")))
2579
+ return c.json({ error: "Skill not found" }, 404);
2580
+ if (existsSync7(target))
2581
+ return c.json({ error: `${from.name} already exists in ${toRef}` }, 409);
2582
+ try {
2583
+ cpSync(source, target, { recursive: true });
2584
+ return c.json({ ok: true });
2585
+ } catch (err) {
2586
+ const message = err instanceof Error ? err.message : "Unknown error";
2587
+ return c.json({ error: `Failed to copy skill: ${message}` }, 500);
2588
+ }
2559
2589
  });
2560
2590
  app.get("/global/:name", (c) => {
2561
2591
  const name = c.req.param("name");
@@ -2701,9 +2731,8 @@ function skillRoutes(reader, options = {}) {
2701
2731
  if (!repo || !skill) {
2702
2732
  return c.json({ error: "repo and skill are required" }, 400);
2703
2733
  }
2704
- const ref = scope === "global" ? "global" : scopeRef ?? "";
2705
- const known = scope === "global" || (scope === "account" ? reader.getAccounts().includes(ref) : ref in reader.getProjects());
2706
- if (!known) {
2734
+ const ref = refOf(scope, scopeRef);
2735
+ if (!knownScope(scope, ref)) {
2707
2736
  return c.json({ error: `Unknown ${scope}: ${ref}` }, 404);
2708
2737
  }
2709
2738
  const configDir = reader.getSkillConfigDir(scope, ref);
@@ -2754,7 +2783,7 @@ function skillRoutes(reader, options = {}) {
2754
2783
  });
2755
2784
  return app;
2756
2785
  }
2757
- var INSTALL_TIMEOUT_MS;
2786
+ var INSTALL_TIMEOUT_MS, SKILL_NAME_RE;
2758
2787
  var init_skill_routes = __esm({
2759
2788
  "../core/dist/skill-routes.js"() {
2760
2789
  "use strict";
@@ -2762,6 +2791,7 @@ var init_skill_routes = __esm({
2762
2791
  init_task_review();
2763
2792
  init_plugins();
2764
2793
  INSTALL_TIMEOUT_MS = 12e4;
2794
+ SKILL_NAME_RE = /^\w[\w.-]*$/;
2765
2795
  }
2766
2796
  });
2767
2797
 
@@ -3143,9 +3173,8 @@ var init_terminal_screen = __esm({
3143
3173
  write(data) {
3144
3174
  this.term.write(data);
3145
3175
  }
3146
- // a resize applies at once while writes wait to be parsed, so it waits its turn behind them
3147
3176
  resize(cols, rows) {
3148
- this.term.write("", () => this.term.resize(cols, rows));
3177
+ this.term.resize(cols, rows);
3149
3178
  }
3150
3179
  // rows oldest first, scrollback included, without the blank rows under the last one written
3151
3180
  read() {
@@ -3401,17 +3430,22 @@ var init_state_log = __esm({
3401
3430
 
3402
3431
  // ../core/dist/pty-routes.js
3403
3432
  import { WebSocketServer, WebSocket } from "ws";
3404
- function takeSession(reader, project, sessionDir) {
3433
+ function takeSession(reader, project, sessionDir, general) {
3405
3434
  const sessionId = `${project}::${sessionDir}`;
3406
3435
  const pending = pendingSessions.get(sessionId);
3407
3436
  if (pending) {
3408
3437
  pendingSessions.delete(sessionId);
3438
+ if (pending.type === "general")
3439
+ general?.remember(sessionId, pending);
3409
3440
  return { session: pending, isNew: true };
3410
3441
  }
3411
3442
  const session = reader.getSession(project, sessionDir);
3412
- return session ? { session, isNew: false } : null;
3443
+ if (session)
3444
+ return { session, isNew: false };
3445
+ const known = general?.get(sessionId);
3446
+ return known ? { session: known, isNew: true } : null;
3413
3447
  }
3414
- function createTerminalWss(manager, reader, { localOnly = true } = {}) {
3448
+ function createTerminalWss(manager, reader, { localOnly = true, general } = {}) {
3415
3449
  const wss = new WebSocketServer({ noServer: true });
3416
3450
  function wireWs(ws, sessionId, project, sessionDir) {
3417
3451
  const client = {
@@ -3448,18 +3482,18 @@ function createTerminalWss(manager, reader, { localOnly = true } = {}) {
3448
3482
  wireWs(ws, sessionId, project, sessionDir);
3449
3483
  return;
3450
3484
  }
3451
- const taken = takeSession(reader, project, sessionDir);
3485
+ const taken = takeSession(reader, project, sessionDir, general);
3452
3486
  if (!taken) {
3453
3487
  console.log(`[pty-ws] Session not found: ${project}/${sessionDir}`);
3454
3488
  ws.send(JSON.stringify({ type: "error", message: `Session not found: ${project}/${sessionDir}` }));
3455
- ws.close();
3489
+ ws.close(REFUSED_CLOSE_CODE);
3456
3490
  return;
3457
3491
  }
3458
3492
  const ptySession = manager.getOrCreate(project, sessionDir, taken.session, { isNew: taken.isNew });
3459
3493
  if (!ptySession) {
3460
3494
  console.error(`[pty-ws] Failed to spawn terminal for: ${project}/${sessionDir}`);
3461
3495
  ws.send(JSON.stringify({ type: "error", message: `Failed to start terminal for ${project}/${sessionDir}` }));
3462
- ws.close();
3496
+ ws.close(REFUSED_CLOSE_CODE);
3463
3497
  return;
3464
3498
  }
3465
3499
  wireWs(ws, sessionId, project, sessionDir);
@@ -3495,16 +3529,67 @@ function parseTerminalUpgradeUrl(pathname) {
3495
3529
  return null;
3496
3530
  }
3497
3531
  }
3532
+ var REFUSED_CLOSE_CODE;
3498
3533
  var init_pty_routes = __esm({
3499
3534
  "../core/dist/pty-routes.js"() {
3500
3535
  "use strict";
3501
3536
  init_cw_routes();
3502
3537
  init_origin_guard();
3538
+ REFUSED_CLOSE_CODE = 4404;
3539
+ }
3540
+ });
3541
+
3542
+ // ../core/dist/general-sessions.js
3543
+ import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "node:fs";
3544
+ import { dirname as dirname5 } from "node:path";
3545
+ var MAX_AGE_MS, GeneralSessions;
3546
+ var init_general_sessions = __esm({
3547
+ "../core/dist/general-sessions.js"() {
3548
+ "use strict";
3549
+ MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
3550
+ GeneralSessions = class {
3551
+ file;
3552
+ sessions = /* @__PURE__ */ new Map();
3553
+ constructor(file = null, now = Date.now()) {
3554
+ this.file = file;
3555
+ if (!file || !existsSync9(file))
3556
+ return;
3557
+ try {
3558
+ const saved = JSON.parse(readFileSync7(file, "utf-8"));
3559
+ for (const [key, session] of Object.entries(saved)) {
3560
+ if (now - Date.parse(session.created) < MAX_AGE_MS)
3561
+ this.sessions.set(key, session);
3562
+ }
3563
+ } catch {
3564
+ }
3565
+ }
3566
+ get(key) {
3567
+ return this.sessions.get(key);
3568
+ }
3569
+ remember(key, session) {
3570
+ this.sessions.set(key, session);
3571
+ this.save();
3572
+ }
3573
+ forget(key) {
3574
+ if (this.sessions.delete(key))
3575
+ this.save();
3576
+ }
3577
+ save() {
3578
+ if (!this.file)
3579
+ return;
3580
+ try {
3581
+ mkdirSync4(dirname5(this.file), { recursive: true });
3582
+ writeFileSync3(this.file, JSON.stringify(Object.fromEntries(this.sessions), null, 2));
3583
+ } catch (err) {
3584
+ console.warn(`[pty] could not save general sessions: ${err.message}`);
3585
+ }
3586
+ }
3587
+ };
3503
3588
  }
3504
3589
  });
3505
3590
 
3506
3591
  // ../core/dist/liveframe.js
3507
- import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "node:fs";
3592
+ import { existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync5 } from "node:fs";
3508
3593
  import { mkdir, rm } from "node:fs/promises";
3509
3594
  import { homedir as homedir4 } from "node:os";
3510
3595
  import { join as join11 } from "node:path";
@@ -3523,7 +3608,7 @@ function parseFrameUrl(stdout) {
3523
3608
  }
3524
3609
  function readJson2(path) {
3525
3610
  try {
3526
- return JSON.parse(readFileSync7(path, "utf8"));
3611
+ return JSON.parse(readFileSync8(path, "utf8"));
3527
3612
  } catch {
3528
3613
  return null;
3529
3614
  }
@@ -3596,7 +3681,7 @@ var init_liveframe = __esm({
3596
3681
  return this.readFrame(project, frame, this.config().api);
3597
3682
  }
3598
3683
  listFrames() {
3599
- if (!existsSync9(this.root))
3684
+ if (!existsSync10(this.root))
3600
3685
  return [];
3601
3686
  const { api } = this.config();
3602
3687
  const frames = [];
@@ -3636,7 +3721,7 @@ var init_liveframe = __esm({
3636
3721
  return this.exclusive(project, frame, async () => {
3637
3722
  const projectDir = join11(this.root, project);
3638
3723
  const dir = this.frameDir(project, frame);
3639
- if (existsSync9(dir))
3724
+ if (existsSync10(dir))
3640
3725
  throw new LiveframeError(`${project}/${frame} already exists in ${this.root}`, 409);
3641
3726
  await mkdir(projectDir, { recursive: true });
3642
3727
  try {
@@ -3715,7 +3800,7 @@ var init_liveframe_routes = __esm({
3715
3800
  import { Hono as Hono4 } from "hono";
3716
3801
  import { cors } from "hono/cors";
3717
3802
  import { join as join12, basename as basename3, resolve as resolve4 } from "node:path";
3718
- import { readdirSync as readdirSync6, existsSync as existsSync10 } from "node:fs";
3803
+ import { readdirSync as readdirSync6, existsSync as existsSync11 } from "node:fs";
3719
3804
  function createForgeServer(options) {
3720
3805
  const { dataDir, db: externalDb, authToken, localOnly = true } = options;
3721
3806
  const dbPath = join12(dataDir, "forge.db");
@@ -3752,13 +3837,15 @@ function createForgeServer(options) {
3752
3837
  }
3753
3838
  });
3754
3839
  const ptyManager = new PTYManager(states);
3755
- const terminalWss = createTerminalWss(ptyManager, cwReader, { localOnly });
3840
+ const generalSessions = new GeneralSessions(join12(dataDir, "general-sessions.json"));
3841
+ const terminalWss = createTerminalWss(ptyManager, cwReader, { localOnly, general: generalSessions });
3756
3842
  app.route("/api/liveframe", liveframeRoutes());
3757
3843
  app.get("/api/cw/session-states", (c) => c.json({ classifier: states.remoteName ?? "local", shadow: states.isShadow, states: states.snapshot() }));
3758
3844
  app.post("/api/cw/terminal/kill", async (c) => {
3759
3845
  const { project, sessionDir } = await c.req.json();
3760
3846
  const sessionId = `${project}::${sessionDir}`;
3761
3847
  ptyManager.kill(sessionId);
3848
+ generalSessions.forget(sessionId);
3762
3849
  return c.json({ ok: true });
3763
3850
  });
3764
3851
  async function resolveAction(c) {
@@ -3895,7 +3982,7 @@ data: ${JSON.stringify(data)}
3895
3982
  if (!dir.startsWith(home)) {
3896
3983
  return c.json({ error: "Access denied: path must be within home directory" }, 403);
3897
3984
  }
3898
- if (!existsSync10(dir)) {
3985
+ if (!existsSync11(dir)) {
3899
3986
  return c.json({ error: "Directory not found" }, 404);
3900
3987
  }
3901
3988
  try {
@@ -3903,8 +3990,8 @@ data: ${JSON.stringify(data)}
3903
3990
  const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({
3904
3991
  name: e.name,
3905
3992
  path: join12(dir, e.name),
3906
- hasPackageJson: existsSync10(join12(dir, e.name, "package.json")),
3907
- hasGit: existsSync10(join12(dir, e.name, ".git"))
3993
+ hasPackageJson: existsSync11(join12(dir, e.name, "package.json")),
3994
+ hasGit: existsSync11(join12(dir, e.name, ".git"))
3908
3995
  })).sort((a, b) => a.name.localeCompare(b.name));
3909
3996
  return c.json({
3910
3997
  current: dir,
@@ -3947,6 +4034,7 @@ var init_server = __esm({
3947
4034
  init_state_log();
3948
4035
  init_pty_routes();
3949
4036
  init_login_manager();
4037
+ init_general_sessions();
3950
4038
  init_liveframe_routes();
3951
4039
  }
3952
4040
  });
@@ -4081,7 +4169,7 @@ var init_db_factory = __esm({
4081
4169
  });
4082
4170
 
4083
4171
  // ../core/dist/init.js
4084
- import { existsSync as existsSync11, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
4172
+ import { existsSync as existsSync12, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "node:fs";
4085
4173
  import { join as join13 } from "node:path";
4086
4174
  import { homedir as homedir5 } from "node:os";
4087
4175
  function getForgeDir() {
@@ -4089,14 +4177,14 @@ function getForgeDir() {
4089
4177
  }
4090
4178
  function ensureForgeDir() {
4091
4179
  const forgeDir = getForgeDir();
4092
- if (existsSync11(forgeDir)) {
4180
+ if (existsSync12(forgeDir)) {
4093
4181
  return { forgeDir, created: false };
4094
4182
  }
4095
- mkdirSync4(forgeDir, { recursive: true });
4183
+ mkdirSync5(forgeDir, { recursive: true });
4096
4184
  for (const d of FORGE_SUBDIRS) {
4097
- mkdirSync4(join13(forgeDir, d), { recursive: true });
4185
+ mkdirSync5(join13(forgeDir, d), { recursive: true });
4098
4186
  }
4099
- writeFileSync3(join13(forgeDir, "config.json"), JSON.stringify({
4187
+ writeFileSync4(join13(forgeDir, "config.json"), JSON.stringify({
4100
4188
  port: 3e3,
4101
4189
  theme: "dark",
4102
4190
  openBrowser: true,
@@ -4115,11 +4203,11 @@ var init_init = __esm({
4115
4203
  // ../core/dist/cw-install.js
4116
4204
  import { execFile as execFile5 } from "node:child_process";
4117
4205
  import { createHash as createHash2 } from "node:crypto";
4118
- import { accessSync as accessSync2, constants as constants2, existsSync as existsSync12, readFileSync as readFileSync8, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
4206
+ import { accessSync as accessSync2, constants as constants2, existsSync as existsSync13, readFileSync as readFileSync9, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
4119
4207
  import { delimiter as delimiter2, join as join14 } from "node:path";
4120
4208
  function readCwVersion(file) {
4121
4209
  try {
4122
- const match = /^CW_VERSION="([^"]+)"/m.exec(readFileSync8(file, "utf-8"));
4210
+ const match = /^CW_VERSION="([^"]+)"/m.exec(readFileSync9(file, "utf-8"));
4123
4211
  return match ? match[1] : null;
4124
4212
  } catch {
4125
4213
  return null;
@@ -4137,9 +4225,9 @@ function compareVersions(a, b) {
4137
4225
  }
4138
4226
  function readCwBundle(dir) {
4139
4227
  try {
4140
- const { ref } = JSON.parse(readFileSync8(join14(dir, ".bundle.json"), "utf-8"));
4228
+ const { ref } = JSON.parse(readFileSync9(join14(dir, ".bundle.json"), "utf-8"));
4141
4229
  const version = readCwVersion(join14(dir, "cw"));
4142
- if (!ref || !version || !existsSync12(join14(dir, "install.sh")))
4230
+ if (!ref || !version || !existsSync13(join14(dir, "install.sh")))
4143
4231
  return null;
4144
4232
  return { dir, version, ref };
4145
4233
  } catch {
@@ -4187,7 +4275,7 @@ function pathWithCw(cwHome, pathVar) {
4187
4275
  }
4188
4276
  function readMarker(cwHome) {
4189
4277
  try {
4190
- return JSON.parse(readFileSync8(join14(cwHome, MARKER_FILE), "utf-8"));
4278
+ return JSON.parse(readFileSync9(join14(cwHome, MARKER_FILE), "utf-8"));
4191
4279
  } catch {
4192
4280
  return null;
4193
4281
  }
@@ -4203,7 +4291,7 @@ async function ensureCw(options) {
4203
4291
  const run = options.run ?? runInstall;
4204
4292
  const bin = join14(cwHome, "bin", "cw");
4205
4293
  const bundle = readCwBundle(bundleDir);
4206
- const installed = existsSync12(bin) ? { version: readCwVersion(bin), sha256: sha256Of(bin) } : null;
4294
+ const installed = existsSync13(bin) ? { version: readCwVersion(bin), sha256: sha256Of(bin) } : null;
4207
4295
  const decision = decideCwInstall({
4208
4296
  bundle,
4209
4297
  installed,
@@ -4214,10 +4302,10 @@ async function ensureCw(options) {
4214
4302
  if (decision.action === "skip" || !bundle)
4215
4303
  return { action: "skipped", message: decision.reason };
4216
4304
  const { code, output } = await run(join14(bundle.dir, "install.sh"), ["--no-shell"], { ...env, CW_HOME: cwHome });
4217
- if (code !== 0 || !existsSync12(bin))
4305
+ if (code !== 0 || !existsSync13(bin))
4218
4306
  return { action: "failed", message: failureMessage2(output) };
4219
4307
  const marker = { version: bundle.version, ref: bundle.ref, sha256: sha256Of(bin) };
4220
- writeFileSync4(join14(cwHome, MARKER_FILE), JSON.stringify(marker, null, 2) + "\n");
4308
+ writeFileSync5(join14(cwHome, MARKER_FILE), JSON.stringify(marker, null, 2) + "\n");
4221
4309
  return {
4222
4310
  action: decision.action === "install" ? "installed" : "updated",
4223
4311
  version: bundle.version,
@@ -4231,7 +4319,7 @@ var init_cw_install = __esm({
4231
4319
  MARKER_FILE = ".forge-cw.json";
4232
4320
  INSTALL_TIMEOUT_MS2 = 12e4;
4233
4321
  ANSI_RE3 = /\u001b\[[0-9;?]*[A-Za-z]/g;
4234
- sha256Of = (file) => createHash2("sha256").update(readFileSync8(file)).digest("hex");
4322
+ sha256Of = (file) => createHash2("sha256").update(readFileSync9(file)).digest("hex");
4235
4323
  runInstall = (script, args, env) => new Promise((resolve5) => {
4236
4324
  execFile5("bash", [script, ...args], { env, timeout: INSTALL_TIMEOUT_MS2, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
4237
4325
  const output = `${String(stdout)}${String(stderr)}`;
@@ -4244,6 +4332,52 @@ var init_cw_install = __esm({
4244
4332
  }
4245
4333
  });
4246
4334
 
4335
+ // ../core/dist/port-in-use.js
4336
+ function waitForListening(server) {
4337
+ if (server.listening)
4338
+ return Promise.resolve();
4339
+ return new Promise((resolve5, reject) => {
4340
+ const onError = (err) => {
4341
+ server.off("listening", onListening);
4342
+ reject(err);
4343
+ };
4344
+ const onListening = () => {
4345
+ server.off("error", onError);
4346
+ resolve5();
4347
+ };
4348
+ server.once("error", onError);
4349
+ server.once("listening", onListening);
4350
+ });
4351
+ }
4352
+ async function probePort(port, timeoutMs = 1500) {
4353
+ try {
4354
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(timeoutMs) });
4355
+ const body = await res.json().catch(() => null);
4356
+ return body?.status === "ok" && typeof body.modules === "number" ? "forge" : "other";
4357
+ } catch (err) {
4358
+ return err.name === "TimeoutError" ? "unresponsive" : "other";
4359
+ }
4360
+ }
4361
+ function portInUseMessage(port, holder) {
4362
+ const next = `Start on another port with --port ${port + 1} (or FORGE_PORT=${port + 1}).`;
4363
+ const find = `Find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN`;
4364
+ if (holder === "forge")
4365
+ return `Forge is already running at http://localhost:${port}.`;
4366
+ if (holder === "unresponsive") {
4367
+ return [
4368
+ `Port ${port} is taken by a process that does not answer, often a Forge suspended with Ctrl+Z.`,
4369
+ `${find}, then bring it back with \`fg\` in its terminal or stop it with \`kill <pid>\`.`,
4370
+ next
4371
+ ].join("\n ");
4372
+ }
4373
+ return [`Port ${port} is in use by another program.`, find, next].join("\n ");
4374
+ }
4375
+ var init_port_in_use = __esm({
4376
+ "../core/dist/port-in-use.js"() {
4377
+ "use strict";
4378
+ }
4379
+ });
4380
+
4247
4381
  // ../core/dist/index.js
4248
4382
  var dist_exports = {};
4249
4383
  __export(dist_exports, {
@@ -4269,10 +4403,13 @@ __export(dist_exports, {
4269
4403
  getForgeDir: () => getForgeDir,
4270
4404
  liveframeRoutes: () => liveframeRoutes,
4271
4405
  pathWithCw: () => pathWithCw,
4406
+ portInUseMessage: () => portInUseMessage,
4407
+ probePort: () => probePort,
4272
4408
  quietSqliteWarning: () => quietSqliteWarning,
4273
4409
  resolveListenOptions: () => resolveListenOptions,
4274
4410
  skillRoutes: () => skillRoutes,
4275
- supports: () => supports
4411
+ supports: () => supports,
4412
+ waitForListening: () => waitForListening
4276
4413
  });
4277
4414
  var init_dist = __esm({
4278
4415
  "../core/dist/index.js"() {
@@ -4295,11 +4432,12 @@ var init_dist = __esm({
4295
4432
  init_pty_routes();
4296
4433
  init_liveframe_routes();
4297
4434
  init_liveframe();
4435
+ init_port_in_use();
4298
4436
  }
4299
4437
  });
4300
4438
 
4301
4439
  // src/index.ts
4302
- import { existsSync as existsSync13 } from "node:fs";
4440
+ import { existsSync as existsSync14 } from "node:fs";
4303
4441
  import { homedir as homedir6 } from "node:os";
4304
4442
  import { join as join15 } from "node:path";
4305
4443
  function parseArgs(argv) {
@@ -4313,10 +4451,10 @@ function parseArgs(argv) {
4313
4451
  return out;
4314
4452
  }
4315
4453
  var packageRoot = join15(import.meta.dirname, "..");
4316
- var isCheckout = existsSync13(join15(packageRoot, "src"));
4454
+ var isCheckout = existsSync14(join15(packageRoot, "src"));
4317
4455
  var consoleDist = isCheckout ? join15(packageRoot, "../console/dist") : join15(packageRoot, "console");
4318
4456
  async function main() {
4319
- const { ensureForgeDir: ensureForgeDir2, createForgeServer: createForgeServer2, createDatabase: createDatabase2, resolveListenOptions: resolveListenOptions2, ensureCw: ensureCw2, pathWithCw: pathWithCw2, quietSqliteWarning: quietSqliteWarning2 } = await Promise.resolve().then(() => (init_dist(), dist_exports));
4457
+ const { ensureForgeDir: ensureForgeDir2, createForgeServer: createForgeServer2, createDatabase: createDatabase2, resolveListenOptions: resolveListenOptions2, ensureCw: ensureCw2, pathWithCw: pathWithCw2, quietSqliteWarning: quietSqliteWarning2, waitForListening: waitForListening2, probePort: probePort2, portInUseMessage: portInUseMessage2 } = await Promise.resolve().then(() => (init_dist(), dist_exports));
4320
4458
  quietSqliteWarning2();
4321
4459
  const args = parseArgs(process.argv.slice(2));
4322
4460
  const { forgeDir, created } = ensureForgeDir2();
@@ -4347,14 +4485,31 @@ async function main() {
4347
4485
  localOnly
4348
4486
  });
4349
4487
  const { serveStatic } = await import("@hono/node-server/serve-static");
4350
- const { readFileSync: readFileSync9 } = await import("node:fs");
4351
- if (existsSync13(join15(consoleDist, "index.html"))) {
4488
+ const { readFileSync: readFileSync10 } = await import("node:fs");
4489
+ if (existsSync14(join15(consoleDist, "index.html"))) {
4352
4490
  server.app.use("/*", serveStatic({ root: consoleDist }));
4353
- const indexHtml = readFileSync9(join15(consoleDist, "index.html"), "utf-8");
4491
+ const indexHtml = readFileSync10(join15(consoleDist, "index.html"), "utf-8");
4354
4492
  server.app.get("*", (c) => c.html(indexHtml));
4355
4493
  }
4356
4494
  const { serve } = await import("@hono/node-server");
4357
4495
  const httpServer = serve({ fetch: server.app.fetch, port, hostname: host });
4496
+ try {
4497
+ await waitForListening2(httpServer);
4498
+ } catch (err) {
4499
+ if (err.code !== "EADDRINUSE") throw err;
4500
+ const holder = await probePort2(port);
4501
+ console.log(`
4502
+ ${portInUseMessage2(port, holder)}
4503
+ `);
4504
+ if (holder === "forge" && args.open && process.env.FORGE_NO_OPEN !== "1") {
4505
+ try {
4506
+ const open = (await import("open")).default;
4507
+ await open(`http://localhost:${port}`);
4508
+ } catch {
4509
+ }
4510
+ }
4511
+ process.exit(holder === "forge" ? 0 : 1);
4512
+ }
4358
4513
  server.attachTerminalWs(httpServer);
4359
4514
  console.log(`
4360
4515
  Forge Console running at http://localhost:${port}
@@ -4373,4 +4528,7 @@ ${authToken ? " Auth: bearer token required\n" : ""}
4373
4528
  }
4374
4529
  }
4375
4530
  }
4376
- main().catch(console.error);
4531
+ main().catch((err) => {
4532
+ console.error(err);
4533
+ process.exit(1);
4534
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-cw",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A visual dashboard for your coding agents on Claude Code, Codex, Pi or OpenCode. Installs CW for you.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,8 +39,8 @@
39
39
  "@types/node": "^22.0.0",
40
40
  "esbuild": "^0.25.0",
41
41
  "typescript": "^5.8.0",
42
- "@forge-dev/core": "0.1.0",
43
- "@forge-dev/console": "0.1.0"
42
+ "@forge-dev/console": "0.1.0",
43
+ "@forge-dev/core": "0.1.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsc",