harlay 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.
@@ -87,7 +87,34 @@ button { font: inherit; color: inherit; background: none; border: 0; text-align:
87
87
  .label { display: flex; align-items: center; gap: 7px; padding: 8px 16px 6px; font: 500 10.5px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--fg3); }
88
88
  .label::before { content: ""; width: 5px; height: 5px; background: var(--accent); }
89
89
 
90
+ /* usage */
91
+ .usage { padding: 0 8px 4px; }
92
+ .usage .label { padding: 2px 8px 6px; }
93
+ .u-row { display: flex; gap: 10px; padding: 6px 8px; }
94
+ .u-name { flex: none; width: 86px; font-size: 12px; font-weight: 600; padding-top: 1px; }
95
+ .u-body { flex: 1; min-width: 0; }
96
+ .u-win { display: flex; align-items: center; gap: 8px; height: 18px; }
97
+ .u-lbl { flex: none; width: 34px; font: 10.5px var(--mono); color: var(--fg3); }
98
+ .u-bar { flex: 1; height: 5px; border-radius: 999px; background: rgba(var(--ink-rgb), .08); overflow: hidden; }
99
+ .u-bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
100
+ .u-bar i.warm { background: var(--warn); }
101
+ .u-bar i.hot { background: var(--err); }
102
+ .u-pct { flex: none; width: 38px; text-align: right; font: 500 11px var(--mono); color: var(--fg); }
103
+ .u-reset { flex: none; width: 92px; font: 10.5px var(--mono); color: var(--fg3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
104
+ .u-meta { margin-top: 2px; font-size: 11px; color: var(--fg3); }
105
+ .link.inline { margin: 0 0 0 6px; font-size: 11px; }
106
+ .switch:disabled { opacity: .4; }
107
+
108
+ .ask { margin: 0 8px 8px; padding: 11px 12px; border-radius: 10px; background: var(--card); border: 1px solid rgba(var(--accent-rgb), .35); }
109
+ .ask-title { font-size: 12.5px; font-weight: 600; }
110
+ .ask-body { margin-top: 3px; font-size: 11.5px; color: var(--fg2); }
111
+ .ask-btns { display: flex; gap: 6px; margin-top: 9px; }
112
+
90
113
  /* sessions */
114
+ .label.group { padding: 10px 8px 4px; }
115
+ .label.group:first-child { padding-top: 2px; }
116
+ .label.attn::before { background: var(--err); }
117
+ .label .count { margin-left: auto; letter-spacing: 0; color: var(--fg3); opacity: .8; }
91
118
  .scroll { max-height: 372px; overflow-y: auto; padding: 0 8px 8px; }
92
119
  .scroll::-webkit-scrollbar { width: 8px; }
93
120
  .scroll::-webkit-scrollbar-thumb { background: rgba(var(--ink-rgb), .15); border-radius: 8px; border: 2px solid var(--bg); }
@@ -14,7 +14,7 @@
14
14
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 20 10 4h4.3L6.8 20z" fill="currentColor" opacity=".45"/><path class="mk-b" d="M10.2 20 17.7 4H22l-7.5 16z"/></svg>
15
15
  harlay
16
16
  </div>
17
- <span class="pill" id="pill"><i></i><span id="pill-text">Watching</span></span>
17
+ <span class="pill" id="pill"><i></i><span id="pill-text">Standing by</span></span>
18
18
  <button class="icon-btn" id="toggle-theme" aria-label="Dark mode" title="Dark mode"></button>
19
19
  <button class="icon-btn" id="toggle-settings" aria-label="Settings" title="Settings"></button>
20
20
  </header>
@@ -34,7 +34,7 @@ function h(tag, props, ...kids) {
34
34
  else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
35
35
  else el.setAttribute(k, v === true ? '' : String(v));
36
36
  }
37
- for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid);
37
+ for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) el.append(kid);
38
38
  return el;
39
39
  }
40
40
 
@@ -67,9 +67,9 @@ function renderHero() {
67
67
  h('p', {}, `${plural(sessions.length, 'recent session')} · ${plural(installed, 'agent')} ready`));
68
68
  }
69
69
 
70
- function renderSession(s) {
70
+ function renderSession(s, showRepo) {
71
71
  const open = s.key === openKey;
72
- const meta = [s.toolLabel, s.repo, s.ago].filter(Boolean).join(' · ');
72
+ const meta = [s.toolLabel, showRepo ? s.repo : null, s.ago].filter(Boolean).join(' · ');
73
73
  const tool = (t) => model.tools.find((x) => x.tool === t.tool);
74
74
  const actions = s.busy
75
75
  ? h('div', { class: 'busy' }, h('span', { class: 'spin' }), 'Handing off…')
@@ -106,14 +106,78 @@ function previewBrief(s) {
106
106
  api.act({ type: 'previewBrief', key: s.key });
107
107
  }
108
108
 
109
+ /**
110
+ * Stopped and near-limit sessions first, then everything else grouped by project (most recent project first,
111
+ * as the sessions arrive newest first).
112
+ */
113
+ function groupSessions(sessions) {
114
+ const attention = sessions.filter((s) => s.status === 'limit' || s.status === 'warning');
115
+ const projects = new Map();
116
+ for (const s of sessions) {
117
+ if (attention.includes(s)) continue;
118
+ const key = s.cwd ?? '';
119
+ if (!projects.has(key)) projects.set(key, { title: s.repo ?? 'No project folder', path: s.cwd, sessions: [] });
120
+ projects.get(key).sessions.push(s);
121
+ }
122
+ return { attention, projects: [...projects.values()] };
123
+ }
124
+
125
+ /** A bar's fill. The page's CSP forbids style attributes, so the width is set through the CSSOM. */
126
+ function fill(pct, cls) {
127
+ const el = h('i', { class: cls });
128
+ el.style.width = `${Math.max(2, Math.min(100, pct))}%`;
129
+ return el;
130
+ }
131
+
132
+ /** Per-agent usage: a bar per window (≈ marks Harlay's estimate), then reset time, pace and notes. */
133
+ function renderUsage() {
134
+ const rows = model.usage ?? [];
135
+ if (!rows.length) return null;
136
+ const ask = model.askClaudeBridge
137
+ ? h('div', { class: 'ask' },
138
+ h('p', { class: 'ask-title' }, 'Show Claude’s exact usage?'),
139
+ h('p', { class: 'ask-body' }, 'Claude Code in Terminal can pass its real usage to Harlay through its status line. Harlay adds one line to ~/.claude/settings.json; a status line you already have keeps working.'),
140
+ h('div', { class: 'ask-btns' },
141
+ h('button', { class: 'btn primary', onclick: () => api.act({ type: 'answerClaudeBridge', allow: true }) }, 'Allow'),
142
+ h('button', { class: 'btn', onclick: () => api.act({ type: 'answerClaudeBridge', allow: false }) }, 'Not now')))
143
+ : null;
144
+ return h('div', { class: 'usage' },
145
+ h('div', { class: 'label' }, 'Usage'),
146
+ ask,
147
+ rows.map((r) => {
148
+ const level = (pct) => (pct >= 90 ? ' hot' : pct >= 70 ? ' warm' : '');
149
+ const meta = [r.windows.length === 1 ? r.windows[0].resets : null, r.pace, r.note].filter(Boolean);
150
+ return h('div', { class: 'u-row' },
151
+ h('div', { class: 'u-name' }, r.label),
152
+ h('div', { class: 'u-body' },
153
+ r.windows.map((w) => h('div', { class: 'u-win', title: w.exact ? 'Reported by the agent' : 'Harlay’s estimate from your session logs' },
154
+ h('span', { class: 'u-lbl' }, w.label),
155
+ h('span', { class: 'u-bar' }, fill(w.pct, level(w.pct))),
156
+ h('span', { class: 'u-pct' }, `${w.exact ? '' : '≈'}${w.pct}%`),
157
+ r.windows.length > 1 && w.resets ? h('span', { class: 'u-reset' }, w.resets.replace('resets ', '')) : null)),
158
+ meta.length || r.usagePage
159
+ ? h('div', { class: 'u-meta' }, meta.join(' · '),
160
+ r.usagePage ? h('button', { class: 'link inline', onclick: () => api.act({ type: 'openCursorUsage' }) }, 'Open usage page') : null)
161
+ : null));
162
+ }));
163
+ }
164
+
109
165
  function renderHome() {
110
166
  const { sessions } = model;
167
+ if (!sessions.length) {
168
+ $('#home').replaceChildren(renderHero(), renderUsage(), h('p', { class: 'empty' }, 'Harlay reads session logs from ~/.claude, ~/.codex and ~/.cursor on this Mac.'));
169
+ return;
170
+ }
171
+ const { attention, projects } = groupSessions(sessions);
111
172
  $('#home').replaceChildren(
112
173
  renderHero(),
113
- h('div', { class: 'label' }, 'Recent sessions'),
114
- sessions.length
115
- ? h('div', { class: 'scroll' }, sessions.map(renderSession))
116
- : h('p', { class: 'empty' }, 'Harlay reads session logs from ~/.claude, ~/.codex and ~/.cursor on this Mac.'));
174
+ renderUsage(),
175
+ h('div', { class: 'scroll' },
176
+ attention.length ? [h('div', { class: 'label group attn' }, 'Needs attention'), attention.map((s) => renderSession(s, true))] : null,
177
+ projects.map((p) => [
178
+ h('div', { class: 'label group', title: p.path }, p.title, h('span', { class: 'count' }, String(p.sessions.length))),
179
+ p.sessions.map((s) => renderSession(s, false)),
180
+ ])));
117
181
  }
118
182
 
119
183
  function seg(options, current, onPick) {
@@ -121,10 +185,10 @@ function seg(options, current, onPick) {
121
185
  h('button', { 'aria-pressed': String(o.value === current), disabled: o.disabled, onclick: () => onPick(o.value) }, o.label)));
122
186
  }
123
187
 
124
- function toggleRow(label, hint, checked, action) {
188
+ function toggleRow(label, hint, checked, action, disabled = false) {
125
189
  return h('div', { class: 'set-row' },
126
190
  h('span', { class: 'k' }, label, hint ? h('span', { class: 'hint' }, hint) : null),
127
- h('button', { class: 'switch', role: 'switch', 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
191
+ h('button', { class: 'switch', role: 'switch', disabled, 'aria-checked': String(checked), 'aria-label': label, onclick: () => api.act(action) }));
128
192
  }
129
193
 
130
194
  function renderSettings() {
@@ -149,7 +213,16 @@ function renderSettings() {
149
213
  ], settings.theme, (v) => api.act({ type: 'setTheme', theme: v })),
150
214
  h('div', { class: 'label' }, 'Behavior'),
151
215
  toggleRow('Auto-continue', 'Hand off without asking when an agent stops', settings.autoContinue, { type: 'toggleAuto' }),
152
- toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' })));
216
+ toggleRow('Open at login', null, settings.openAtLogin, { type: 'toggleLogin' }),
217
+ h('div', { class: 'label' }, 'Usage'),
218
+ toggleRow(
219
+ 'Exact Claude usage',
220
+ settings.claudeBridge === 'unavailable'
221
+ ? 'Claude Code’s ~/.claude/settings.json isn’t valid JSON, so Harlay leaves it alone.'
222
+ : 'Claude Code in Terminal passes its real usage to Harlay through its status line. Your own status line keeps working; turning this off restores it.',
223
+ settings.claudeBridge === 'on',
224
+ { type: 'toggleClaudeBridge' },
225
+ settings.claudeBridge === 'unavailable')));
153
226
  }
154
227
 
155
228
  /** Inline markdown: `code` and **bold**; everything else stays plain text. */
@@ -260,7 +333,7 @@ function render() {
260
333
  theme.title = theme.getAttribute('aria-label');
261
334
  const pill = $('#pill');
262
335
  pill.classList.toggle('auto', model.settings.autoContinue);
263
- $('#pill-text').textContent = model.settings.autoContinue ? 'Auto-continue' : 'Watching';
336
+ $('#pill-text').textContent = model.settings.autoContinue ? 'Auto-continue' : 'Standing by';
264
337
  $('#hotkey').textContent = model.hotkey;
265
338
  if (view === 'home') renderHome();
266
339
  else if (view === 'settings') renderSettings();
@@ -330,7 +403,12 @@ function demoApi() {
330
403
  { tool: 'codex', label: 'Codex', mode: 'app', app: true, terminal: true },
331
404
  { tool: 'cursor', label: 'Cursor', mode: 'app', app: true, terminal: false },
332
405
  ];
333
- const settings = { defaultTarget: 'codex', autoContinue: false, openAtLogin: true, theme: 'system' };
406
+ const settings = { defaultTarget: 'codex', autoContinue: false, openAtLogin: true, theme: 'system', claudeBridge: 'off' };
407
+ const usage = [
408
+ { tool: 'claude-code', label: 'Claude Code', windows: [{ label: '5h', pct: 72, exact: false, resets: 'resets 4:10 PM' }], pace: '≈ 38 min to limit at this pace', note: '≈ estimated from your session logs', usagePage: false },
409
+ { tool: 'codex', label: 'Codex', windows: [{ label: '5h', pct: 18, exact: true, resets: 'resets in 52m' }, { label: 'Week', pct: 41, exact: true, resets: 'resets Fri 7:12 PM' }], pace: null, note: '1 free limit reset available', usagePage: false },
410
+ { tool: 'cursor', label: 'Cursor', windows: [], pace: null, note: 'Cursor keeps no usage on this Mac', usagePage: true },
411
+ ];
334
412
  const targets = (from) => {
335
413
  const order = [settings.defaultTarget, ...tools.map((t) => t.tool).filter((t) => t !== settings.defaultTarget)];
336
414
  return order.filter((t) => t !== from).map((t) => {
@@ -342,6 +420,7 @@ function demoApi() {
342
420
  { key: 'claude-code:1', from: 'claude-code', title: 'Migrate auth to JWT', toolLabel: 'Claude Code', status: 'limit', detail: 'Limit hit · resets 6:00 PM', cwd: '/Users/you/acme-api', repo: 'acme-api', ago: 'now', busy: false },
343
421
  { key: 'codex:2', from: 'codex', title: 'Fix flaky checkout tests', toolLabel: 'Codex', status: 'warning', detail: '92% of limit used', cwd: '/Users/you/shop', repo: 'shop', ago: '4m ago', busy: false },
344
422
  { key: 'claude-code:3', from: 'claude-code', title: 'Harlay landing page', toolLabel: 'Claude Code', status: 'idle', detail: 'Idle', cwd: '/Users/you/harlay', repo: 'harlay', ago: '1h ago', busy: false },
423
+ { key: 'codex:5', from: 'codex', title: 'Rate limiter for /login', toolLabel: 'Codex', status: 'idle', detail: 'Idle', cwd: '/Users/you/acme-api', repo: 'acme-api', ago: '2h ago', busy: false },
345
424
  { key: 'cursor:4', from: 'cursor', title: 'Onboarding copy pass', toolLabel: 'Cursor', status: 'handed_off', detail: 'Handed off to Codex', cwd: '/Users/you/web', repo: 'web', ago: '3h ago', busy: false },
346
425
  ];
347
426
  let onModel = () => {};
@@ -349,6 +428,8 @@ function demoApi() {
349
428
  const sampleBrief = (title) => `---\nharlay: 1\n---\n\n# Continue: ${title}\n\n> You are continuing a coding task that another AI agent started.\n\n## 1. Goal (user's words)\n> Move auth from session cookies to JWT across \`api/\`.\n\n## 2. Next step\nMigrate the last 3 route handlers in \`api/routes/\`.\n\n## 4. Progress\n- [x] Added \`src/lib/jwt.ts\`\n- [ ] Migrate \`billing.ts\`\n\n\`\`\`text\nnpm test -- auth\n\`\`\`\n`;
350
429
  const emit = () => onModel({
351
430
  sessions: sessions.map((s) => ({ ...s, targets: targets(s.from) })),
431
+ usage,
432
+ askClaudeBridge: !settings.asked,
352
433
  settings: { ...settings },
353
434
  tools: tools.map((t) => ({ ...t })),
354
435
  hotkey: '⌥H',
@@ -367,6 +448,11 @@ function demoApi() {
367
448
  if (a.type === 'toggleAuto') settings.autoContinue = !settings.autoContinue;
368
449
  if (a.type === 'toggleLogin') settings.openAtLogin = !settings.openAtLogin;
369
450
  if (a.type === 'setTheme') settings.theme = a.theme;
451
+ if (a.type === 'answerClaudeBridge') {
452
+ settings.asked = true;
453
+ settings.claudeBridge = a.allow ? 'on' : 'off';
454
+ }
455
+ if (a.type === 'toggleClaudeBridge') settings.claudeBridge = settings.claudeBridge === 'on' ? 'off' : 'on';
370
456
  if (a.type === 'previewBrief') {
371
457
  const s = sessions.find((x) => x.key === a.key);
372
458
  setTimeout(() => onBrief({ key: s.key, title: s.title, path: `${s.cwd}/.harlay/HANDOFF.md`, text: sampleBrief(s.title) }), 600);
package/dist/harlay.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/main.ts
4
- import { homedir as homedir3 } from "node:os";
4
+ import { homedir as homedir4 } from "node:os";
5
5
  import { parseArgs } from "node:util";
6
6
 
7
7
  // ../core/src/types.ts
@@ -1610,12 +1610,70 @@ var Watcher = class extends EventEmitter {
1610
1610
  }
1611
1611
  };
1612
1612
 
1613
+ // ../core/src/usage/codex.ts
1614
+ var PACE_MS = 60 * 6e4;
1615
+ var TAIL_BYTES = 1024 * 1024;
1616
+
1617
+ // ../core/src/usage/claude.ts
1618
+ var HOUR = 36e5;
1619
+ var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
1620
+ var PACE_MS2 = 30 * 6e4;
1621
+
1622
+ // ../core/src/usage/bridge.ts
1623
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile5, rename, rm, writeFile as writeFile3 } from "node:fs/promises";
1624
+ import { join as join9 } from "node:path";
1625
+ var BRIDGE_MARKER = "# harlay-usage";
1626
+ function claudeSettingsPath(home) {
1627
+ return join9(home, ".claude", "settings.json");
1628
+ }
1629
+ var previousJson = (dataDir) => join9(dataDir, "claude-statusline.previous.json");
1630
+ var previousCommand = (dataDir) => join9(dataDir, "claude-statusline.previous");
1631
+ function isObj2(x) {
1632
+ return typeof x === "object" && x !== null && !Array.isArray(x);
1633
+ }
1634
+ function isOurs(statusLine) {
1635
+ return isObj2(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
1636
+ }
1637
+ async function readSettings(path) {
1638
+ let text;
1639
+ try {
1640
+ text = await readFile5(path, "utf8");
1641
+ } catch (e) {
1642
+ if (e.code === "ENOENT") return {};
1643
+ throw e;
1644
+ }
1645
+ const parsed = JSON.parse(text);
1646
+ if (!isObj2(parsed)) throw new Error(`${path} is not a JSON object`);
1647
+ return parsed;
1648
+ }
1649
+ async function writeAtomic(path, text) {
1650
+ const tmp = `${path}.harlay-tmp`;
1651
+ await writeFile3(tmp, text);
1652
+ await rename(tmp, path);
1653
+ }
1654
+ async function removeBridge(home, dataDir) {
1655
+ const path = claudeSettingsPath(home);
1656
+ const s = await readSettings(path);
1657
+ if (!isOurs(s.statusLine)) return;
1658
+ let prev;
1659
+ try {
1660
+ prev = JSON.parse(await readFile5(previousJson(dataDir), "utf8"));
1661
+ } catch {
1662
+ prev = void 0;
1663
+ }
1664
+ if (prev === void 0) delete s.statusLine;
1665
+ else s.statusLine = prev;
1666
+ await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
1667
+ await rm(previousJson(dataDir), { force: true });
1668
+ await rm(previousCommand(dataDir), { force: true });
1669
+ }
1670
+
1613
1671
  // src/app.ts
1614
1672
  import { execFile as execFile3 } from "node:child_process";
1615
1673
  import { existsSync } from "node:fs";
1616
- import { cp, mkdir as mkdir3, mkdtemp, rm, writeFile as writeFile3 } from "node:fs/promises";
1617
- import { tmpdir } from "node:os";
1618
- import { dirname as dirname3, join as join9, resolve } from "node:path";
1674
+ import { cp, mkdir as mkdir4, mkdtemp, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1675
+ import { homedir as homedir3, tmpdir } from "node:os";
1676
+ import { dirname as dirname3, join as join10, resolve } from "node:path";
1619
1677
  import { fileURLToPath } from "node:url";
1620
1678
  import { promisify as promisify3 } from "node:util";
1621
1679
  var run = promisify3(execFile3);
@@ -1624,19 +1682,19 @@ var APP_BUNDLE_ID = "com.corelabs.harlay";
1624
1682
  var APP_FILES = ["dist", "renderer", "assets"];
1625
1683
  function findAppSource(cliFile = fileURLToPath(import.meta.url)) {
1626
1684
  const pkg = resolve(dirname3(cliFile), "..");
1627
- for (const dir of [join9(pkg, "app"), resolve(pkg, "..", "..", "apps", "desktop")]) {
1628
- if (existsSync(join9(dir, "dist", "main.cjs")) && existsSync(join9(dir, "renderer", "popover.html")) && existsSync(join9(dir, "assets", "icon.png"))) return dir;
1685
+ for (const dir of [join10(pkg, "app"), resolve(pkg, "..", "..", "apps", "desktop")]) {
1686
+ if (existsSync(join10(dir, "dist", "main.cjs")) && existsSync(join10(dir, "renderer", "popover.html")) && existsSync(join10(dir, "assets", "icon.png"))) return dir;
1629
1687
  }
1630
1688
  return null;
1631
1689
  }
1632
1690
  async function makeIcns(png, work) {
1633
- const set = join9(work, "icon.iconset");
1634
- await mkdir3(set);
1691
+ const set = join10(work, "icon.iconset");
1692
+ await mkdir4(set);
1635
1693
  for (const size of [16, 32, 128, 256, 512]) {
1636
- await run("sips", ["-z", String(size), String(size), png, "--out", join9(set, `icon_${size}x${size}.png`)]);
1637
- await run("sips", ["-z", String(size * 2), String(size * 2), png, "--out", join9(set, `icon_${size}x${size}@2x.png`)]);
1694
+ await run("sips", ["-z", String(size), String(size), png, "--out", join10(set, `icon_${size}x${size}.png`)]);
1695
+ await run("sips", ["-z", String(size * 2), String(size * 2), png, "--out", join10(set, `icon_${size}x${size}@2x.png`)]);
1638
1696
  }
1639
- const icns = join9(work, "icon.icns");
1697
+ const icns = join10(work, "icon.icns");
1640
1698
  await run("iconutil", ["-c", "icns", set, "-o", icns]);
1641
1699
  return icns;
1642
1700
  }
@@ -1654,25 +1712,47 @@ async function quitRunningApp() {
1654
1712
  await new Promise((r) => setTimeout(r, 250));
1655
1713
  }
1656
1714
  }
1715
+ function harlayDataDir(home = homedir3()) {
1716
+ return join10(home, "Library", "Application Support", "Harlay");
1717
+ }
1718
+ async function uninstallApp(opts) {
1719
+ await quitRunningApp();
1720
+ try {
1721
+ await removeBridge(opts.home, harlayDataDir(opts.home));
1722
+ opts.log("Restored your Claude Code status line settings.\n");
1723
+ } catch (e) {
1724
+ opts.log(`Left ~/.claude/settings.json unchanged: ${e instanceof Error ? e.message : String(e)}
1725
+ `);
1726
+ }
1727
+ const app = join10(opts.dir, "Harlay.app");
1728
+ if (!existsSync(app)) {
1729
+ opts.log(`No Harlay.app in ${opts.dir}.
1730
+ `);
1731
+ return;
1732
+ }
1733
+ await run("osascript", ["-e", "on run argv", "-e", 'tell application "Finder" to delete (POSIX file (item 1 of argv) as alias)', "-e", "end run", app]);
1734
+ opts.log(`Moved ${app} to the Trash.
1735
+ `);
1736
+ }
1657
1737
  async function installApp(opts) {
1658
1738
  if (process.platform !== "darwin") throw new Error("The Harlay app is macOS only; the CLI works without it.");
1659
1739
  const source = findAppSource();
1660
1740
  if (!source) throw new Error("App files are missing from this install. Reinstall with `npm install -g harlay`.");
1661
- const work = await mkdtemp(join9(tmpdir(), "harlay-app-"));
1741
+ const work = await mkdtemp(join10(tmpdir(), "harlay-app-"));
1662
1742
  try {
1663
- const appSrc = join9(work, "src");
1664
- for (const f of APP_FILES) await cp(join9(source, f), join9(appSrc, f), { recursive: true });
1665
- await writeFile3(
1666
- join9(appSrc, "package.json"),
1743
+ const appSrc = join10(work, "src");
1744
+ for (const f of APP_FILES) await cp(join10(source, f), join10(appSrc, f), { recursive: true });
1745
+ await writeFile4(
1746
+ join10(appSrc, "package.json"),
1667
1747
  JSON.stringify({ name: "harlay-desktop", productName: "Harlay", version: opts.version, main: "dist/main.cjs" }, null, 2)
1668
1748
  );
1669
- const icon = await makeIcns(join9(source, "assets", "icon.png"), work);
1749
+ const icon = await makeIcns(join10(source, "assets", "icon.png"), work);
1670
1750
  opts.log(`Building Harlay.app (Electron ${ELECTRON_VERSION}; the first install downloads it, about 100 MB)\u2026
1671
1751
  `);
1672
1752
  const { packager } = await import("@electron/packager");
1673
1753
  const [outDir] = await packager({
1674
1754
  dir: appSrc,
1675
- out: join9(work, "out"),
1755
+ out: join10(work, "out"),
1676
1756
  name: "Harlay",
1677
1757
  platform: "darwin",
1678
1758
  arch: process.arch === "arm64" ? "arm64" : "x64",
@@ -1687,24 +1767,24 @@ async function installApp(opts) {
1687
1767
  quiet: true,
1688
1768
  extendInfo: { LSUIElement: true, NSUserNotificationAlertStyle: "alert" }
1689
1769
  });
1690
- const built = join9(outDir, "Harlay.app");
1770
+ const built = join10(outDir, "Harlay.app");
1691
1771
  await run("codesign", ["--force", "--deep", "--sign", "-", built]);
1692
- const dest = join9(opts.dir, "Harlay.app");
1772
+ const dest = join10(opts.dir, "Harlay.app");
1693
1773
  await quitRunningApp();
1694
- await mkdir3(opts.dir, { recursive: true });
1695
- await rm(dest, { recursive: true, force: true });
1774
+ await mkdir4(opts.dir, { recursive: true });
1775
+ await rm2(dest, { recursive: true, force: true });
1696
1776
  await run("ditto", [built, dest]);
1697
1777
  opts.log(`Installed ${dest}
1698
1778
  `);
1699
1779
  if (opts.open) await run("open", [dest]);
1700
1780
  return dest;
1701
1781
  } finally {
1702
- await rm(work, { recursive: true, force: true });
1782
+ await rm2(work, { recursive: true, force: true });
1703
1783
  }
1704
1784
  }
1705
1785
 
1706
1786
  // src/main.ts
1707
- var VERSION = "0.1.0";
1787
+ var VERSION = "0.1.2";
1708
1788
  var HELP = `harlay ${VERSION} \u2014 hand off a stalled AI coding session to another agent
1709
1789
 
1710
1790
  Usage:
@@ -1712,6 +1792,7 @@ Usage:
1712
1792
  harlay handoff --to <claude|codex|cursor> [--session <tool>:<id>] [--mode app|terminal] [--no-launch] [--terminal <App>]
1713
1793
  harlay watch [--auto <claude|codex|cursor>] [--mode app|terminal] [--threshold N]
1714
1794
  harlay app install [--dir <folder>] [--no-open] install or update the macOS menu bar app (default folder: /Applications)
1795
+ harlay app uninstall [--dir <folder>] quit it, restore Claude Code's status line, move it to the Trash
1715
1796
  harlay --version
1716
1797
  `;
1717
1798
  var UsageError = class extends Error {
@@ -1775,7 +1856,7 @@ async function cmdHandoff(args, readers, ctx) {
1775
1856
  if (!values.to) throw new UsageError("--to is required (claude, codex or cursor)");
1776
1857
  const target = parseTool(values.to);
1777
1858
  const mode = parseMode(values.mode, target);
1778
- const session = await pickSession(readers, values.session, ctx.cwd, ctx.env.HARLAY_HOME ?? homedir3());
1859
+ const session = await pickSession(readers, values.session, ctx.cwd, ctx.env.HARLAY_HOME ?? homedir4());
1779
1860
  const res = await runHandoff({
1780
1861
  session,
1781
1862
  target,
@@ -1836,7 +1917,19 @@ async function cmdWatch(args, readers, ctx) {
1836
1917
  }
1837
1918
  async function cmdApp(args, ctx) {
1838
1919
  const [sub, ...rest] = args;
1839
- if (sub !== "install") throw new UsageError(sub ? `Unknown app command: ${sub}` : "Usage: harlay app install [--dir <folder>] [--no-open]");
1920
+ if (sub === "uninstall") {
1921
+ const { values: values2 } = parseArgs({ args: rest, options: { dir: { type: "string" } }, strict: true });
1922
+ try {
1923
+ await uninstallApp({ dir: values2.dir ?? "/Applications", home: ctx.env.HARLAY_HOME ?? homedir4(), log: ctx.out });
1924
+ } catch (e) {
1925
+ ctx.err(`harlay: could not uninstall the app: ${e instanceof Error ? e.message : String(e)}
1926
+ `);
1927
+ return 1;
1928
+ }
1929
+ ctx.out("Uninstall the CLI too with: npm rm -g harlay\n");
1930
+ return 0;
1931
+ }
1932
+ if (sub !== "install") throw new UsageError(sub ? `Unknown app command: ${sub}` : "Usage: harlay app install|uninstall [--dir <folder>]");
1840
1933
  const { values } = parseArgs({ args: rest, options: { dir: { type: "string" }, "no-open": { type: "boolean" } }, strict: true });
1841
1934
  try {
1842
1935
  await installApp({ dir: values.dir ?? "/Applications", version: VERSION, open: !values["no-open"], log: ctx.out });
@@ -1849,7 +1942,7 @@ async function cmdApp(args, ctx) {
1849
1942
  return 0;
1850
1943
  }
1851
1944
  async function main(argv, ctx = defaultContext()) {
1852
- const readers = createReaders({ home: ctx.env.HARLAY_HOME ?? homedir3(), slugRoot: ctx.env.HARLAY_SLUG_ROOT });
1945
+ const readers = createReaders({ home: ctx.env.HARLAY_HOME ?? homedir4(), slugRoot: ctx.env.HARLAY_SLUG_ROOT });
1853
1946
  const [cmd, ...rest] = argv;
1854
1947
  try {
1855
1948
  switch (cmd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harlay",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Hand off a stalled AI coding session (Claude Code, Codex, Cursor) to another agent. CLI and macOS menu bar app.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",