@indigo-gr/claude-code-station 0.4.1 → 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.
- package/CHANGELOG.md +37 -0
- package/README.md +67 -1
- package/bin/ccs +572 -26
- package/bin/ccs-config-edit.ts +431 -0
- package/bin/ccs-config.ts +324 -44
- package/bin/ccs-delete.sh +2 -1
- package/bin/ccs-doctor.ts +432 -0
- package/bin/ccs-groups.ts +167 -0
- package/bin/ccs-init.ts +58 -14
- package/bin/ccs-list-json.ts +124 -0
- package/bin/ccs-list.ts +107 -7
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +4 -1
- package/bin/ccs-preview.ts +68 -1
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-scan.ts +41 -4
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -0
- package/bin/package.json +3 -0
- package/docs/design/machine-interface.md +860 -0
- package/docs/design/repos-yml-schema.md +225 -76
- package/package.json +1 -1
package/bin/ccs-open.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
MAX_JSONL_SIZE,
|
|
16
16
|
UUID_RE,
|
|
17
17
|
} from "./ccs-utils.ts";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
18
19
|
|
|
19
20
|
const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
20
21
|
const MAX_PREVIEW_MESSAGES = 20;
|
|
@@ -181,7 +182,9 @@ export async function renderSessionPreview(
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
// CLI bootstrap: only run when invoked directly (not when imported)
|
|
184
|
-
|
|
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) {
|
|
185
188
|
const sessionId = process.argv[2];
|
|
186
189
|
if (!sessionId) {
|
|
187
190
|
console.log("Usage: ccs-preview-session.ts <session-uuid>");
|
package/bin/ccs-preview.ts
CHANGED
|
@@ -17,7 +17,8 @@ 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 {
|
|
23
24
|
displayIcon,
|
|
@@ -362,7 +363,72 @@ function renderRepoPreview(name: string, cwd: string): void {
|
|
|
362
363
|
// Group-header preview (repos.yml `launch: false`)
|
|
363
364
|
// ---------------------------------------------------------------------------
|
|
364
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
|
+
|
|
365
430
|
function renderGroupPreview(name: string, cwd: string): void {
|
|
431
|
+
if (name.startsWith("@") && renderLogicalGroup(name.slice(1), false)) return;
|
|
366
432
|
const paths = getPaths();
|
|
367
433
|
if (!existsSync(paths.stateDb)) {
|
|
368
434
|
console.log(`${hdr("📁 " + name)}`);
|
|
@@ -613,6 +679,7 @@ function renderSimpleRepoCard(name: string, cwd: string): void {
|
|
|
613
679
|
}
|
|
614
680
|
|
|
615
681
|
function renderSimpleGroupCard(name: string): void {
|
|
682
|
+
if (name.startsWith("@") && renderLogicalGroup(name.slice(1), true)) return;
|
|
616
683
|
withDb((db) => {
|
|
617
684
|
const listed = db
|
|
618
685
|
.prepare(
|