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.
- package/app/dist/main.cjs +823 -55
- package/app/renderer/popover.css +27 -0
- package/app/renderer/popover.html +1 -1
- package/app/renderer/popover.js +98 -12
- package/dist/harlay.mjs +120 -27
- package/package.json +1 -1
package/app/renderer/popover.css
CHANGED
|
@@ -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">
|
|
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>
|
package/app/renderer/popover.js
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
? h('div', { class: '
|
|
116
|
-
|
|
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' : '
|
|
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
|
|
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
|
|
1617
|
-
import { tmpdir } from "node:os";
|
|
1618
|
-
import { dirname as dirname3, join as
|
|
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 [
|
|
1628
|
-
if (existsSync(
|
|
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 =
|
|
1634
|
-
await
|
|
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",
|
|
1637
|
-
await run("sips", ["-z", String(size * 2), String(size * 2), png, "--out",
|
|
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 =
|
|
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(
|
|
1741
|
+
const work = await mkdtemp(join10(tmpdir(), "harlay-app-"));
|
|
1662
1742
|
try {
|
|
1663
|
-
const appSrc =
|
|
1664
|
-
for (const f of APP_FILES) await cp(
|
|
1665
|
-
await
|
|
1666
|
-
|
|
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(
|
|
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:
|
|
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 =
|
|
1770
|
+
const built = join10(outDir, "Harlay.app");
|
|
1691
1771
|
await run("codesign", ["--force", "--deep", "--sign", "-", built]);
|
|
1692
|
-
const dest =
|
|
1772
|
+
const dest = join10(opts.dir, "Harlay.app");
|
|
1693
1773
|
await quitRunningApp();
|
|
1694
|
-
await
|
|
1695
|
-
await
|
|
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
|
|
1782
|
+
await rm2(work, { recursive: true, force: true });
|
|
1703
1783
|
}
|
|
1704
1784
|
}
|
|
1705
1785
|
|
|
1706
1786
|
// src/main.ts
|
|
1707
|
-
var VERSION = "0.1.
|
|
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 ??
|
|
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
|
|
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 ??
|
|
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