@scrylog/cli 0.1.4 → 0.3.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/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +109 -14
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/index.ts +2 -1
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- package/src/tui/index.ts +0 -193
package/src/cli/commands.ts
CHANGED
|
@@ -9,12 +9,27 @@ import { getDaemonLogPath } from "../shared/paths";
|
|
|
9
9
|
import { createPromptSession } from "./prompt";
|
|
10
10
|
import { getServiceManagerOrThrow } from "../service/manager";
|
|
11
11
|
import { createDaemonFetch, getSocketPath } from "../shared/ipc";
|
|
12
|
-
import {
|
|
12
|
+
import { adviseProviderIcons, probeTerminalGraphics } from "../shared/terminal-capabilities";
|
|
13
|
+
import { IS_MAC, IS_WINDOWS, whichCommand } from "../shared/platform";
|
|
14
|
+
import { formatVersionDiagnostics, getInstalledVersion } from "../shared/version";
|
|
15
|
+
import { listSessions } from "@joeldmtz/prtl-core";
|
|
16
|
+
import type { ListSessionsOptions, PrtlListResult } from "@joeldmtz/prtl-core";
|
|
13
17
|
|
|
14
18
|
export interface GlobalCliOptions {
|
|
15
19
|
configPath?: string;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
type HerdrList = (options?: ListSessionsOptions) => Promise<PrtlListResult>;
|
|
23
|
+
|
|
24
|
+
export async function isHerdrAvailable(timeoutMs: number, list: HerdrList = listSessions): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
const result = await list({ provider: "herdr", timeoutMs });
|
|
27
|
+
return result.metadata.providers.herdr?.available === true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
export async function runDaemonCommand(action: string, options: GlobalCliOptions): Promise<void> {
|
|
19
34
|
const manager = getServiceManagerOrThrow();
|
|
20
35
|
if (action === "install") {
|
|
@@ -89,7 +104,7 @@ export function runTokenCommand(target: "mcp" | "api" | "both", action: string,
|
|
|
89
104
|
throw new Error(`Unknown token action: ${action}`);
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
export async function runLogsCommand(): Promise<void> {
|
|
107
|
+
export async function runLogsCommand(_options: GlobalCliOptions): Promise<void> {
|
|
93
108
|
const logPath = getDaemonLogPath();
|
|
94
109
|
process.stdout.write(`Log path: ${logPath}\n`);
|
|
95
110
|
if (!existsSync(logPath)) {
|
|
@@ -102,8 +117,32 @@ export async function runLogsCommand(): Promise<void> {
|
|
|
102
117
|
process.stdout.write(`${recent.join("\n")}\n`);
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
interface ClaudeCredentialDiagnostics {
|
|
121
|
+
state: "missing" | "expired" | "valid";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Claude credential state for doctor diagnostics, derived from the running
|
|
126
|
+
* daemon's usage state. Reports only the state — never prints token material.
|
|
127
|
+
* Returns null when the daemon is unreachable (no line is printed).
|
|
128
|
+
*/
|
|
129
|
+
async function claudeCredentialState(daemonFetch: Awaited<ReturnType<typeof createDaemonFetch>>): Promise<ClaudeCredentialDiagnostics | null> {
|
|
130
|
+
try {
|
|
131
|
+
const usage = await daemonFetch("/api/usage") as { providers?: Array<{ provider: string; status: string; errors?: string[] }> };
|
|
132
|
+
const claude = usage.providers?.find((provider) => provider.provider === "claude");
|
|
133
|
+
if (!claude) return null;
|
|
134
|
+
if (claude.status === "ok") return { state: "valid" };
|
|
135
|
+
if (claude.status === "unavailable") {
|
|
136
|
+
const expired = (claude.errors ?? []).some((message) => message.includes("expired") || message.includes("re-login"));
|
|
137
|
+
return { state: expired ? "expired" : "missing" };
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function runDoctor(options: GlobalCliOptions): Promise<void> { const configPath = getResolvedConfigPath(options.configPath);
|
|
107
146
|
const config = loadConfig(options.configPath);
|
|
108
147
|
const daemonFetch = createDaemonFetch({
|
|
109
148
|
host: config.api.host,
|
|
@@ -121,12 +160,33 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
|
|
|
121
160
|
process.stdout.write(`Retention max sessions: ${config.retention.maxSessions}\n`);
|
|
122
161
|
process.stdout.write(`Retention standalone max age hours: ${config.retention.standaloneMaxAgeHours}\n`);
|
|
123
162
|
process.stdout.write(`Usage enabled: ${config.usage.enabled}\n`);
|
|
163
|
+
if (IS_MAC && config.usage.enabled) {
|
|
164
|
+
process.stdout.write(`Keychain prompts allowed: ${config.usage.auth.allowKeychainPrompts}\n`);
|
|
165
|
+
process.stdout.write("Keychain items the daemon may read: claude-api-oauth, Claude Code-credentials, Claude Safe Storage, per-browser Safe Storage (Chrome, Brave, Edge, Arc, Vivaldi, Chromium)\n");
|
|
166
|
+
process.stdout.write("Keychain note: macOS authorization is granted per item — you have about 90 seconds to answer each dialog; click \"Always Allow\" once per item. Successful reads are memoized for the daemon's lifetime, so each item prompts at most once per daemon run, and an unanswered dialog retries after 10 minutes. Set usage.auth.allowKeychainPrompts to false to suppress all keychain prompts.\n");
|
|
167
|
+
|
|
168
|
+
const claudeCredential = await claudeCredentialState(daemonFetch);
|
|
169
|
+
if (claudeCredential?.state === "valid") {
|
|
170
|
+
process.stdout.write("Claude credential: valid (OAuth session active)\n");
|
|
171
|
+
} else if (claudeCredential?.state === "expired") {
|
|
172
|
+
process.stdout.write("Claude credential: expired — run \"claude\" and log in to refresh, then restart the daemon\n");
|
|
173
|
+
} else if (claudeCredential?.state === "missing") {
|
|
174
|
+
process.stdout.write("Claude credential: not found — run \"claude\" and log in to enable the Claude OAuth usage source\n");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
124
177
|
|
|
125
178
|
try {
|
|
126
179
|
const health = await daemonFetch("/api/health");
|
|
127
180
|
process.stdout.write(`Local daemon reachable: ${health.ok}\n`);
|
|
181
|
+
if (health.ok) {
|
|
182
|
+
const body = await health.json().catch(() => ({})) as { version?: string };
|
|
183
|
+
process.stdout.write(formatVersionDiagnostics(getInstalledVersion(), body.version));
|
|
184
|
+
} else {
|
|
185
|
+
process.stdout.write(`CLI version: ${getInstalledVersion()}\n`);
|
|
186
|
+
}
|
|
128
187
|
} catch {
|
|
129
188
|
process.stdout.write("Local daemon reachable: false\n");
|
|
189
|
+
process.stdout.write(`CLI version: ${getInstalledVersion()}\n`);
|
|
130
190
|
}
|
|
131
191
|
|
|
132
192
|
process.stdout.write(`TCP API enabled: ${config.api.tcpEnabled}\n`);
|
|
@@ -155,6 +215,27 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
|
|
|
155
215
|
process.stdout.write(`CORS origins: ${config.cors.origins.join(", ")}\n`);
|
|
156
216
|
}
|
|
157
217
|
|
|
218
|
+
const graphics = await probeTerminalGraphics();
|
|
219
|
+
const advice = adviseProviderIcons(graphics);
|
|
220
|
+
process.stdout.write("\nTerminal graphics:\n");
|
|
221
|
+
if (graphics.probeFailed) {
|
|
222
|
+
const reason = graphics.probeFailedReason === "raw-unavailable"
|
|
223
|
+
? "raw mode is unavailable in this stdin (try the interactive scrylog binary)"
|
|
224
|
+
: graphics.probeFailedReason === "no-answer"
|
|
225
|
+
? "the terminal did not answer any probe (graphics detection is unknown)"
|
|
226
|
+
: "stdin is not an interactive TTY — attach one to test";
|
|
227
|
+
process.stdout.write(` Probe: unavailable (${reason})\n`);
|
|
228
|
+
} else {
|
|
229
|
+
process.stdout.write(` Terminal: ${graphics.terminalName ?? "(no XTVERSION response)"}\n`);
|
|
230
|
+
process.stdout.write(` TERM: ${graphics.term ?? "(unset)"}\n`);
|
|
231
|
+
process.stdout.write(` Multiplexer: ${graphics.multiplexer}\n`);
|
|
232
|
+
process.stdout.write(` Kitty graphics: ${graphics.kittyGraphics}\n`);
|
|
233
|
+
process.stdout.write(` Sixel: ${graphics.sixel}\n`);
|
|
234
|
+
process.stdout.write(` Window pixel resolution: ${graphics.pixelResolution ? `${graphics.pixelResolution.width}x${graphics.pixelResolution.height}` : "(not reported)"}\n`);
|
|
235
|
+
}
|
|
236
|
+
process.stdout.write(` providerIcons config: ${config.tui.providerIcons}\n`);
|
|
237
|
+
process.stdout.write(` Recommended providerIcons: ${graphics.probeFailed ? `run again from an interactive terminal (${advice} is the safe default)` : advice}\n`);
|
|
238
|
+
|
|
158
239
|
process.stdout.write(`MCP HTTP enabled: ${config.mcp.httpEnabled}\n`);
|
|
159
240
|
if (config.mcp.httpEnabled) {
|
|
160
241
|
try {
|
|
@@ -184,6 +265,7 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
|
|
|
184
265
|
|
|
185
266
|
const tmux = Bun.spawnSync(["tmux", "-V"], { stdout: "pipe", stderr: "pipe" });
|
|
186
267
|
process.stdout.write(`tmux available: ${tmux.exitCode === 0}\n`);
|
|
268
|
+
process.stdout.write(`herdr available: ${await isHerdrAvailable(config.daemon.commandTimeoutMs)}\n`);
|
|
187
269
|
|
|
188
270
|
if (config.opencode.mode === "hooks") {
|
|
189
271
|
process.stdout.write("OpenCode mode: hooks\n");
|
|
@@ -247,6 +329,24 @@ export async function runSetupWizard(options: GlobalCliOptions): Promise<void> {
|
|
|
247
329
|
}
|
|
248
330
|
}
|
|
249
331
|
|
|
332
|
+
if (isClaudeHooksInstalled()) {
|
|
333
|
+
process.stdout.write("Claude hooks already installed.\n");
|
|
334
|
+
} else {
|
|
335
|
+
const installClaude = await prompt.yesNo("Install Claude hooks now?", false);
|
|
336
|
+
if (installClaude) {
|
|
337
|
+
await runClaudeHooksInstall(options);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (isCodexHooksInstalled()) {
|
|
342
|
+
process.stdout.write("Codex hooks already installed.\n");
|
|
343
|
+
} else {
|
|
344
|
+
const installCodex = await prompt.yesNo("Install Codex hooks now?", false);
|
|
345
|
+
if (installCodex) {
|
|
346
|
+
await runCodexHooksInstall(options);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
250
350
|
const pollInterval = await prompt.text("Daemon poll interval (ms)", String(current.daemon.pollIntervalMs));
|
|
251
351
|
const logLevel = await prompt.choose("Daemon log level", ["debug", "info", "warn", "error"], ["debug", "info", "warn", "error"].indexOf(current.daemon.logLevel));
|
|
252
352
|
next.daemon.pollIntervalMs = parseOptionalInt(pollInterval, current.daemon.pollIntervalMs);
|
|
@@ -326,6 +426,52 @@ function parseOptionalInt(value: string, fallback: number): number {
|
|
|
326
426
|
|
|
327
427
|
const OPENCODE_PLUGIN_NAME = "@scrylog/opencode-plugin";
|
|
328
428
|
|
|
429
|
+
export function isClaudeHooksInstalled(homeDir = homedir()): boolean {
|
|
430
|
+
const settingsPath = join(homeDir, ".claude", "settings.json");
|
|
431
|
+
if (!existsSync(settingsPath)) return false;
|
|
432
|
+
try {
|
|
433
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ClaudeSettings;
|
|
434
|
+
const hooks = settings.hooks ?? {};
|
|
435
|
+
return Object.values(hooks).some((rules) =>
|
|
436
|
+
rules.some((rule) =>
|
|
437
|
+
rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-claude-hook")),
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function isCodexHooksInstalled(homeDir = homedir()): boolean {
|
|
446
|
+
const codexDir = process.env.CODEX_HOME ? process.env.CODEX_HOME : join(homeDir, ".codex");
|
|
447
|
+
const hooksPath = join(codexDir, "hooks.json");
|
|
448
|
+
if (!existsSync(hooksPath)) return false;
|
|
449
|
+
try {
|
|
450
|
+
const config = JSON.parse(readFileSync(hooksPath, "utf8")) as CodexHooksConfig;
|
|
451
|
+
const hooks = config.hooks ?? {};
|
|
452
|
+
return Object.values(hooks).some((rules) =>
|
|
453
|
+
rules.some((rule) =>
|
|
454
|
+
rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-codex-hook")),
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function isOpencodePluginRegistered(homeDir = homedir()): boolean {
|
|
463
|
+
const settingsPath = join(homeDir, ".config", "opencode", "opencode.json");
|
|
464
|
+
if (!existsSync(settingsPath)) return false;
|
|
465
|
+
try {
|
|
466
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as OpenCodeSettings;
|
|
467
|
+
return (settings.plugin ?? []).some(
|
|
468
|
+
(plugin) => typeof plugin === "string" && plugin.includes(OPENCODE_PLUGIN_NAME),
|
|
469
|
+
);
|
|
470
|
+
} catch {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
329
475
|
function backupBeforeWrite(path: string): void {
|
|
330
476
|
if (!existsSync(path)) return;
|
|
331
477
|
const bak = `${path}.bak`;
|
|
@@ -397,21 +543,21 @@ export async function runClaudeHooksInstall(options: GlobalCliOptions): Promise<
|
|
|
397
543
|
mkdirSync(claudeDir, { recursive: true });
|
|
398
544
|
}
|
|
399
545
|
|
|
546
|
+
if (isClaudeHooksInstalled()) {
|
|
547
|
+
process.stdout.write("Claude hooks are already installed.\n");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
400
551
|
// Merge hooks — add scrylog entries without removing existing ones
|
|
401
552
|
const existingHooks = settings.hooks ?? {};
|
|
402
553
|
const updatedHooks: Record<string, ClaudeSettingsHookRule[]> = { ...existingHooks };
|
|
403
554
|
|
|
404
555
|
for (const event of CLAUDE_HOOK_EVENTS) {
|
|
405
556
|
const existing = updatedHooks[event] ?? [];
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
updatedHooks[event] = [
|
|
411
|
-
...existing,
|
|
412
|
-
{ matcher: "", hooks: [{ type: "command", command: hookBin }] },
|
|
413
|
-
];
|
|
414
|
-
}
|
|
557
|
+
updatedHooks[event] = [
|
|
558
|
+
...existing,
|
|
559
|
+
{ matcher: "", hooks: [{ type: "command", command: hookBin }] },
|
|
560
|
+
];
|
|
415
561
|
}
|
|
416
562
|
|
|
417
563
|
settings.hooks = updatedHooks;
|
|
@@ -490,22 +636,22 @@ export async function runCodexHooksInstall(options: GlobalCliOptions): Promise<v
|
|
|
490
636
|
mkdirSync(codexDir, { recursive: true });
|
|
491
637
|
}
|
|
492
638
|
|
|
639
|
+
if (isCodexHooksInstalled()) {
|
|
640
|
+
process.stdout.write("Codex hooks are already installed.\n");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
493
644
|
// Merge hooks — add scrylog entries without removing existing ones
|
|
494
645
|
const existingHooks = config.hooks ?? {};
|
|
495
646
|
const updatedHooks: Record<string, CodexHookRule[]> = { ...existingHooks };
|
|
496
647
|
|
|
497
648
|
for (const event of CODEX_HOOK_EVENTS) {
|
|
498
649
|
const existing = updatedHooks[event] ?? [];
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
matcher: event === "UserPromptSubmit" || event === "Stop" ? undefined : "",
|
|
505
|
-
hooks: [{ type: "command", command: hookBin, ...(event === "Stop" ? { timeout: 30 } : {}) }],
|
|
506
|
-
};
|
|
507
|
-
updatedHooks[event] = [...existing, entry];
|
|
508
|
-
}
|
|
650
|
+
const entry: CodexHookRule = {
|
|
651
|
+
matcher: event === "UserPromptSubmit" || event === "Stop" ? undefined : "",
|
|
652
|
+
hooks: [{ type: "command", command: hookBin, ...(event === "Stop" ? { timeout: 30 } : {}) }],
|
|
653
|
+
};
|
|
654
|
+
updatedHooks[event] = [...existing, entry];
|
|
509
655
|
}
|
|
510
656
|
|
|
511
657
|
config.hooks = updatedHooks;
|
|
@@ -549,9 +695,7 @@ export async function runOpenCodeHooksInstall(options: GlobalCliOptions): Promis
|
|
|
549
695
|
|
|
550
696
|
// Merge plugin entry without removing existing ones
|
|
551
697
|
const existingPlugins = settings.plugin ?? [];
|
|
552
|
-
const alreadyInstalled =
|
|
553
|
-
(plugin) => typeof plugin === "string" && plugin.includes(OPENCODE_PLUGIN_NAME),
|
|
554
|
-
);
|
|
698
|
+
const alreadyInstalled = isOpencodePluginRegistered();
|
|
555
699
|
|
|
556
700
|
if (!alreadyInstalled) {
|
|
557
701
|
settings.plugin = [...existingPlugins, OPENCODE_PLUGIN_NAME];
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { CommanderError } from "commander";
|
|
3
|
+
import { createProgram } from "./program";
|
|
4
|
+
|
|
5
|
+
export async function main(argv = process.argv): Promise<void> {
|
|
6
|
+
await createProgram().parseAsync(argv);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (import.meta.main) {
|
|
10
|
+
main().catch((error) => {
|
|
11
|
+
if (error instanceof CommanderError) {
|
|
12
|
+
process.exitCode = error.exitCode;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
process.stderr.write(`scrylog: fatal: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getInstalledVersion } from "../shared/version";
|
|
3
|
+
import {
|
|
4
|
+
runClaudeHooksInstall,
|
|
5
|
+
runCodexHooksInstall,
|
|
6
|
+
runConfigCommand,
|
|
7
|
+
runDaemonCommand,
|
|
8
|
+
runDoctor,
|
|
9
|
+
runLogsCommand,
|
|
10
|
+
runOpenCodeHooksInstall,
|
|
11
|
+
runSetupWizard,
|
|
12
|
+
runTokenCommand,
|
|
13
|
+
} from "./commands";
|
|
14
|
+
import type { GlobalCliOptions } from "./commands";
|
|
15
|
+
|
|
16
|
+
export interface CliIO {
|
|
17
|
+
writeOut(message: string): void;
|
|
18
|
+
writeErr(message: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type TuiLauncher = (configPath?: string, includeUnknown?: boolean) => void | Promise<void>;
|
|
22
|
+
|
|
23
|
+
interface CliHandlers {
|
|
24
|
+
setup(options: GlobalCliOptions): void | Promise<void>;
|
|
25
|
+
doctor(options: GlobalCliOptions): void | Promise<void>;
|
|
26
|
+
daemon(action: string, options: GlobalCliOptions): void | Promise<void>;
|
|
27
|
+
config(action: string, options: GlobalCliOptions): void | Promise<void>;
|
|
28
|
+
token(target: "mcp" | "api" | "both", action: string, options: GlobalCliOptions): void | Promise<void>;
|
|
29
|
+
logs(options: GlobalCliOptions): void | Promise<void>;
|
|
30
|
+
claudeHooksInstall(options: GlobalCliOptions): void | Promise<void>;
|
|
31
|
+
codexHooksInstall(options: GlobalCliOptions): void | Promise<void>;
|
|
32
|
+
openCodeHooksInstall(options: GlobalCliOptions): void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CreateProgramOptions {
|
|
36
|
+
io?: CliIO;
|
|
37
|
+
launchTui?: TuiLauncher;
|
|
38
|
+
handlers?: Partial<CliHandlers>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ProgramOptions {
|
|
42
|
+
config?: string;
|
|
43
|
+
includeUnknown?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HELP_TEXT = `scrylog
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
scrylog [--config <path>] [--include-unknown]
|
|
50
|
+
scrylog setup [--config <path>]
|
|
51
|
+
scrylog doctor [--config <path>]
|
|
52
|
+
scrylog daemon <install|uninstall|start|stop|restart|status> [--config <path>]
|
|
53
|
+
scrylog config <show|path> [--config <path>]
|
|
54
|
+
scrylog token <mcp|api|both> <generate|rotate|clear> [--config <path>]
|
|
55
|
+
scrylog claude hooks install [--config <path>]
|
|
56
|
+
scrylog codex hooks install [--config <path>]
|
|
57
|
+
scrylog opencode hooks install [--config <path>]
|
|
58
|
+
scrylog logs [--config <path>]
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
--config <path> Load config from an explicit path
|
|
62
|
+
--include-unknown Include sessions whose tool is unidentified
|
|
63
|
+
-h, --help Show this help text
|
|
64
|
+
-V, --version Show the installed scrylog version
|
|
65
|
+
|
|
66
|
+
See also:
|
|
67
|
+
scrylogd Background daemon (collects sessions and serves API/MCP)
|
|
68
|
+
scrylog-mcp stdio MCP server that proxies daemon data to AI agents
|
|
69
|
+
scrylog-claude-hook Claude Code lifecycle hook binary
|
|
70
|
+
scrylog-codex-hook Codex CLI lifecycle hook binary
|
|
71
|
+
|
|
72
|
+
Notes:
|
|
73
|
+
Running scrylog with no subcommand launches the TUI dashboard.
|
|
74
|
+
'scrylog claude hooks install' configures Claude Code to emit lifecycle
|
|
75
|
+
hooks that scrylog uses for real-time session state tracking.
|
|
76
|
+
'scrylog codex hooks install' configures Codex CLI to emit lifecycle
|
|
77
|
+
hooks that scrylog uses for real-time session state tracking.
|
|
78
|
+
'scrylog opencode hooks install' registers the scrylog plugin in
|
|
79
|
+
OpenCode configuration for real-time session state tracking.
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
export const TOKEN_USAGE = "Usage: scrylog token <mcp|api|both> <generate|rotate|clear>";
|
|
83
|
+
|
|
84
|
+
export function parseTokenArgs(args: Array<string | undefined>): { target: "mcp" | "api" | "both"; action: string } {
|
|
85
|
+
const [target, action] = args;
|
|
86
|
+
if (!target) throw new Error(`Missing token target (mcp, api, or both). ${TOKEN_USAGE}`);
|
|
87
|
+
if (!action) throw new Error(`Missing token action (generate, rotate, or clear). ${TOKEN_USAGE}`);
|
|
88
|
+
if (target !== "mcp" && target !== "api" && target !== "both") {
|
|
89
|
+
throw new Error(`Invalid token target: ${target}. Must be mcp, api, or both. ${TOKEN_USAGE}`);
|
|
90
|
+
}
|
|
91
|
+
return { target, action };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function defaultIO(): CliIO {
|
|
95
|
+
return {
|
|
96
|
+
writeOut: (message) => process.stdout.write(message),
|
|
97
|
+
writeErr: (message) => process.stderr.write(message),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function defaultLaunchTui(configPath?: string, includeUnknown = false): Promise<void> {
|
|
102
|
+
const tui = await import("../tui/index") as { launchTui: TuiLauncher };
|
|
103
|
+
await tui.launchTui(configPath, includeUnknown);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function globalOptions(command: Command): GlobalCliOptions {
|
|
107
|
+
const options = command.optsWithGlobals<ProgramOptions>();
|
|
108
|
+
return { configPath: options.config };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createProgram(options: CreateProgramOptions = {}): Command {
|
|
112
|
+
const io = options.io ?? defaultIO();
|
|
113
|
+
const launchTui = options.launchTui ?? defaultLaunchTui;
|
|
114
|
+
const handlers: CliHandlers = {
|
|
115
|
+
setup: runSetupWizard,
|
|
116
|
+
doctor: runDoctor,
|
|
117
|
+
daemon: runDaemonCommand,
|
|
118
|
+
config: runConfigCommand,
|
|
119
|
+
token: runTokenCommand,
|
|
120
|
+
logs: runLogsCommand,
|
|
121
|
+
claudeHooksInstall: runClaudeHooksInstall,
|
|
122
|
+
codexHooksInstall: runCodexHooksInstall,
|
|
123
|
+
openCodeHooksInstall: runOpenCodeHooksInstall,
|
|
124
|
+
...options.handlers,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const program = new Command();
|
|
128
|
+
program
|
|
129
|
+
.name("scrylog")
|
|
130
|
+
.option("--config <path>", "Load config from an explicit path")
|
|
131
|
+
.option("--include-unknown", "Include sessions whose tool is unidentified")
|
|
132
|
+
.version(`scrylog ${getInstalledVersion()}`, "-V, --version", "Show the installed scrylog version")
|
|
133
|
+
.helpOption("-h, --help", "Show this help text")
|
|
134
|
+
.configureOutput({ writeOut: io.writeOut, writeErr: io.writeErr })
|
|
135
|
+
.configureHelp({ formatHelp: () => HELP_TEXT })
|
|
136
|
+
.showHelpAfterError("(see --help for usage)")
|
|
137
|
+
.exitOverride()
|
|
138
|
+
.argument("[command]")
|
|
139
|
+
.action(async (unknownCommand?: string) => {
|
|
140
|
+
if (unknownCommand) {
|
|
141
|
+
program.error(`error: unknown command '${unknownCommand}'`, { code: "commander.unknownCommand" });
|
|
142
|
+
}
|
|
143
|
+
const rootOptions = program.opts<ProgramOptions>();
|
|
144
|
+
await launchTui(rootOptions.config ?? process.env.SCRYLOG_CONFIG, rootOptions.includeUnknown ?? false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const setup = program.command("setup").action(() => handlers.setup(globalOptions(setup)));
|
|
148
|
+
const doctor = program.command("doctor").action(() => handlers.doctor(globalOptions(doctor)));
|
|
149
|
+
const daemon = program
|
|
150
|
+
.command("daemon")
|
|
151
|
+
.argument("<action>", "install, uninstall, start, stop, restart, or status")
|
|
152
|
+
.action((action: string) => handlers.daemon(action, globalOptions(daemon)));
|
|
153
|
+
const config = program
|
|
154
|
+
.command("config")
|
|
155
|
+
.argument("<action>", "show or path")
|
|
156
|
+
.action((action: string) => handlers.config(action, globalOptions(config)));
|
|
157
|
+
const token = program
|
|
158
|
+
.command("token")
|
|
159
|
+
.argument("[target]", "mcp, api, or both")
|
|
160
|
+
.argument("[action]", "generate, rotate, or clear")
|
|
161
|
+
.action((target: string | undefined, action: string | undefined) => {
|
|
162
|
+
const parsed = parseTokenArgs([target, action]);
|
|
163
|
+
return handlers.token(parsed.target, parsed.action, globalOptions(token));
|
|
164
|
+
});
|
|
165
|
+
const logs = program.command("logs").action(() => handlers.logs(globalOptions(logs)));
|
|
166
|
+
|
|
167
|
+
const claudeInstall = program.command("claude").command("hooks").command("install");
|
|
168
|
+
claudeInstall.action(() => handlers.claudeHooksInstall(globalOptions(claudeInstall)));
|
|
169
|
+
const codexInstall = program.command("codex").command("hooks").command("install");
|
|
170
|
+
codexInstall.action(() => handlers.codexHooksInstall(globalOptions(codexInstall)));
|
|
171
|
+
const openCodeInstall = program.command("opencode").command("hooks").command("install");
|
|
172
|
+
openCodeInstall.action(() => handlers.openCodeHooksInstall(globalOptions(openCodeInstall)));
|
|
173
|
+
|
|
174
|
+
return program;
|
|
175
|
+
}
|
|
@@ -133,7 +133,7 @@ export class ClaudeCollector implements Collector {
|
|
|
133
133
|
// Don't remove hook-managed sessions — they have an authoritative lifecycle
|
|
134
134
|
// managed by hooks (SessionEnd). Only remove transcript-sourced sessions.
|
|
135
135
|
const existingSession = store.getSession(`claude:${transcript.sessionId}`);
|
|
136
|
-
if (existingSession?.detectionSource !== "claude-hook") {
|
|
136
|
+
if (existingSession?.detectionSource !== "claude-hook" && existingSession?.source === "standalone") {
|
|
137
137
|
store.removeSession(`claude:${transcript.sessionId}`);
|
|
138
138
|
}
|
|
139
139
|
}
|
|
@@ -145,6 +145,16 @@ export class ClaudeCollector implements Collector {
|
|
|
145
145
|
private processTranscript(transcript: import("./discovery").DiscoveredTranscript, _hasRunning: boolean): void {
|
|
146
146
|
const store = this.store!;
|
|
147
147
|
const config = this.config!;
|
|
148
|
+
|
|
149
|
+
// Do not re-add sessions that were explicitly ended via SessionEnd hook.
|
|
150
|
+
// The transcript file persists on disk after the session ends; without this
|
|
151
|
+
// guard, the discovery cycle would recreate the session immediately after
|
|
152
|
+
// the webhook removed it.
|
|
153
|
+
if (store.isLocalCommand(transcript.sessionId)) {
|
|
154
|
+
store.removeSession(`claude:${transcript.sessionId}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
148
158
|
const existingSession = store.getSession(`claude:${transcript.sessionId}`);
|
|
149
159
|
const watched = this.watched.get(transcript.path);
|
|
150
160
|
if (
|
|
@@ -5,6 +5,7 @@ import type { ScrylogConfig } from "../../../shared/config";
|
|
|
5
5
|
import type { Logger } from "../../../shared/logger";
|
|
6
6
|
import { maybeRunCommand } from "../../../shared/utils";
|
|
7
7
|
import { IS_WINDOWS } from "../../../shared/platform";
|
|
8
|
+
import { isKnownClaudeProbePath } from "../../../shared/session-exclusions";
|
|
8
9
|
|
|
9
10
|
export interface DiscoveredTranscript {
|
|
10
11
|
sessionId: string;
|
|
@@ -17,28 +18,6 @@ export function getClaudeDir(config: ScrylogConfig): string {
|
|
|
17
18
|
return config.claude.claudeDir ?? join(homedir(), ".claude");
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// Internal: project slugs known to generate headless probe transcripts.
|
|
21
|
-
// Third-party tools (e.g. CodexBar's ClaudeProbe on macOS) launch `claude`
|
|
22
|
-
// periodically to run slash commands like `/usage` for their own telemetry.
|
|
23
|
-
// Each run leaves a tiny transcript that scrylog misreads as an interactive
|
|
24
|
-
// "working" session, cluttering the TUI with ghosts.
|
|
25
|
-
// Not user-configurable — extend here when new probe sources are identified.
|
|
26
|
-
// Patterns are matched case-insensitively with `*` wildcards; on tiers where
|
|
27
|
-
// these tools do not run (Linux/WSL 2, Windows native) the list is a no-op.
|
|
28
|
-
const KNOWN_PROBE_SLUG_GLOBS: readonly string[] = ["*CodexBar-ClaudeProbe*"];
|
|
29
|
-
|
|
30
|
-
function matchesGlob(value: string, pattern: string): boolean {
|
|
31
|
-
const re = new RegExp(
|
|
32
|
-
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
|
|
33
|
-
"i",
|
|
34
|
-
);
|
|
35
|
-
return re.test(value);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function isKnownProbeSlug(slug: string): boolean {
|
|
39
|
-
return KNOWN_PROBE_SLUG_GLOBS.some((pattern) => matchesGlob(slug, pattern));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
21
|
/**
|
|
43
22
|
* Decodes a Claude project slug (e.g. "-Volumes-Files-Workspace-Projects-other-scrylog-feature-daemon")
|
|
44
23
|
* back to an absolute filesystem path ("/Volumes/Files/Workspace/Projects/other/scrylog-feature-daemon").
|
|
@@ -87,7 +66,7 @@ export function discoverTranscripts(claudeDir: string, hookManagedIds?: Readonly
|
|
|
87
66
|
// Skip project directories emitted by known headless probe tools
|
|
88
67
|
// (e.g. CodexBar's ClaudeProbe). Their transcripts are not interactive
|
|
89
68
|
// sessions and would otherwise show as permanent "working" ghosts.
|
|
90
|
-
if (
|
|
69
|
+
if (isKnownClaudeProbePath(projectSlug)) continue;
|
|
91
70
|
const projectPath = join(projectsDir, projectSlug);
|
|
92
71
|
try {
|
|
93
72
|
if (!statSync(projectPath).isDirectory()) continue;
|
|
@@ -122,14 +122,14 @@ function hasAnsweredRequestUserInputWork(entries: TranscriptEntry[]): boolean {
|
|
|
122
122
|
return false;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function preserveLocationMetadata(model: SessionModel, existing?: SessionModel): SessionModel {
|
|
125
|
+
export function preserveLocationMetadata(model: SessionModel, existing?: SessionModel): SessionModel {
|
|
126
126
|
if (!existing) return model;
|
|
127
|
-
|
|
128
127
|
return {
|
|
129
128
|
...model,
|
|
130
129
|
source: existing.source,
|
|
131
130
|
project: existing.project ?? model.project,
|
|
132
131
|
tmux: existing.tmux ?? model.tmux,
|
|
132
|
+
herdr: existing.herdr ?? model.herdr,
|
|
133
133
|
legacyPaneId: existing.legacyPaneId ?? model.legacyPaneId,
|
|
134
134
|
processTree: existing.processTree ?? model.processTree,
|
|
135
135
|
processChain: existing.processChain ?? model.processChain,
|
|
@@ -267,7 +267,7 @@ export class CodexCollector implements Collector {
|
|
|
267
267
|
// Don't remove hook-managed sessions — they have an authoritative lifecycle
|
|
268
268
|
// managed by hooks. Only remove transcript-sourced sessions.
|
|
269
269
|
const existingSession = store.getSession(`codex:${transcript.sessionId}`);
|
|
270
|
-
if (existingSession?.detectionSource !== "codex-hook") {
|
|
270
|
+
if (existingSession?.detectionSource !== "codex-hook" && existingSession?.source === "standalone") {
|
|
271
271
|
store.removeSession(`codex:${transcript.sessionId}`);
|
|
272
272
|
}
|
|
273
273
|
}
|
|
@@ -18,12 +18,70 @@ interface GitCommandContext {
|
|
|
18
18
|
workTree?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Freshness tracking for cached ProjectInfo entries. The branch (and only the
|
|
23
|
+
* branch) goes stale when the repo's HEAD file changes; everything else
|
|
24
|
+
* (displayName, remote, worktree status) is stable per cwd. We fingerprint the
|
|
25
|
+
* resolved HEAD file — works for regular repos, worktrees, and WSL-backed
|
|
26
|
+
* Windows checkouts, and never spawns a git subprocess.
|
|
27
|
+
*/
|
|
28
|
+
interface FreshnessMeta {
|
|
29
|
+
headPath?: string;
|
|
30
|
+
headMtimeMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const freshnessByProject = new WeakMap<ProjectInfo, FreshnessMeta>();
|
|
34
|
+
|
|
35
|
+
function freshenProject(project: ProjectInfo, gitMarkerDirectory: string | null): ProjectInfo {
|
|
36
|
+
if (!gitMarkerDirectory) return project;
|
|
37
|
+
const headPath = resolveHeadPath(gitMarkerDirectory);
|
|
38
|
+
if (!headPath) return project;
|
|
39
|
+
const mtime = readHeadMtime(headPath);
|
|
40
|
+
if (mtime === undefined) return project;
|
|
41
|
+
freshnessByProject.set(project, { headPath, headMtimeMs: mtime });
|
|
42
|
+
return project;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveHeadPath(gitMarkerDirectory: string): string | undefined {
|
|
46
|
+
const context = resolveGitCommandContext(gitMarkerDirectory);
|
|
47
|
+
const gitDir = context.gitDir
|
|
48
|
+
? resolve(context.cwd, context.gitDir)
|
|
49
|
+
: join(gitMarkerDirectory, ".git");
|
|
50
|
+
return join(gitDir, "HEAD");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readHeadMtime(headPath: string | undefined): number | undefined {
|
|
54
|
+
if (!headPath) return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return statSync(headPath).mtimeMs;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isFreshnessCurrent(project: ProjectInfo): boolean {
|
|
63
|
+
if (!project.isGitRepository) return true;
|
|
64
|
+
const meta = freshnessByProject.get(project);
|
|
65
|
+
if (!meta?.headPath || meta.headMtimeMs === undefined) return false;
|
|
66
|
+
return readHeadMtime(meta.headPath) === meta.headMtimeMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function invalidateCachedProject(cache: Map<string, ProjectInfo>, project: ProjectInfo): void {
|
|
70
|
+
for (const [key, value] of [...cache]) {
|
|
71
|
+
if (value === project) cache.delete(key);
|
|
72
|
+
}
|
|
73
|
+
freshnessByProject.delete(project);
|
|
74
|
+
}
|
|
75
|
+
|
|
21
76
|
export function resolveProjectInfo(currentPath: string, cache = new Map<string, ProjectInfo>(), timeoutMs?: number): ProjectInfo {
|
|
22
77
|
const normalizedPath = normalizePathForLookup(currentPath);
|
|
23
78
|
const cached = lookupCachedProject(normalizedPath, cache);
|
|
24
|
-
if (cached) {
|
|
79
|
+
if (cached && isFreshnessCurrent(cached)) {
|
|
25
80
|
return cached;
|
|
26
81
|
}
|
|
82
|
+
if (cached) {
|
|
83
|
+
invalidateCachedProject(cache, cached);
|
|
84
|
+
}
|
|
27
85
|
|
|
28
86
|
const visited: string[] = [];
|
|
29
87
|
const searchStart = resolveSearchStart(normalizedPath);
|
|
@@ -41,7 +99,7 @@ export function resolveProjectInfo(currentPath: string, cache = new Map<string,
|
|
|
41
99
|
cache.set(cacheKey, project);
|
|
42
100
|
}
|
|
43
101
|
|
|
44
|
-
return project;
|
|
102
|
+
return project.isGitRepository ? freshenProject(project, gitMarkerDirectory) : project;
|
|
45
103
|
}
|
|
46
104
|
|
|
47
105
|
export function buildCompactProjectLabel(project: Pick<ProjectInfo, "displayName" | "isGitRepository" | "branch" | "isWorktree">): string {
|