@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.
- package/CHANGELOG.md +71 -0
- package/LICENSE +1 -1
- package/README.md +89 -5
- package/bin/ccs +739 -30
- 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 +121 -10
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +14 -5
- package/bin/ccs-preview.ts +75 -6
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-sanitize.ts +18 -4
- package/bin/ccs-scan.ts +81 -10
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -0
- package/bin/ccs-update-check.ts +131 -0
- package/bin/ccs-utils.ts +5 -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/docs/v0.2.0-review-notes.md +1 -1
- package/docs/v0.2.1-backlog.md +2 -2
- package/package.json +4 -3
package/bin/ccs-init.ts
CHANGED
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { readdir } from "node:fs/promises";
|
|
17
|
-
import { readFileSync
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { basename, join, relative } from "node:path";
|
|
20
20
|
import { parseDocument, isSeq } from "yaml";
|
|
21
21
|
|
|
22
22
|
import { ensureConfigDir, getPaths, loadConfig } from "./ccs-config.ts";
|
|
23
|
+
import { EditStop, runEditSync } from "./ccs-config-edit.ts";
|
|
23
24
|
import { hasShellMetachars } from "./ccs-sanitize.ts";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
24
26
|
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// Discovery
|
|
@@ -143,6 +145,27 @@ export function buildCandidates(
|
|
|
143
145
|
// YAML append (comment/format-preserving)
|
|
144
146
|
// ---------------------------------------------------------------------------
|
|
145
147
|
|
|
148
|
+
/**
|
|
149
|
+
* `repos: [{name: a, path: ~/a}]` (one line, YAML flow style) is valid, but
|
|
150
|
+
* appending to it keeps it on one line, and tools that read repos.yml line by
|
|
151
|
+
* line (`name:` and `path:` under each `- ` item) see none of it. ccs init
|
|
152
|
+
* only writes the block form, so it refuses here and leaves the file as is.
|
|
153
|
+
* `flow_style_repos` is the reason word for scripts to match on.
|
|
154
|
+
*/
|
|
155
|
+
export function rejectFlowStyleRepos(repos: unknown): void {
|
|
156
|
+
if (isSeq(repos) && repos.flow) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"flow_style_repos: repos.yml lists `repos:` on one line ([...]); ccs init only adds to a block list. " +
|
|
159
|
+
"Rewrite it with one `- name: ...` / `path: ...` item per entry, then run ccs init --auto-discover again. repos.yml unchanged",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The repos.yml source's `repos:` node, for the checks before any scan. */
|
|
165
|
+
function reposNodeOf(source: string): unknown {
|
|
166
|
+
return parseDocument(source).get("repos", true);
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
/**
|
|
147
170
|
* Append candidates to the `repos:` sequence of a repos.yml source string.
|
|
148
171
|
* parseDocument round-trips comments and the user's existing formatting —
|
|
@@ -157,6 +180,7 @@ export function appendReposToYaml(
|
|
|
157
180
|
if (!isSeq(repos)) {
|
|
158
181
|
throw new Error("repos.yml: top-level `repos` sequence not found");
|
|
159
182
|
}
|
|
183
|
+
rejectFlowStyleRepos(repos);
|
|
160
184
|
for (const c of candidates) {
|
|
161
185
|
repos.add(doc.createNode({ name: c.name, path: c.path }));
|
|
162
186
|
}
|
|
@@ -168,25 +192,41 @@ export function appendReposToYaml(
|
|
|
168
192
|
}
|
|
169
193
|
|
|
170
194
|
/**
|
|
171
|
-
* Write candidates into repos.yml
|
|
172
|
-
*
|
|
173
|
-
*
|
|
195
|
+
* Write candidates into repos.yml through the common write path
|
|
196
|
+
* (ccs-config-edit: lock, check, `.bak`, write in place, check again, put the
|
|
197
|
+
* old text back on failure) — `ccs init` must never leave a config the
|
|
198
|
+
* launcher cannot load. A one-line `repos:` is refused before anything else,
|
|
199
|
+
* even when the file fails the other checks.
|
|
174
200
|
*/
|
|
175
201
|
export function appendAndValidate(
|
|
176
202
|
reposYmlPath: string,
|
|
177
203
|
candidates: Candidate[],
|
|
178
204
|
): void {
|
|
179
|
-
|
|
180
|
-
const updated = appendReposToYaml(source, candidates);
|
|
181
|
-
writeFileSync(reposYmlPath + ".bak", source, { mode: 0o600 });
|
|
182
|
-
writeFileSync(reposYmlPath, updated, { mode: 0o600 });
|
|
205
|
+
rejectFlowStyleRepos(reposNodeOf(readFileSync(reposYmlPath, "utf-8")));
|
|
183
206
|
try {
|
|
184
|
-
|
|
207
|
+
runEditSync({
|
|
208
|
+
reposYml: reposYmlPath,
|
|
209
|
+
transform: ({ doc }) => {
|
|
210
|
+
const repos = doc.get("repos", true);
|
|
211
|
+
if (!isSeq(repos)) {
|
|
212
|
+
throw new Error("repos.yml: top-level `repos` sequence not found");
|
|
213
|
+
}
|
|
214
|
+
rejectFlowStyleRepos(repos);
|
|
215
|
+
for (const c of candidates) {
|
|
216
|
+
repos.add(doc.createNode({ name: c.name, path: c.path }));
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
185
220
|
} catch (err) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
221
|
+
if (err instanceof EditStop && err.code === "validation_after_write_failed") {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[ccs-init] validation failed after append — repos.yml restored (backup kept at .bak): ${err.message}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (err instanceof EditStop) {
|
|
227
|
+
throw new Error(`${err.code}: ${err.message}`);
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
190
230
|
}
|
|
191
231
|
}
|
|
192
232
|
|
|
@@ -208,6 +248,8 @@ async function cliDiscover(argv: string[]): Promise<number> {
|
|
|
208
248
|
const maxDepth = parseDepth(argv);
|
|
209
249
|
ensureConfigDir();
|
|
210
250
|
const config = loadConfig(); // throws ConfigError on a broken repos.yml
|
|
251
|
+
// Refuse before the $HOME scan and the picker, not after the user chose.
|
|
252
|
+
rejectFlowStyleRepos(reposNodeOf(readFileSync(getPaths().reposYml, "utf-8")));
|
|
211
253
|
const existing: ExistingConfig = {
|
|
212
254
|
names: new Set(config.repos.map((r) => r.name)),
|
|
213
255
|
paths: new Set(config.repos.map((r) => r.path)),
|
|
@@ -274,7 +316,9 @@ async function main(): Promise<number> {
|
|
|
274
316
|
}
|
|
275
317
|
}
|
|
276
318
|
|
|
277
|
-
|
|
319
|
+
// pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
|
|
320
|
+
// character a URL escapes) in its name would never match.
|
|
321
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
278
322
|
main().then(
|
|
279
323
|
// exitCode, not process.exit(): bin/ccs captures discover's candidates
|
|
280
324
|
// with $(...), a pipe, and exiting right after the writes dropped every
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccs-list-json.ts — `ccs list --json [--with-paths]`.
|
|
3
|
+
*
|
|
4
|
+
* repos.yml after ccs's checks, read directly (not the cache: no wait for a
|
|
5
|
+
* scan, nothing stale after --no-scan). Names, labels and descriptions are
|
|
6
|
+
* printed — that is what this command is for — but paths only with
|
|
7
|
+
* --with-paths, and never `command`, `cwd`, `custom` or `scan`.
|
|
8
|
+
* Contract: docs/design/machine-interface.md 2.2.
|
|
9
|
+
*
|
|
10
|
+
* Creates nothing: a missing repos.yml is exit 4 (config_missing), not a
|
|
11
|
+
* template.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { statSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
import { readConfig, type RepoEntry } from "./ccs-config.ts";
|
|
17
|
+
import {
|
|
18
|
+
cliOutcome,
|
|
19
|
+
configFailure,
|
|
20
|
+
emit,
|
|
21
|
+
okOutcome,
|
|
22
|
+
onInterrupt,
|
|
23
|
+
parseFlags,
|
|
24
|
+
wantsJson,
|
|
25
|
+
type Outcome,
|
|
26
|
+
} from "./ccs-machine.ts";
|
|
27
|
+
import { readLogicalGroups } from "./ccs-groups.ts";
|
|
28
|
+
import { findParentRepo } from "./ccs-utils.ts";
|
|
29
|
+
|
|
30
|
+
const COMMAND = "list";
|
|
31
|
+
|
|
32
|
+
function isDirectory(p: string): boolean {
|
|
33
|
+
if (!p) return false;
|
|
34
|
+
try {
|
|
35
|
+
return statSync(p).isDirectory();
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One repos[] item of the output, in the documented key order. */
|
|
42
|
+
export function listItem(
|
|
43
|
+
repo: RepoEntry,
|
|
44
|
+
parent: RepoEntry | null,
|
|
45
|
+
withPaths: boolean,
|
|
46
|
+
): Record<string, unknown> {
|
|
47
|
+
const item: Record<string, unknown> = {
|
|
48
|
+
name: repo.name,
|
|
49
|
+
label: repo.label,
|
|
50
|
+
description: repo.description,
|
|
51
|
+
icon: repo.icon,
|
|
52
|
+
kind: repo.launch ? "repo" : "group",
|
|
53
|
+
launch: repo.launch,
|
|
54
|
+
lead: repo.lead,
|
|
55
|
+
disabled: repo.disabled,
|
|
56
|
+
parent: parent ? parent.name : null,
|
|
57
|
+
tags: [...repo.tags],
|
|
58
|
+
exists: isDirectory(repo.path),
|
|
59
|
+
};
|
|
60
|
+
if (withPaths) item.path = repo.path || null;
|
|
61
|
+
return item;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `groups[]`: the logical groups in defaults.groups_dir, read now (not from
|
|
66
|
+
* the cache), with their members' repos.yml names in the file's order.
|
|
67
|
+
*/
|
|
68
|
+
function logicalGroupsJson(dir: string, repos: RepoEntry[]): Record<string, unknown>[] {
|
|
69
|
+
return readLogicalGroups(dir, repos).groups.map((g) => ({
|
|
70
|
+
id: g.id,
|
|
71
|
+
label: g.label,
|
|
72
|
+
kind: "logical",
|
|
73
|
+
members: g.members,
|
|
74
|
+
unregistered_members: g.unregistered,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The outcome of `ccs list` for these arguments (no printing). */
|
|
79
|
+
export function listOutcome(args: readonly string[]): Outcome {
|
|
80
|
+
const flags = parseFlags(args, { booleans: ["--json", "--with-paths"] });
|
|
81
|
+
if (!flags.ok) return cliOutcome(flags.code);
|
|
82
|
+
if (flags.positionals.length > 0 || flags.rest.length > 0) {
|
|
83
|
+
return cliOutcome("extra_argument");
|
|
84
|
+
}
|
|
85
|
+
if (!flags.booleans.has("--json")) {
|
|
86
|
+
return cliOutcome(
|
|
87
|
+
"json_required",
|
|
88
|
+
{},
|
|
89
|
+
"`ccs list` needs --json (to pick a repo by hand, run ccs)",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const withPaths = flags.booleans.has("--with-paths");
|
|
93
|
+
|
|
94
|
+
let repos: RepoEntry[];
|
|
95
|
+
let groupsDir: string | null = null;
|
|
96
|
+
try {
|
|
97
|
+
const result = readConfig();
|
|
98
|
+
if (!result.found) {
|
|
99
|
+
return cliOutcome(
|
|
100
|
+
"config_missing",
|
|
101
|
+
{},
|
|
102
|
+
"no repos.yml yet (config_missing); `ccs init` creates one",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
repos = result.config.repos;
|
|
106
|
+
groupsDir = result.config.settings.groupsDir;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return configFailure(err);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The list tree's rule (ccs-list): the deepest enabled entry whose path
|
|
112
|
+
// holds this one; a disabled entry is never anyone's parent.
|
|
113
|
+
const enabled = repos.filter((r) => !r.disabled);
|
|
114
|
+
return okOutcome({
|
|
115
|
+
repos: repos.map((r) => listItem(r, findParentRepo(r, enabled), withPaths)),
|
|
116
|
+
groups: groupsDir ? logicalGroupsJson(groupsDir, repos) : [],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function runList(args: readonly string[]): number {
|
|
121
|
+
const json = wantsJson(args);
|
|
122
|
+
onInterrupt(COMMAND, json);
|
|
123
|
+
return emit(COMMAND, listOutcome(args), json);
|
|
124
|
+
}
|
package/bin/ccs-list.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `launch: false`), `resume` (past session) or `separator`.
|
|
10
10
|
*
|
|
11
11
|
* Modes:
|
|
12
|
-
* --current-only sessions whose cwd matches process.cwd()
|
|
12
|
+
* --current-only sessions whose cwd matches CCS_CWD (else process.cwd())
|
|
13
13
|
* --repos-only only NEW repo rows (and group headers)
|
|
14
14
|
* --sessions-only only RESUME session rows
|
|
15
15
|
* (default) NEW first (alpha, nested repos in a tree under their
|
|
@@ -23,13 +23,17 @@
|
|
|
23
23
|
* row; no badge column, no tree glyphs, plain-word times
|
|
24
24
|
*
|
|
25
25
|
* --print-ui print the `defaults.ui` the last scan stored, then exit
|
|
26
|
+
* --print-settings print `ui=<mode>` and `update_check=<on|off>` as the
|
|
27
|
+
* last scan stored them, then exit
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
import { homedir } from "node:os";
|
|
29
31
|
import { openDb, getMeta, reposV3Columns } from "./ccs-db.ts";
|
|
32
|
+
import { groupsFromMeta } from "./ccs-groups.ts";
|
|
30
33
|
import { getPaths } from "./ccs-config.ts";
|
|
31
34
|
import { SHELL_METACHARS } from "./ccs-sanitize.ts";
|
|
32
35
|
import {
|
|
36
|
+
displayIcon,
|
|
33
37
|
truncate,
|
|
34
38
|
formatRelativeTime,
|
|
35
39
|
formatDateTime,
|
|
@@ -43,6 +47,7 @@ import {
|
|
|
43
47
|
uiFromArgv,
|
|
44
48
|
type UiMode,
|
|
45
49
|
} from "./ccs-utils.ts";
|
|
50
|
+
import { pathToFileURL } from "node:url";
|
|
46
51
|
|
|
47
52
|
// ---------------------------------------------------------------------------
|
|
48
53
|
// Constants
|
|
@@ -70,6 +75,7 @@ interface Flags {
|
|
|
70
75
|
sessionsOnly: boolean;
|
|
71
76
|
ui: UiMode;
|
|
72
77
|
printUi: boolean;
|
|
78
|
+
printSettings: boolean;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
function parseArgs(argv: string[]): Flags {
|
|
@@ -79,6 +85,7 @@ function parseArgs(argv: string[]): Flags {
|
|
|
79
85
|
sessionsOnly: argv.includes("--sessions-only"),
|
|
80
86
|
ui: uiFromArgv(argv),
|
|
81
87
|
printUi: argv.includes("--print-ui"),
|
|
88
|
+
printSettings: argv.includes("--print-settings"),
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -268,7 +275,7 @@ function repoToRow({ repo: r, parent, prefix }: GroupedRepo): string {
|
|
|
268
275
|
parentName && parentName !== displayName
|
|
269
276
|
? ` \x1b[2m‹${parentName}›\x1b[0m`
|
|
270
277
|
: "";
|
|
271
|
-
const label = `${prefix}${r.icon
|
|
278
|
+
const label = `${prefix}${displayIcon(r.icon)} ${displayName}${parentHint}${LABEL_SEP}`;
|
|
272
279
|
const desc = truncate(r.description || "", DESC_MAX);
|
|
273
280
|
if (r.launch === 0) {
|
|
274
281
|
// Group header (`launch: false`): display-only, so no badges (the header
|
|
@@ -301,6 +308,10 @@ interface SessionRowFull {
|
|
|
301
308
|
repo_command: string | null;
|
|
302
309
|
}
|
|
303
310
|
|
|
311
|
+
function isWellFormedRow(row: string): boolean {
|
|
312
|
+
return row.split("\t").length === 6 && !/[\n\r\x1f]/.test(row);
|
|
313
|
+
}
|
|
314
|
+
|
|
304
315
|
function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
|
|
305
316
|
// Mapped sessions use the registered repo icon + name — the unique `name`,
|
|
306
317
|
// deliberately not the repos.yml `label`, so session rows read the same as
|
|
@@ -318,7 +329,7 @@ function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
|
|
|
318
329
|
: s.cwd
|
|
319
330
|
? truncate(s.cwd.replace(homedir(), "~"), DESC_MAX)
|
|
320
331
|
: "(unknown)";
|
|
321
|
-
const mapIcon = s.repo_display ? s.repo_icon
|
|
332
|
+
const mapIcon = s.repo_display ? displayIcon(s.repo_icon) : "❓";
|
|
322
333
|
const label = `🔄 ${mapIcon} ${displayName}${LABEL_SEP}`;
|
|
323
334
|
const desc = truncate(s.topic || "", DESC_MAX);
|
|
324
335
|
const badges = `[${formatDateTime(s.last_activity_at)}]`;
|
|
@@ -447,17 +458,77 @@ function simpleRepoRow(n: SimpleNode, nameCol: number): string {
|
|
|
447
458
|
return [label, desc, "", `new:${r.name}`, cwd, r.command || "claude"].join("\t");
|
|
448
459
|
}
|
|
449
460
|
|
|
450
|
-
|
|
461
|
+
/** A logical group (defaults.groups_dir) with the listed repos it holds. */
|
|
462
|
+
interface LogicalView {
|
|
463
|
+
id: string;
|
|
464
|
+
label: string;
|
|
465
|
+
members: RepoRowFull[];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Match the stored groups to the listed (enabled) repos; empty groups go. */
|
|
469
|
+
function logicalGroupsOf(
|
|
470
|
+
groups: { id: string; label: string; members: string[] }[],
|
|
471
|
+
repos: RepoRowFull[],
|
|
472
|
+
): LogicalView[] {
|
|
473
|
+
const byName = new Map(repos.map((r) => [r.name, r]));
|
|
474
|
+
return groups
|
|
475
|
+
.map((g) => ({
|
|
476
|
+
id: g.id,
|
|
477
|
+
label: g.label,
|
|
478
|
+
members: g.members.map((n) => byName.get(n)).filter((r): r is RepoRowFull => r !== undefined),
|
|
479
|
+
}))
|
|
480
|
+
.filter((g) => g.members.length > 0);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The icon of a logical group's header (not a repos.yml entry, so no icon of its own). */
|
|
484
|
+
const LOGICAL_ICON = "🗂";
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Classic rows of the logical groups: a display-only header (KIND=group, key
|
|
488
|
+
* `@<id>`, so the preview looks in the logical groups first) and the members
|
|
489
|
+
* under it, drawn as a tree.
|
|
490
|
+
*/
|
|
491
|
+
function logicalGroupRows(groups: LogicalView[]): string[] {
|
|
492
|
+
const lines: string[] = [];
|
|
493
|
+
for (const g of groups) {
|
|
494
|
+
const label = `${displayIcon(LOGICAL_ICON)} ${truncate(g.label, 40)}${LABEL_SEP}`;
|
|
495
|
+
lines.push([label, "", "", `group:@${g.id}`, "", ""].join("\t"));
|
|
496
|
+
g.members.forEach((r, i) => {
|
|
497
|
+
const last = i === g.members.length - 1;
|
|
498
|
+
lines.push(repoToRow({ repo: r, parent: null, prefix: ` ${last ? "└ " : "├ "}` }));
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return lines;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Simple rows of a logical group: the bold header, then its members one level in. */
|
|
505
|
+
function simpleLogicalRows(g: LogicalView, nameCol: number): string[] {
|
|
506
|
+
const head = `${padDisplay(LOGICAL_ICON, 2)} ${truncate(g.label, 40)}`;
|
|
507
|
+
const gap = " ".repeat(Math.max(0, nameCol - displayWidth(head)));
|
|
508
|
+
const styled = `${padDisplay(LOGICAL_ICON, 2)} ${BOLD}${truncate(g.label, 40)}${RESET}${gap}`;
|
|
509
|
+
return [
|
|
510
|
+
[`${styled} `, "", "", `group:@${g.id}`, "", ""].join("\t"),
|
|
511
|
+
...g.members.map((r) => simpleRepoRow({ repo: r, parent: null, depth: 1 }, nameCol)),
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function simpleRepoRows(repos: RepoRowFull[], logical: LogicalView[] = []): string[] {
|
|
451
516
|
const { groups, projects } = simpleSections(repos);
|
|
452
517
|
const all = [...groups, ...projects];
|
|
518
|
+
const logicalHeads = logical.flatMap((g) => [
|
|
519
|
+
`${padDisplay(LOGICAL_ICON, 2)} ${truncate(g.label, 40)}`,
|
|
520
|
+
...g.members.map((r) => simpleHead({ repo: r, parent: null, depth: 1 })),
|
|
521
|
+
]);
|
|
453
522
|
const nameCol = Math.min(
|
|
454
523
|
SIMPLE_NAME_COL_MAX,
|
|
455
|
-
Math.max(0, ...all.map((n) => displayWidth(simpleHead(n)))),
|
|
524
|
+
Math.max(0, ...all.map((n) => displayWidth(simpleHead(n))), ...logicalHeads.map(displayWidth)),
|
|
456
525
|
);
|
|
457
526
|
const lines: string[] = [];
|
|
458
|
-
if (groups.length > 0) {
|
|
527
|
+
if (groups.length > 0 || logical.length > 0) {
|
|
459
528
|
lines.push(simpleHeading("グループ"));
|
|
460
529
|
for (const n of groups) lines.push(simpleRepoRow(n, nameCol));
|
|
530
|
+
// After the launch: false headers.
|
|
531
|
+
for (const g of logical) lines.push(...simpleLogicalRows(g, nameCol));
|
|
461
532
|
}
|
|
462
533
|
if (projects.length > 0) {
|
|
463
534
|
lines.push(simpleHeading("プロジェクト"));
|
|
@@ -549,10 +620,35 @@ function printUi(stateDb: string): number {
|
|
|
549
620
|
return 0;
|
|
550
621
|
}
|
|
551
622
|
|
|
623
|
+
/**
|
|
624
|
+
* `--print-settings`: what the last scan stored, as the pre-scan prints it
|
|
625
|
+
* (`ui=<mode>` and `update_check=<on|off>`). Any failure prints the defaults
|
|
626
|
+
* (classic, on): the launcher must always get an answer it can use.
|
|
627
|
+
*/
|
|
628
|
+
function printSettings(stateDb: string): number {
|
|
629
|
+
let ui: UiMode = "classic";
|
|
630
|
+
let updateCheck = "on";
|
|
631
|
+
try {
|
|
632
|
+
const handle = openDb(stateDb, { readonly: true, skipMigrate: true });
|
|
633
|
+
try {
|
|
634
|
+
const v = getMeta(handle.db, "defaults_ui");
|
|
635
|
+
if (isUiMode(v)) ui = v;
|
|
636
|
+
if (getMeta(handle.db, "defaults_update_check") === "off") updateCheck = "off";
|
|
637
|
+
} finally {
|
|
638
|
+
handle.close();
|
|
639
|
+
}
|
|
640
|
+
} catch {
|
|
641
|
+
// keep the defaults
|
|
642
|
+
}
|
|
643
|
+
process.stdout.write(`ui=${ui}\nupdate_check=${updateCheck}\n`);
|
|
644
|
+
return 0;
|
|
645
|
+
}
|
|
646
|
+
|
|
552
647
|
function main(): number {
|
|
553
648
|
const flags = parseArgs(process.argv.slice(2));
|
|
554
649
|
const paths = getPaths();
|
|
555
650
|
if (flags.printUi) return printUi(paths.stateDb);
|
|
651
|
+
if (flags.printSettings) return printSettings(paths.stateDb);
|
|
556
652
|
const simple = flags.ui === "simple";
|
|
557
653
|
// openDb() is called INSIDE the try so that a cold-start failure (state.db
|
|
558
654
|
// missing before first scan) is caught and rendered as a friendly hint
|
|
@@ -584,12 +680,17 @@ function main(): number {
|
|
|
584
680
|
ORDER BY r.name COLLATE NOCASE ASC`,
|
|
585
681
|
)
|
|
586
682
|
.all() as RepoRowFull[];
|
|
683
|
+
// defaults.groups_dir: logical groups the last scan matched to these
|
|
684
|
+
// repos (none when the setting is off).
|
|
685
|
+
const logical = logicalGroupsOf(groupsFromMeta(getMeta(db, "logical_groups")), repos);
|
|
587
686
|
if (simple) {
|
|
588
|
-
lines.push(...simpleRepoRows(repos));
|
|
687
|
+
lines.push(...simpleRepoRows(repos, logical));
|
|
589
688
|
} else {
|
|
590
689
|
for (const g of groupRepos(repos)) {
|
|
591
690
|
lines.push(repoToRow(g));
|
|
592
691
|
}
|
|
692
|
+
// After every project row, before the conversations.
|
|
693
|
+
lines.push(...logicalGroupRows(logical));
|
|
593
694
|
}
|
|
594
695
|
}
|
|
595
696
|
|
|
@@ -605,7 +706,9 @@ function main(): number {
|
|
|
605
706
|
? metaCommand
|
|
606
707
|
: "claude";
|
|
607
708
|
|
|
608
|
-
|
|
709
|
+
// CCS_CWD: the folder ccs was run in (bin/ccs starts this module from
|
|
710
|
+
// its own folder; see ccs-tsx.sh).
|
|
711
|
+
const filterCwd = flags.currentOnly ? process.env.CCS_CWD || process.cwd() : null;
|
|
609
712
|
const baseSelect = `SELECT s.uuid, s.repo_name, s.cwd, s.last_activity_at, s.topic,
|
|
610
713
|
r.name AS repo_display, r.icon AS repo_icon, r.command AS repo_command
|
|
611
714
|
FROM sessions s
|
|
@@ -660,7 +763,13 @@ function main(): number {
|
|
|
660
763
|
lines.push(...sessionRows);
|
|
661
764
|
}
|
|
662
765
|
|
|
663
|
-
|
|
766
|
+
// Last check: bin/ccs takes the columns by position and runs column 6, so a
|
|
767
|
+
// row of any other shape is dropped, never handed over.
|
|
768
|
+
const rows = lines.filter(isWellFormedRow);
|
|
769
|
+
if (rows.length !== lines.length) {
|
|
770
|
+
process.stderr.write(`[ccs-list] skipped ${lines.length - rows.length} malformed row(s)\n`);
|
|
771
|
+
}
|
|
772
|
+
process.stdout.write(rows.join("\n") + (rows.length > 0 ? "\n" : ""));
|
|
664
773
|
return 0;
|
|
665
774
|
} catch (err) {
|
|
666
775
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -690,7 +799,9 @@ function main(): number {
|
|
|
690
799
|
}
|
|
691
800
|
}
|
|
692
801
|
|
|
693
|
-
|
|
802
|
+
// pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
|
|
803
|
+
// character a URL escapes) in its name would never match.
|
|
804
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
694
805
|
// exitCode, not process.exit(): stdout is a pipe into fzf, and pipe writes
|
|
695
806
|
// are async on macOS. Exiting right after the write dropped everything past
|
|
696
807
|
// the pipe buffer — as little as 512 bytes, so the list ended after 3 repos
|