@indigo-gr/claude-code-station 0.3.3 → 0.5.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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * ccs-open.ts — `ccs open (--repo <name> | --session <uuid>) [--here |
3
+ * --new-window] [--terminal <app>] [--json] [-- <args>…]`.
4
+ *
5
+ * Starts Claude Code without fzf, through the same rules as a row picked in
6
+ * the list: the working folder must be under $HOME, the command passes the
7
+ * metacharacter check, a session ID must look like one. Callers pass a name
8
+ * or a UUID — never a path or a command. Contract:
9
+ * docs/design/machine-interface.md 4.6.
10
+ *
11
+ * This module decides; it does not start anything in this terminal.
12
+ * --here bin/ccs gives it CCS_OPEN_PLAN (a file) and CCS_OPEN_TTY.
13
+ * On success the plan (kind, key, cwd, command; NUL-separated)
14
+ * goes to that file, stdout stays empty, and bin/ccs checks it
15
+ * again and runs the command as the list does.
16
+ * --new-window macOS only: a one-shot `.command` file that runs
17
+ * `ccs open --here …` is handed to the terminal app with
18
+ * `open -a`, and this returns without waiting. The file
19
+ * deletes itself (and its folder) when it starts.
20
+ */
21
+
22
+ import {
23
+ accessSync,
24
+ constants,
25
+ existsSync,
26
+ mkdtempSync,
27
+ realpathSync,
28
+ rmSync,
29
+ statSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { homedir, tmpdir } from "node:os";
33
+ import { delimiter, dirname, join } from "node:path";
34
+ import { spawnSync } from "node:child_process";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ import {
38
+ getPaths,
39
+ isTerminalApp,
40
+ isUnderHome,
41
+ TERMINAL_APPS,
42
+ readConfig,
43
+ type CcsConfig,
44
+ type TerminalApp,
45
+ } from "./ccs-config.ts";
46
+ import {
47
+ cliOutcome,
48
+ configFailure,
49
+ emit,
50
+ hasAnswered,
51
+ okOutcome,
52
+ parseFlags,
53
+ wantsJson,
54
+ type Outcome,
55
+ } from "./ccs-machine.ts";
56
+ import { sanitizeSessionCwd, SHELL_METACHARS } from "./ccs-sanitize.ts";
57
+
58
+ const COMMAND = "open";
59
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url));
60
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
61
+
62
+ /** Each terminal app: the name `open -a` takes and where the .app may live. */
63
+ export const TERMINAL_APP_INFO: Readonly<
64
+ Record<TerminalApp, { openName: string; bundles: (home: string) => string[] }>
65
+ > = {
66
+ terminal: {
67
+ openName: "Terminal",
68
+ bundles: () => [
69
+ "/System/Applications/Utilities/Terminal.app",
70
+ "/Applications/Utilities/Terminal.app",
71
+ ],
72
+ },
73
+ };
74
+
75
+ /** The app's bundle is in one of its fixed places (Finder is never asked). */
76
+ export function terminalAppInstalled(app: TerminalApp, home: string = homedir()): boolean {
77
+ return TERMINAL_APP_INFO[app].bundles(home).some((p) => {
78
+ try {
79
+ return statSync(p).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ });
84
+ }
85
+
86
+ /** The program a command line starts is runnable (absolute, or found on PATH). */
87
+ export function programFound(command: string, pathEnv: string = process.env.PATH ?? ""): boolean {
88
+ const program = command.trim().split(/\s+/)[0] ?? "";
89
+ if (program === "") return false;
90
+ const runnable = (p: string): boolean => {
91
+ try {
92
+ accessSync(p, constants.X_OK);
93
+ return statSync(p).isFile();
94
+ } catch {
95
+ return false;
96
+ }
97
+ };
98
+ if (program.includes("/")) return runnable(program);
99
+ return pathEnv.split(delimiter).some((dir) => dir !== "" && runnable(join(dir, program)));
100
+ }
101
+
102
+ /** What to start, after every check. */
103
+ export interface LaunchPlan {
104
+ kind: "new" | "resume";
105
+ /** The repo's name, or the session's UUID. */
106
+ key: string;
107
+ cwd: string;
108
+ command: string;
109
+ }
110
+
111
+ type Resolved = { ok: true; plan: LaunchPlan } | { ok: false; outcome: Outcome };
112
+
113
+ function fail(outcome: Outcome): Resolved {
114
+ return { ok: false, outcome };
115
+ }
116
+
117
+ /** The env's launch command (CCS_CMD, then CCR_CMD), as ccs-config resolves it. */
118
+ function envCommand(): string {
119
+ return process.env.CCS_CMD || process.env.CCR_CMD || "claude";
120
+ }
121
+
122
+ /** Find the target and run every check but the terminal's (4.6 table). */
123
+ export async function resolveTarget(
124
+ target: { repo?: string; session?: string },
125
+ config: CcsConfig | null,
126
+ configError: unknown,
127
+ ): Promise<Resolved> {
128
+ let plan: LaunchPlan;
129
+ if (target.repo !== undefined) {
130
+ if (configError !== undefined) return fail(configFailure(configError));
131
+ if (!config) return fail(cliOutcome("config_missing", {}, "no repos.yml yet (config_missing)"));
132
+ const repo = config.repos.find((r) => r.name === target.repo);
133
+ if (!repo) return fail(cliOutcome("repo_not_found"));
134
+ if (!repo.launch) return fail(cliOutcome("not_launchable"));
135
+ if (repo.disabled) return fail(cliOutcome("repo_disabled"));
136
+ plan = { kind: "new", key: repo.name, cwd: repo.cwd || repo.path, command: repo.command };
137
+ } else {
138
+ const uuid = target.session ?? "";
139
+ if (!UUID_RE.test(uuid)) return fail(cliOutcome("invalid_uuid"));
140
+ const { stateDb } = getPaths();
141
+ if (!existsSync(stateDb)) {
142
+ return fail(cliOutcome("cache_missing", {}, "no cache yet (cache_missing); open the list once, or run ccs --refresh"));
143
+ }
144
+ let row: { cwd: string; repo_name: string | null } | undefined;
145
+ try {
146
+ const { openDb } = await import("./ccs-db.ts");
147
+ const handle = openDb(stateDb, { readonly: true, skipMigrate: true });
148
+ try {
149
+ row = handle.db
150
+ .prepare(`SELECT cwd, repo_name FROM sessions WHERE uuid = ?`)
151
+ .get(uuid) as typeof row;
152
+ } finally {
153
+ handle.db.close();
154
+ }
155
+ } catch {
156
+ return fail(cliOutcome("cache_not_readable", {}, "cannot open the cache (cache_not_readable)"));
157
+ }
158
+ if (!row) return fail(cliOutcome("session_not_found"));
159
+ const cwd = typeof row.cwd === "string" ? sanitizeSessionCwd(row.cwd) : null;
160
+ if (!cwd) return fail(cliOutcome("session_cwd_unknown"));
161
+ // The launch command: the matching entry's, else the default chain.
162
+ if (configError !== undefined) return fail(configFailure(configError));
163
+ const repo = config?.repos.find((r) => r.name === row?.repo_name);
164
+ const command = repo ? repo.command : (config?.defaults.command ?? envCommand());
165
+ plan = { kind: "resume", key: uuid, cwd, command };
166
+ }
167
+
168
+ if (plan.cwd === "") return fail(cliOutcome("path_not_found"));
169
+ // Unlike the list, never fall back to the current folder: a caller would
170
+ // not notice that Claude Code started somewhere else.
171
+ if (plan.cwd.split("/").includes("..") || !isUnderHome(plan.cwd)) {
172
+ return fail(cliOutcome("path_outside_home"));
173
+ }
174
+ let isDir = false;
175
+ try {
176
+ isDir = statSync(plan.cwd).isDirectory();
177
+ } catch {
178
+ isDir = false;
179
+ }
180
+ if (!isDir) return fail(cliOutcome("path_not_found"));
181
+ if (SHELL_METACHARS.test(plan.command) || plan.command.trim() === "") {
182
+ return fail(cliOutcome("command_unsafe"));
183
+ }
184
+ if (!programFound(plan.command)) {
185
+ return fail(cliOutcome("dependency_missing", { detail: "claude" }, "the launch command was not found on PATH (dependency_missing)"));
186
+ }
187
+ return { ok: true, plan };
188
+ }
189
+
190
+ /** The `.command` file's text: deletes itself, then runs `ccs open --here`. */
191
+ export function launcherScript(ccsPath: string, target: { repo?: string; session?: string }): string {
192
+ const flag = target.repo !== undefined ? `--repo '${target.repo}'` : `--session '${target.session}'`;
193
+ return (
194
+ "#!/bin/sh\n" +
195
+ 'rm -f -- "$0"; rmdir -- "$(dirname -- "$0")" 2>/dev/null\n' +
196
+ `exec '${ccsPath}' open --here ${flag}\n`
197
+ );
198
+ }
199
+
200
+ export interface OpenRun {
201
+ outcome: Outcome;
202
+ /** --here, checks passed: the plan was written; print nothing. */
203
+ silent: boolean;
204
+ }
205
+
206
+ export async function openRun(args: readonly string[]): Promise<OpenRun> {
207
+ const answer = (outcome: Outcome): OpenRun => ({ outcome, silent: false });
208
+ const flags = parseFlags(args, {
209
+ booleans: ["--json", "--here", "--new-window"],
210
+ values: ["--repo", "--session", "--terminal"],
211
+ });
212
+ if (!flags.ok) return answer(cliOutcome(flags.code));
213
+ if (flags.positionals.length > 0) return answer(cliOutcome("extra_argument"));
214
+ const repo = flags.values.get("--repo");
215
+ const session = flags.values.get("--session");
216
+ if (repo !== undefined && session !== undefined) return answer(cliOutcome("conflicting_options"));
217
+ if (repo === undefined && session === undefined) return answer(cliOutcome("missing_argument"));
218
+ const here = flags.booleans.has("--here");
219
+ const newWindow = flags.booleans.has("--new-window");
220
+ const terminalFlag = flags.values.get("--terminal");
221
+ if (here && newWindow) return answer(cliOutcome("conflicting_options"));
222
+ if (!newWindow && (terminalFlag !== undefined)) return answer(cliOutcome("conflicting_options"));
223
+ if (newWindow && flags.rest.length > 0) return answer(cliOutcome("conflicting_options"));
224
+ if (terminalFlag !== undefined && !isTerminalApp(terminalFlag)) {
225
+ return answer(cliOutcome("invalid_argument", {}, `--terminal takes ${TERMINAL_APPS.join(" or ")}`));
226
+ }
227
+ const target = { repo, session };
228
+
229
+ let config: CcsConfig | null = null;
230
+ let configError: unknown;
231
+ try {
232
+ const result = readConfig();
233
+ if (result.found) config = result.config;
234
+ } catch (err) {
235
+ configError = err;
236
+ }
237
+
238
+ if (!newWindow) {
239
+ const resolved = await resolveTarget(target, config, configError);
240
+ if (!resolved.ok) return answer(resolved.outcome);
241
+ if (process.env.CCS_OPEN_TTY !== "1") {
242
+ return answer(cliOutcome("no_tty", {}, "ccs open --here needs a terminal (no_tty); use --new-window from a script"));
243
+ }
244
+ const planFile = process.env.CCS_OPEN_PLAN;
245
+ if (!planFile) return answer(cliOutcome("internal_error", {}, "run ccs open through bin/ccs"));
246
+ const { kind, key, cwd, command } = resolved.plan;
247
+ writeFileSync(planFile, [kind, key, cwd, command].join("\0") + "\0", { mode: 0o600 });
248
+ return { outcome: okOutcome({ mode: "here" }), silent: true };
249
+ }
250
+
251
+ // --new-window (macOS only)
252
+ if (process.platform !== "darwin") {
253
+ return answer(cliOutcome("unsupported_os", {}, "ccs open --new-window works on macOS only (unsupported_os)"));
254
+ }
255
+ if (configError !== undefined && terminalFlag === undefined) {
256
+ return answer(configFailure(configError));
257
+ }
258
+ const app = (terminalFlag as TerminalApp | undefined) ?? config?.settings.terminal ?? null;
259
+ if (!app) {
260
+ return answer(cliOutcome("terminal_not_set", {}, "choose a terminal app first: ccs config set defaults.terminal terminal (terminal_not_set)"));
261
+ }
262
+ if (!terminalAppInstalled(app)) return answer(cliOutcome("terminal_app_missing"));
263
+ const resolved = await resolveTarget(target, config, configError);
264
+ if (!resolved.ok) return answer(resolved.outcome);
265
+
266
+ let ccsPath: string;
267
+ try {
268
+ ccsPath = realpathSync(join(BIN_DIR, "ccs"));
269
+ } catch {
270
+ return answer(cliOutcome("internal_error"));
271
+ }
272
+ if (SHELL_METACHARS.test(ccsPath)) return answer(cliOutcome("unsafe_install_path"));
273
+
274
+ let dir: string;
275
+ try {
276
+ // bin/ccs runs this module with TMPDIR pointing into its own temporary
277
+ // folder (removed when it exits); the launcher must outlive that.
278
+ dir = mkdtempSync(join(process.env.CCS_OPEN_TMPDIR || tmpdir(), "ccs-open-")); // 0700
279
+ writeFileSync(join(dir, "ccs-open.command"), launcherScript(ccsPath, target), { mode: 0o700 });
280
+ } catch {
281
+ return answer(cliOutcome("launch_failed", {}, "could not write the launcher file (launch_failed)"));
282
+ }
283
+ const res = spawnSync("open", ["-a", TERMINAL_APP_INFO[app].openName, join(dir, "ccs-open.command")], {
284
+ stdio: "ignore",
285
+ timeout: 15_000,
286
+ });
287
+ if (res.status !== 0) {
288
+ rmSync(dir, { recursive: true, force: true });
289
+ return answer(cliOutcome("launch_failed", {}, "the terminal app did not take the launcher (launch_failed)"));
290
+ }
291
+ return answer(okOutcome({ mode: "new_window", terminal: app }));
292
+ }
293
+
294
+ export async function runOpen(args: readonly string[]): Promise<number> {
295
+ const json = wantsJson(args);
296
+ process.on("SIGINT", () => {
297
+ if (hasAnswered()) return;
298
+ process.exitCode = emit(COMMAND, cliOutcome("interrupted"), json);
299
+ process.exit();
300
+ });
301
+ const { outcome, silent } = await openRun(args);
302
+ if (silent) return 0;
303
+ return emit(COMMAND, outcome, json);
304
+ }
@@ -8,17 +8,24 @@ import { readdir, readFile, stat } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { maskSecrets } from "./ccs-secrets.ts";
11
+ import { stripControlChars } from "./ccs-sanitize.ts";
11
12
  import {
12
13
  extractText,
13
14
  truncate,
14
15
  MAX_JSONL_SIZE,
15
16
  UUID_RE,
16
17
  } from "./ccs-utils.ts";
18
+ import { pathToFileURL } from "node:url";
17
19
 
18
20
  const PROJECTS_DIR = join(homedir(), ".claude", "projects");
19
21
  const MAX_PREVIEW_MESSAGES = 20;
20
22
  const MAX_MSG_LEN = 200;
21
23
 
24
+ /** Hide secrets before cutting: a key across the cut no longer matches its pattern. */
25
+ function safeText(text: string, max: number): string {
26
+ return truncate(maskSecrets(stripControlChars(text)), max);
27
+ }
28
+
22
29
  // extractText / truncate / UUID_RE / MAX_JSONL_SIZE come from ccs-utils.ts —
23
30
  // shared with the scanner so the preview and topic extraction can no longer
24
31
  // drift apart (review A-1/A-2). The preview passes includeToolBlocks below
@@ -128,7 +135,7 @@ export async function renderSessionPreview(
128
135
  : entry.type === "user"
129
136
  ? "👤"
130
137
  : "🤖",
131
- text: maskSecrets(truncate(text, MAX_MSG_LEN)),
138
+ text: safeText(text, MAX_MSG_LEN),
132
139
  });
133
140
  // Memory optimization: keep at most 2x display limit
134
141
  if (messages.length > MAX_PREVIEW_MESSAGES * 2) {
@@ -160,9 +167,9 @@ export async function renderSessionPreview(
160
167
  // treatment as message text: maskSecrets for credential leakage (audit
161
168
  // M-2), truncate to strip control chars / escape sequences (audit NEW-1).
162
169
  console.log("━━━ Session Info ━━━");
163
- console.log(`📁 ${maskSecrets(truncate(cwd, 200))}`);
164
- if (gitBranch) console.log(`🌿 ${maskSecrets(truncate(gitBranch, 100))}`);
165
- if (version) console.log(`📌 Claude ${maskSecrets(truncate(version, 50))}`);
170
+ console.log(`📁 ${safeText(cwd, 200)}`);
171
+ if (gitBranch) console.log(`🌿 ${safeText(gitBranch, 100)}`);
172
+ if (version) console.log(`📌 Claude ${safeText(version, 50)}`);
166
173
  console.log(`💬 ${messages.length} messages`);
167
174
  console.log("━━━ Conversation ━━━");
168
175
  console.log();
@@ -175,7 +182,9 @@ export async function renderSessionPreview(
175
182
  }
176
183
 
177
184
  // CLI bootstrap: only run when invoked directly (not when imported)
178
- if (import.meta.url === `file://${process.argv[1]}`) {
185
+ // pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
186
+ // character a URL escapes) in its name would never match.
187
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
179
188
  const sessionId = process.argv[2];
180
189
  if (!sessionId) {
181
190
  console.log("Usage: ccs-preview-session.ts <session-uuid>");
@@ -17,9 +17,11 @@ import { existsSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
18
  import { relative } from "node:path";
19
19
  import { getPaths } from "./ccs-config.ts";
20
- import { openDb, reposV3Columns, type DbHandle } from "./ccs-db.ts";
20
+ import { getMeta, openDb, reposV3Columns, type DbHandle } from "./ccs-db.ts";
21
+ import { groupsFromMeta } from "./ccs-groups.ts";
21
22
  import { renderSessionPreview } from "./ccs-preview-session.ts";
22
23
  import {
24
+ displayIcon,
23
25
  formatRelativeTime,
24
26
  formatDateTime,
25
27
  formatFriendlyTime,
@@ -29,6 +31,7 @@ import {
29
31
  tildePath,
30
32
  uiFromArgv,
31
33
  } from "./ccs-utils.ts";
34
+ import { stripControlChars, stripControlCharsKeepLines } from "./ccs-sanitize.ts";
32
35
 
33
36
  // ---------------------------------------------------------------------------
34
37
  // ANSI helpers
@@ -225,9 +228,9 @@ function renderRepoPreview(name: string, cwd: string): void {
225
228
  // showing the name.
226
229
  const displayName = repoDisplayName(repo);
227
230
  const nameHint = displayName !== repo.name ? ` ${dim(`(${repo.name})`)}` : "";
228
- console.log(`${hdr(`${repo.icon || "📁"} ${displayName}`)}${nameHint}`);
231
+ console.log(`${hdr(`${displayIcon(repo.icon)} ${displayName}`)}${nameHint}`);
229
232
  console.log(dim(repo.path));
230
- if (repo.description) console.log(repo.description);
233
+ if (repo.description) console.log(stripControlCharsKeepLines(repo.description));
231
234
  console.log("");
232
235
 
233
236
  // --- Git ---
@@ -337,7 +340,7 @@ function renderRepoPreview(name: string, cwd: string): void {
337
340
  const tags = JSON.parse(repo.tags_json || "[]") as unknown;
338
341
  if (Array.isArray(tags) && tags.length > 0) {
339
342
  console.log(divider("Tags"));
340
- console.log(tags.map(String).join(", "));
343
+ console.log(tags.map((t) => stripControlChars(String(t))).join(", "));
341
344
  }
342
345
  } catch {
343
346
  // ignore
@@ -360,7 +363,72 @@ function renderRepoPreview(name: string, cwd: string): void {
360
363
  // Group-header preview (repos.yml `launch: false`)
361
364
  // ---------------------------------------------------------------------------
362
365
 
366
+ /**
367
+ * A logical group's header (`group:@<id>`, from defaults.groups_dir): its
368
+ * label and the entries listed under it. Both looks. Never throws. False
369
+ * when the cache holds no such group (a repos.yml header may be named `@…`).
370
+ */
371
+ function renderLogicalGroup(id: string, simple: boolean): boolean {
372
+ const paths = getPaths();
373
+ let handle: DbHandle | null = null;
374
+ try {
375
+ handle = openDb(paths.stateDb, { readonly: true, skipMigrate: true });
376
+ const db = handle.db;
377
+ const group = groupsFromMeta(getMeta(db, "logical_groups")).find((g) => g.id === id);
378
+ if (!group) return false;
379
+ const listed = db
380
+ .prepare(
381
+ `SELECT name, path, description, icon, ${reposV3Columns(db)}
382
+ FROM repos WHERE disabled = 0`,
383
+ )
384
+ .all() as GroupMemberView[];
385
+ const entries = group.members
386
+ .map((n) => listed.find((r) => r.name === n))
387
+ .filter((r): r is GroupMemberView => r !== undefined);
388
+ const title = truncate(group.label, 60);
389
+ if (simple) {
390
+ cardTitle(`🗂 グループ「${title}」`);
391
+ console.log(" ここは見出しです。開くときは、下のプロジェクトをえらんでください");
392
+ console.log("");
393
+ if (entries.length === 0) {
394
+ console.log(" このグループには、一覧に出るプロジェクトがありません");
395
+ return true;
396
+ }
397
+ cardField(
398
+ "🗂 ",
399
+ `中のプロジェクト(${entries.length})`,
400
+ entries.map((r) => {
401
+ const desc = r.description ? ` ${dim(truncate(r.description, 40))}` : "";
402
+ return `${truncate(r.icon || "", 8) || "📁"} ${repoDisplayName(r)}${desc}`;
403
+ }),
404
+ );
405
+ return true;
406
+ }
407
+ console.log(hdr(`🗂 ${title}`));
408
+ console.log("");
409
+ console.log(divider("Group"));
410
+ console.log("Logical group (defaults.groups_dir) — not launchable.");
411
+ console.log("Pick one of the entries below to start a session.");
412
+ console.log("");
413
+ console.log(divider(`Entries (${entries.length})`));
414
+ for (const r of entries) {
415
+ const desc = r.description ? ` ${dim(truncate(r.description, 60))}` : "";
416
+ console.log(` ${displayIcon(r.icon)} ${repoDisplayName(r)}${desc}`);
417
+ }
418
+ return true;
419
+ } catch {
420
+ return false;
421
+ } finally {
422
+ try {
423
+ handle?.close();
424
+ } catch {
425
+ // ignore
426
+ }
427
+ }
428
+ }
429
+
363
430
  function renderGroupPreview(name: string, cwd: string): void {
431
+ if (name.startsWith("@") && renderLogicalGroup(name.slice(1), false)) return;
364
432
  const paths = getPaths();
365
433
  if (!existsSync(paths.stateDb)) {
366
434
  console.log(`${hdr("📁 " + name)}`);
@@ -395,7 +463,7 @@ function renderGroupPreview(name: string, cwd: string): void {
395
463
  return;
396
464
  }
397
465
 
398
- console.log(`${hdr(`${group.icon || "📁"} ${repoDisplayName(group)}`)}`);
466
+ console.log(`${hdr(`${displayIcon(group.icon)} ${repoDisplayName(group)}`)}`);
399
467
  console.log(dim(group.path));
400
468
  if (group.description) console.log(truncate(group.description, 200));
401
469
  console.log("");
@@ -418,7 +486,7 @@ function renderGroupPreview(name: string, cwd: string): void {
418
486
  entries.forEach((r, i) => {
419
487
  const rel = truncate(relative(group.path, r.path), 60) + "/";
420
488
  const tag = r.launch === 0 ? ` ${dim("(group)")}` : "";
421
- console.log(` ${r.icon || "📁"} ${names[i].padEnd(width)} ${dim(rel)}${tag}`);
489
+ console.log(` ${displayIcon(r.icon)} ${names[i].padEnd(width)} ${dim(rel)}${tag}`);
422
490
  });
423
491
  } catch (err) {
424
492
  const msg = err instanceof Error ? err.message : String(err);
@@ -611,6 +679,7 @@ function renderSimpleRepoCard(name: string, cwd: string): void {
611
679
  }
612
680
 
613
681
  function renderSimpleGroupCard(name: string): void {
682
+ if (name.startsWith("@") && renderLogicalGroup(name.slice(1), true)) return;
614
683
  withDb((db) => {
615
684
  const listed = db
616
685
  .prepare(