ccdesk 0.2.2 → 0.2.3

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.
Files changed (130) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +6 -3
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +15 -21
  5. package/dist/core/backups.d.ts +19 -0
  6. package/dist/{server → core}/backups.js +37 -45
  7. package/dist/core/core.d.ts +96 -0
  8. package/dist/core/core.js +57 -0
  9. package/dist/core/index.d.ts +22 -0
  10. package/dist/core/index.js +38 -0
  11. package/dist/core/mcp-edit.d.ts +25 -0
  12. package/dist/{server → core}/mcp-edit.js +115 -98
  13. package/dist/core/mutations.d.ts +20 -0
  14. package/dist/{server → core}/mutations.js +8 -15
  15. package/dist/core/open.d.ts +31 -0
  16. package/dist/{server → core}/open.js +39 -12
  17. package/dist/core/parse-edit.d.ts +12 -0
  18. package/dist/core/parse-edit.js +64 -0
  19. package/dist/core/reference.d.ts +3 -0
  20. package/dist/core/reference.js +11 -0
  21. package/dist/core/scan/compare.d.ts +7 -0
  22. package/dist/{server → core}/scan/compare.js +2 -2
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +55 -32
  25. package/dist/core/scan/data.d.ts +8 -0
  26. package/dist/core/scan/data.js +18 -0
  27. package/dist/core/scan/env.d.ts +26 -0
  28. package/dist/{server → core}/scan/env.js +7 -3
  29. package/dist/core/scan/files.d.ts +68 -0
  30. package/dist/core/scan/files.js +270 -0
  31. package/dist/core/scan/git.d.ts +14 -0
  32. package/dist/core/scan/git.js +98 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +115 -81
  35. package/dist/core/scan/instructions.d.ts +54 -0
  36. package/dist/{server → core}/scan/instructions.js +141 -73
  37. package/dist/core/scan/inventory.d.ts +38 -0
  38. package/dist/{server → core}/scan/inventory.js +134 -115
  39. package/dist/core/scan/mcp.d.ts +28 -0
  40. package/dist/{server → core}/scan/mcp.js +151 -94
  41. package/dist/core/scan/memory.d.ts +8 -0
  42. package/dist/{server → core}/scan/memory.js +8 -8
  43. package/dist/core/scan/path-test.d.ts +9 -0
  44. package/dist/core/scan/path-test.js +156 -0
  45. package/dist/core/scan/permissions.d.ts +31 -0
  46. package/dist/{server → core}/scan/permissions.js +5 -8
  47. package/dist/core/scan/plugins.d.ts +39 -0
  48. package/dist/{server → core}/scan/plugins.js +36 -27
  49. package/dist/core/scan/projects.d.ts +9 -0
  50. package/dist/{server → core}/scan/projects.js +5 -4
  51. package/dist/core/scan/sandbox.d.ts +14 -0
  52. package/dist/{server → core}/scan/sandbox.js +87 -59
  53. package/dist/core/scan/settings.d.ts +35 -0
  54. package/dist/{server → core}/scan/settings.js +88 -92
  55. package/dist/core/scan/tools.d.ts +10 -0
  56. package/dist/{server → core}/scan/tools.js +28 -20
  57. package/dist/core/scan/usage.d.ts +25 -0
  58. package/dist/{server → core}/scan/usage.js +117 -95
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +44 -0
  62. package/dist/core/secrets.js +187 -0
  63. package/dist/core/settings-edit.d.ts +9 -0
  64. package/dist/{server → core}/settings-edit.js +91 -112
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +54 -17
  67. package/dist/core/write.d.ts +119 -0
  68. package/dist/{server → core}/write.js +63 -8
  69. package/dist/server/args.d.ts +32 -0
  70. package/dist/server/args.js +106 -0
  71. package/dist/server/cli.d.ts +2 -0
  72. package/dist/server/cli.js +58 -83
  73. package/dist/server/commands.d.ts +8 -0
  74. package/dist/server/commands.js +39 -0
  75. package/dist/server/server.d.ts +12 -0
  76. package/dist/server/server.js +80 -195
  77. package/dist/server/start.d.ts +13 -0
  78. package/dist/server/start.js +21 -0
  79. package/dist/shared/compare.d.ts +25 -0
  80. package/dist/shared/memory.d.ts +2 -0
  81. package/dist/shared/requests.d.ts +104 -0
  82. package/dist/shared/requests.js +73 -0
  83. package/dist/shared/scopes.d.ts +10 -0
  84. package/dist/shared/settings-catalog.json +2199 -0
  85. package/dist/shared/types.d.ts +530 -0
  86. package/dist/shared/types.js +11 -0
  87. package/dist/ui/assets/index-CH1HoC38.js +60 -0
  88. package/dist/ui/index.html +1 -1
  89. package/dist/view/diff.d.ts +16 -0
  90. package/dist/view/diff.js +85 -0
  91. package/dist/view/format.d.ts +17 -0
  92. package/dist/view/format.js +47 -0
  93. package/dist/view/index.d.ts +11 -0
  94. package/dist/view/index.js +13 -0
  95. package/dist/view/lines.d.ts +9 -0
  96. package/dist/view/lines.js +12 -0
  97. package/dist/view/path-test.d.ts +3 -0
  98. package/dist/view/path-test.js +7 -0
  99. package/dist/view/sections/catalog.d.ts +9 -0
  100. package/dist/view/sections/catalog.js +47 -0
  101. package/dist/view/sections/extensions.d.ts +1 -0
  102. package/dist/view/sections/extensions.js +48 -0
  103. package/dist/view/sections/hooks.d.ts +1 -0
  104. package/dist/view/sections/hooks.js +32 -0
  105. package/dist/view/sections/index.d.ts +6 -0
  106. package/dist/view/sections/index.js +27 -0
  107. package/dist/view/sections/instructions.d.ts +6 -0
  108. package/dist/view/sections/instructions.js +38 -0
  109. package/dist/view/sections/mcp.d.ts +3 -0
  110. package/dist/view/sections/mcp.js +38 -0
  111. package/dist/view/sections/memory.d.ts +1 -0
  112. package/dist/view/sections/memory.js +31 -0
  113. package/dist/view/sections/permissions.d.ts +17 -0
  114. package/dist/view/sections/permissions.js +87 -0
  115. package/dist/view/sections/settings.d.ts +1 -0
  116. package/dist/view/sections/settings.js +33 -0
  117. package/dist/view/sections/tools.d.ts +6 -0
  118. package/dist/view/sections/tools.js +57 -0
  119. package/dist/view/sections/types.d.ts +123 -0
  120. package/dist/view/sections/types.js +10 -0
  121. package/dist/view/settings.d.ts +12 -0
  122. package/dist/view/settings.js +31 -0
  123. package/dist/view/usage.d.ts +8 -0
  124. package/dist/view/usage.js +18 -0
  125. package/package.json +13 -2
  126. package/dist/server/scan/files.js +0 -480
  127. package/dist/server/scan/path-test.js +0 -136
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. package/dist/ui/assets/index-Dg9dxngR.js +0 -60
@@ -1,7 +1,8 @@
1
1
  import { basename, join } from "node:path";
2
2
  import {} from "../../shared/types.js";
3
3
  import {} from "./env.js";
4
- import { isWithin, parseFrontmatter, readTextFile, resolvedPath } from "./files.js";
4
+ import { isWithin, readTextFile, resolvedPath } from "./files.js";
5
+ import { parseFrontmatter } from "./data.js";
5
6
  /**
6
7
  * Imports can name any file, so a cloned repo's CLAUDE.md could point at private files.
7
8
  * ccdesk shows an import's text only from the project, your Claude folders and ~/.agents.
@@ -59,20 +60,39 @@ export async function buildContext(input) {
59
60
  note: "Part of every session; their size depends on your Claude Code version and model",
60
61
  }, null),
61
62
  ];
62
- const style = effective.find((s) => s.key === "outputStyle")?.value;
63
- if (typeof style === "string" && !BUILT_IN_STYLES.includes(style.toLowerCase())) {
64
- const custom = inventory.outputStyles.find((s) => s.name === style);
65
- blocks.push(custom
66
- ? block({ kind: "output-style", path: custom.path, scope: custom.scope }, parseFrontmatter(custom.text).body)
67
- : block({
68
- kind: "not-counted",
69
- path: `Output style "${style}"`,
70
- scope: "user",
71
- note: "ccdesk could not find this style's file",
72
- }, null));
63
+ const style = outputStyleBlock(effective, inventory);
64
+ if (style)
65
+ blocks.push(style);
66
+ blocks.push(...(await instructionBlocks(env, projectRoot, instructions)));
67
+ const index = memory?.index;
68
+ if (memory?.loaded && index) {
69
+ blocks.push(block({ kind: "memory-index", path: index.path, scope: "memory" }, index.text.split("\n").slice(0, index.loadedLines).join("\n")));
73
70
  }
74
- // A file both imported and loaded on its own (a rule, an AGENTS.md) is counted once, as itself,
75
- // also when it is loaded through a link, to it or to its folder, and imported by its real path.
71
+ blocks.push(...listingBlocks(inventory));
72
+ return blocks;
73
+ }
74
+ /** The output style's text, when outputStyle names one that is not built in. */
75
+ function outputStyleBlock(effective, inventory) {
76
+ const style = effective.find((s) => s.key === "outputStyle")?.value;
77
+ if (typeof style !== "string" || BUILT_IN_STYLES.includes(style.toLowerCase()))
78
+ return null;
79
+ const custom = inventory.outputStyles.find((s) => s.name === style);
80
+ if (custom)
81
+ return block({ kind: "output-style", path: custom.path, scope: custom.scope }, parseFrontmatter(custom.text).body);
82
+ return block({
83
+ kind: "not-counted",
84
+ path: `Output style "${style}"`,
85
+ scope: "user",
86
+ note: "ccdesk could not find this style's file",
87
+ }, null);
88
+ }
89
+ /**
90
+ * The loaded instruction files, each after the files it imports. A file both imported and loaded
91
+ * on its own (a rule, an AGENTS.md) is counted once, as itself, also when it is loaded through a
92
+ * link, to it or to its folder, and imported by its real path.
93
+ */
94
+ async function instructionBlocks(env, projectRoot, instructions) {
95
+ const blocks = [];
76
96
  const loaded = new Set((await Promise.all(instructions
77
97
  .filter((i) => i.status === "loaded")
78
98
  .map(async (i) => [i.path, i.realPath ?? (await resolvedPath(i.path))]))).flat());
@@ -85,20 +105,7 @@ export async function buildContext(input) {
85
105
  continue;
86
106
  added.add(ref.resolved);
87
107
  await importsOf(file, ref.resolved);
88
- const fields = { kind: "import", path: ref.resolved, scope: file.scope, via };
89
- if (ref.pending)
90
- blocks.push(block({ ...fields, note: "Not loaded until you approve imports from outside this project" }, null));
91
- else if (ref.tooLarge)
92
- blocks.push(block({ ...fields, note: "Over 4 MiB, so Claude Code does not load it" }, null));
93
- else if (!showable(env, projectRoot, ref.resolved))
94
- blocks.push(block({
95
- ...fields,
96
- note: "Outside this project and your Claude folders, so ccdesk does not show or count it. Claude Code loads it.",
97
- }, null));
98
- else {
99
- const text = await readTextFile(env, ref.resolved);
100
- blocks.push(block(fields, text ? injectedText(text.text) : null));
101
- }
108
+ blocks.push(await importBlock(env, projectRoot, file, ref, via));
102
109
  }
103
110
  };
104
111
  for (const file of instructions) {
@@ -107,10 +114,26 @@ export async function buildContext(input) {
107
114
  await importsOf(file, file.path);
108
115
  blocks.push(block({ kind: "instruction", path: file.path, scope: file.scope }, injectedText(file.text)));
109
116
  }
110
- const index = memory?.index;
111
- if (memory?.loaded && index) {
112
- blocks.push(block({ kind: "memory-index", path: index.path, scope: "memory" }, index.text.split("\n").slice(0, index.loadedLines).join("\n")));
113
- }
117
+ return blocks;
118
+ }
119
+ /** One imported file: its text, or why ccdesk does not count it. */
120
+ async function importBlock(env, projectRoot, file, ref, via) {
121
+ const fields = { kind: "import", path: ref.resolved, scope: file.scope, via };
122
+ if (ref.pending)
123
+ return block({ ...fields, note: "Not loaded until you approve imports from outside this project" }, null);
124
+ if (ref.tooLarge)
125
+ return block({ ...fields, note: "Over 4 MiB, so Claude Code does not load it" }, null);
126
+ if (!showable(env, projectRoot, ref.resolved))
127
+ return block({
128
+ ...fields,
129
+ note: "Outside this project and your Claude folders, so ccdesk does not show or count it. Claude Code loads it.",
130
+ }, null);
131
+ const text = await readTextFile(env, ref.resolved);
132
+ return block(fields, text ? injectedText(text.text) : null);
133
+ }
134
+ /** The skill listing, the subagent descriptions and the MCP tool names Claude sees. */
135
+ function listingBlocks(inventory) {
136
+ const blocks = [];
114
137
  const listed = [...inventory.skills, ...inventory.commands].filter((s) => s.listed !== false && !s.shadowedBy);
115
138
  const listing = skillListing(listed);
116
139
  if (listing)
@@ -0,0 +1,8 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
+ interface Frontmatter {
3
+ data: Record<string, unknown>;
4
+ body: string;
5
+ }
6
+ export declare const FRONTMATTER: RegExp;
7
+ export declare function parseFrontmatter(text: string): Frontmatter;
8
+ export {};
@@ -0,0 +1,18 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ // Checks on parsed data shared by file reading and redaction, kept apart so neither imports the other.
3
+ export function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ export const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
7
+ export function parseFrontmatter(text) {
8
+ const match = FRONTMATTER.exec(text);
9
+ if (!match)
10
+ return { data: {}, body: text };
11
+ try {
12
+ const data = parseYaml(match[1] ?? "");
13
+ return { data: isRecord(data) ? data : {}, body: text.slice(match[0].length) };
14
+ }
15
+ catch {
16
+ return { data: {}, body: text.slice(match[0].length) };
17
+ }
18
+ }
@@ -0,0 +1,26 @@
1
+ /** Every location ccdesk reads, resolved once so tests can point it at a sandbox. */
2
+ export interface ScanEnv {
3
+ home: string;
4
+ /** `~/.claude`, or `--config-dir` or `CLAUDE_CONFIG_DIR` when set. */
5
+ claudeDir: string;
6
+ /** CLAUDE_CONFIG_DIR for the claude commands ccdesk runs: `--config-dir` or the shell's; unset for ~/.claude. */
7
+ configDir?: string | undefined;
8
+ /** Claude Code's own state file: OAuth, trust decisions, user MCP servers. Read-only for ccdesk. */
9
+ claudeJson: string;
10
+ /** System directory for organisation-managed CLAUDE.md and managed-settings.json. */
11
+ managedDir: string;
12
+ platform: NodeJS.Platform;
13
+ /**
14
+ * The environment ccdesk runs in: where it finds an editor and `claude` (PATH, VISUAL, EDITOR,
15
+ * CCDESK_EDITOR), and what the programs it starts inherit.
16
+ */
17
+ processEnv: NodeJS.ProcessEnv;
18
+ /** CLAUDE_CODE_DISABLE_AUTO_MEMORY from the shell ccdesk runs in; it outranks autoMemoryEnabled. */
19
+ disableAutoMemory?: string | undefined;
20
+ /** CLAUDE_CODE_PROJECT_DIR_NAME, read only beside CLAUDE_CONFIG_DIR: one memory store for every project. */
21
+ projectDirName?: string | undefined;
22
+ }
23
+ /** `configDirFlag`, from `--config-dir`, stands in for CLAUDE_CONFIG_DIR and wins over it. */
24
+ export declare function scanEnvFromProcess(configDirFlag?: string): ScanEnv;
25
+ /** Your configuration home: your home directory, or the folder whose .claude is CLAUDE_CONFIG_DIR. */
26
+ export declare function isConfigHome(env: ScanEnv, path: string): boolean;
@@ -3,8 +3,11 @@ import { join, resolve } from "node:path";
3
3
  /** `configDirFlag`, from `--config-dir`, stands in for CLAUDE_CONFIG_DIR and wins over it. */
4
4
  export function scanEnvFromProcess(configDirFlag) {
5
5
  const home = homedir();
6
+ // Kept as the object itself: on Windows it reads PATH and Path alike, which a copy would not.
7
+ // biome-ignore lint/style/noProcessEnv: building a ScanEnv from the real process is this function's job.
8
+ const processEnv = process.env;
6
9
  // Made absolute here, from the folder ccdesk started in, because claude runs elsewhere.
7
- const chosen = configDirFlag ?? (process.env.CLAUDE_CONFIG_DIR || undefined);
10
+ const chosen = configDirFlag ?? (processEnv.CLAUDE_CONFIG_DIR || undefined);
8
11
  const configDir = chosen === undefined ? undefined : resolve(chosen);
9
12
  return {
10
13
  home,
@@ -14,8 +17,9 @@ export function scanEnvFromProcess(configDirFlag) {
14
17
  claudeJson: configDir ? join(configDir, ".claude.json") : join(home, ".claude.json"),
15
18
  managedDir: managedDirFor(process.platform),
16
19
  platform: process.platform,
17
- disableAutoMemory: process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY,
18
- projectDirName: configDir ? process.env.CLAUDE_CODE_PROJECT_DIR_NAME || undefined : undefined,
20
+ processEnv,
21
+ disableAutoMemory: processEnv.CLAUDE_CODE_DISABLE_AUTO_MEMORY,
22
+ projectDirName: configDir ? processEnv.CLAUDE_CODE_PROJECT_DIR_NAME || undefined : undefined,
19
23
  };
20
24
  }
21
25
  /** Your configuration home: your home directory, or the folder whose .claude is CLAUDE_CONFIG_DIR. */
@@ -0,0 +1,68 @@
1
+ import { type FileHandle } from "node:fs/promises";
2
+ import { type Problem, type TextFile } from "../../shared/types.ts";
3
+ import { type ScanEnv } from "./env.ts";
4
+ /** Claude Code skips a CLAUDE.md larger than this (docs: memory#auto-memory), so ccdesk reads no more. */
5
+ export declare const MAX_TEXT_BYTES: number;
6
+ /** ccdesk never reads Claude Code's credentials, whatever name or link leads to them. */
7
+ export declare function isSecretPath(path: string): boolean;
8
+ /**
9
+ * Whether a path is Claude Code's credentials file: by name, through any link on the way, or by
10
+ * being the same file as `<config>/.credentials.json`, so a copy kept under another name or a hard
11
+ * link to it counts too.
12
+ */
13
+ export declare function isCredentials(env: ScanEnv, path: string): Promise<boolean>;
14
+ /**
15
+ * Opens a file to read, or null for the credentials file. The open file is checked again, so a
16
+ * file swapped in after the first check is not read either.
17
+ */
18
+ export declare function openUnlessCredentials(env: ScanEnv, path: string): Promise<FileHandle | null>;
19
+ /**
20
+ * A UNC share or a path under /net or /Network. Claude Code does not follow a link there, because
21
+ * looking it up can contact the host it names (docs: memory#share-rules-across-projects-with-symlinks).
22
+ */
23
+ export declare function isNetworkPath(path: string): boolean;
24
+ /** A pattern where `*` matches anything, matched against the whole string. */
25
+ export declare function wildcard(pattern: string, flags?: string): RegExp;
26
+ export declare function isWithin(parent: string, child: string): boolean;
27
+ export declare function isDir(path: string): Promise<boolean>;
28
+ export declare function isFile(path: string): Promise<boolean>;
29
+ export declare function listDir(path: string): Promise<string[]>;
30
+ /**
31
+ * A text file, or null when there is none to read: nothing at the path, a folder, a link to a
32
+ * network location, or the credentials file. A file that is there but cannot be read, such as one
33
+ * without read permission or a link loop, also goes into `problems`.
34
+ */
35
+ export declare function readTextFile(env: ScanEnv, path: string, problems?: Problem[]): Promise<TextFile | null>;
36
+ export declare function resolvedPath(path: string): Promise<string>;
37
+ type JsonResult = {
38
+ ok: true;
39
+ data: Record<string, unknown>;
40
+ }
41
+ /** `unparsed`: the file was read, but is not a JSON object. */
42
+ | {
43
+ ok: false;
44
+ missing: boolean;
45
+ unparsed?: boolean;
46
+ error: string;
47
+ };
48
+ /** A JSON object file; `emptyIsObject` reads an empty file as `{}`. */
49
+ export declare function readJsonObject(env: ScanEnv, path: string, { emptyIsObject }?: {
50
+ emptyIsObject?: boolean | undefined;
51
+ }): Promise<JsonResult>;
52
+ /** The object in a JSON file, or null; a file that is there but does not read or parse goes into `problems`. */
53
+ export declare function readJsonOrReport(env: ScanEnv, path: string, problems: Problem[]): Promise<Record<string, unknown> | null>;
54
+ /** The strings in a JSON list; anything else counts as no strings. */
55
+ export declare function stringsIn(value: unknown): string[];
56
+ /** A frontmatter list given as a YAML list or a comma-separated string, such as `paths`. */
57
+ export declare function toStringList(value: unknown): string[];
58
+ export declare function stringField(data: Record<string, unknown>, key: string): string;
59
+ /** Fingerprint of a file's text, so a change can require the file to be as it was reviewed. */
60
+ export declare function revisionOf(text: string | null): string;
61
+ export declare function shortHash(text: string): string;
62
+ /**
63
+ * Markdown files under `dir`. A symlinked folder is entered once, so a link loop ends, and a link
64
+ * to a network location is not followed. Depth and count are bounded by ccdesk, since a link can
65
+ * lead anywhere; Claude Code's own limits are not documented.
66
+ */
67
+ export declare function walkMarkdown(dir: string): Promise<string[]>;
68
+ export {};
@@ -0,0 +1,270 @@
1
+ import { createHash } from "node:crypto";
2
+ import {} from "node:fs";
3
+ import { lstat, open, readdir, readlink, realpath, stat } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import {} from "../../shared/types.js";
6
+ import { jsonSyntaxError, shownJsonFile, shownText } from "../secrets.js";
7
+ import { isRecord } from "./data.js";
8
+ import {} from "./env.js";
9
+ /** Claude Code skips a CLAUDE.md larger than this (docs: memory#auto-memory), so ccdesk reads no more. */
10
+ export const MAX_TEXT_BYTES = 4 * 1024 * 1024;
11
+ /** ccdesk never reads Claude Code's credentials, whatever name or link leads to them. */
12
+ export function isSecretPath(path) {
13
+ return basename(path) === ".credentials.json";
14
+ }
15
+ /**
16
+ * Whether a path, any link on the way, or the file it finally leads to is the credentials file, so
17
+ * a credentials file that is itself a link is caught too. Checked before opening.
18
+ */
19
+ async function leadsToSecret(path) {
20
+ let current = path;
21
+ for (let hop = 0; hop < 40; hop++) {
22
+ if (isSecretPath(current))
23
+ return true;
24
+ const info = await lstat(current).catch(() => null);
25
+ if (!info?.isSymbolicLink())
26
+ break;
27
+ current = resolve(dirname(current), await readlink(current));
28
+ }
29
+ return isSecretPath(await realpath(path).catch(() => path));
30
+ }
31
+ /** The identity of `<config>/.credentials.json`, or null when there is none. */
32
+ function credentialsIdentity(env) {
33
+ return stat(join(env.claudeDir, ".credentials.json"), { bigint: true }).catch(() => null);
34
+ }
35
+ function sameFile(a, b) {
36
+ return b !== null && a.dev === b.dev && a.ino === b.ino;
37
+ }
38
+ /**
39
+ * Whether a path is Claude Code's credentials file: by name, through any link on the way, or by
40
+ * being the same file as `<config>/.credentials.json`, so a copy kept under another name or a hard
41
+ * link to it counts too.
42
+ */
43
+ export async function isCredentials(env, path) {
44
+ if (await leadsToSecret(path))
45
+ return true;
46
+ const info = await stat(path, { bigint: true }).catch(() => null);
47
+ return info !== null && sameFile(info, await credentialsIdentity(env));
48
+ }
49
+ /**
50
+ * Opens a file to read, or null for the credentials file. The open file is checked again, so a
51
+ * file swapped in after the first check is not read either.
52
+ */
53
+ export async function openUnlessCredentials(env, path) {
54
+ if (await isCredentials(env, path))
55
+ return null;
56
+ const handle = await open(path, "r");
57
+ if (sameFile(await handle.stat({ bigint: true }), await credentialsIdentity(env))) {
58
+ await handle.close();
59
+ return null;
60
+ }
61
+ return handle;
62
+ }
63
+ /**
64
+ * A UNC share or a path under /net or /Network. Claude Code does not follow a link there, because
65
+ * looking it up can contact the host it names (docs: memory#share-rules-across-projects-with-symlinks).
66
+ */
67
+ export function isNetworkPath(path) {
68
+ if (/^[\\/]{2}wsl(\$|\.localhost)[\\/]/i.test(path))
69
+ return false;
70
+ return /^[\\/]{2}[^\\/]/.test(path) || /^\/(net|Network)(\/|$)/.test(path);
71
+ }
72
+ /** Where a symlink points, read without following it; null for anything else. */
73
+ async function linkTarget(path) {
74
+ try {
75
+ if (!(await lstat(path)).isSymbolicLink())
76
+ return null;
77
+ return resolve(dirname(path), await readlink(path));
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /** A link to a network location, which ccdesk, like Claude Code, does not follow. */
84
+ async function linksToNetwork(path) {
85
+ const target = await linkTarget(path);
86
+ return target !== null && isNetworkPath(target);
87
+ }
88
+ /** A pattern where `*` matches anything, matched against the whole string. */
89
+ export function wildcard(pattern, flags = "") {
90
+ const body = pattern
91
+ .split("*")
92
+ .map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
93
+ .join(".*");
94
+ return new RegExp(`^${body}$`, flags);
95
+ }
96
+ export function isWithin(parent, child) {
97
+ const rel = relative(parent, child);
98
+ // Only the parent segment leads out; a name such as ..cache is inside.
99
+ const up = rel === ".." || rel.startsWith(`..${sep}`);
100
+ return rel === "" || (!up && !rel.startsWith(sep) && !/^[a-zA-Z]:/.test(rel));
101
+ }
102
+ export async function isDir(path) {
103
+ try {
104
+ return (await stat(path)).isDirectory();
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ export async function isFile(path) {
111
+ try {
112
+ return (await stat(path)).isFile();
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ export async function listDir(path) {
119
+ try {
120
+ return (await readdir(path)).sort();
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ /**
127
+ * A text file, or null when there is none to read: nothing at the path, a folder, a link to a
128
+ * network location, or the credentials file. A file that is there but cannot be read, such as one
129
+ * without read permission or a link loop, also goes into `problems`.
130
+ */
131
+ export async function readTextFile(env, path, problems) {
132
+ try {
133
+ if (await linksToNetwork(path))
134
+ return null;
135
+ const real = await realpath(path);
136
+ const info = await stat(path);
137
+ if (!info.isFile())
138
+ return null;
139
+ const handle = await openUnlessCredentials(env, path);
140
+ if (!handle)
141
+ return null;
142
+ let text;
143
+ try {
144
+ const buffer = Buffer.alloc(Math.min(info.size, MAX_TEXT_BYTES));
145
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
146
+ text = buffer.subarray(0, bytesRead).toString("utf8");
147
+ }
148
+ finally {
149
+ await handle.close();
150
+ }
151
+ text = real.endsWith(".json") ? shownJsonFile(text) : (shownText(text) ?? "");
152
+ const ref = {
153
+ path,
154
+ mtime: info.mtime.toISOString(),
155
+ bytes: info.size,
156
+ lines: text === "" ? 0 : text.split("\n").length,
157
+ text,
158
+ };
159
+ if ((await lstat(path)).isSymbolicLink())
160
+ ref.realPath = real;
161
+ return ref;
162
+ }
163
+ catch (error) {
164
+ const code = error.code;
165
+ if (code !== "ENOENT" && code !== "ENOTDIR")
166
+ problems?.push({ path, error: `cannot be read (${code ?? String(error)})` });
167
+ return null;
168
+ }
169
+ }
170
+ export async function resolvedPath(path) {
171
+ try {
172
+ return await realpath(path);
173
+ }
174
+ catch {
175
+ return path;
176
+ }
177
+ }
178
+ /** A JSON object file; `emptyIsObject` reads an empty file as `{}`. */
179
+ export async function readJsonObject(env, path, { emptyIsObject = false } = {}) {
180
+ let raw;
181
+ try {
182
+ const handle = await openUnlessCredentials(env, path);
183
+ if (!handle)
184
+ return { ok: false, missing: false, error: "ccdesk does not read credentials" };
185
+ try {
186
+ raw = await handle.readFile("utf8");
187
+ }
188
+ finally {
189
+ await handle.close();
190
+ }
191
+ }
192
+ catch (error) {
193
+ const missing = error.code === "ENOENT";
194
+ return { ok: false, missing, error: String(error) };
195
+ }
196
+ if (emptyIsObject && raw.trim() === "")
197
+ return { ok: true, data: {} };
198
+ try {
199
+ const data = JSON.parse(raw);
200
+ if (!isRecord(data))
201
+ return { ok: false, missing: false, unparsed: true, error: "not a JSON object" };
202
+ return { ok: true, data };
203
+ }
204
+ catch (error) {
205
+ return { ok: false, missing: false, unparsed: true, error: jsonSyntaxError(error) };
206
+ }
207
+ }
208
+ /** The object in a JSON file, or null; a file that is there but does not read or parse goes into `problems`. */
209
+ export async function readJsonOrReport(env, path, problems) {
210
+ const result = await readJsonObject(env, path);
211
+ if (result.ok)
212
+ return result.data;
213
+ if (!result.missing)
214
+ problems.push({ path, error: result.error });
215
+ return null;
216
+ }
217
+ /** The strings in a JSON list; anything else counts as no strings. */
218
+ export function stringsIn(value) {
219
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
220
+ }
221
+ /** A frontmatter list given as a YAML list or a comma-separated string, such as `paths`. */
222
+ export function toStringList(value) {
223
+ if (typeof value === "string")
224
+ return value
225
+ .split(",")
226
+ .map((s) => s.trim())
227
+ .filter(Boolean);
228
+ return stringsIn(value);
229
+ }
230
+ export function stringField(data, key) {
231
+ const value = data[key];
232
+ return typeof value === "string" ? value.trim() : "";
233
+ }
234
+ /** Fingerprint of a file's text, so a change can require the file to be as it was reviewed. */
235
+ export function revisionOf(text) {
236
+ return text === null ? "missing" : createHash("sha256").update(text).digest("hex").slice(0, 16);
237
+ }
238
+ export function shortHash(text) {
239
+ return createHash("sha1").update(text).digest("hex").slice(0, 10);
240
+ }
241
+ /**
242
+ * Markdown files under `dir`. A symlinked folder is entered once, so a link loop ends, and a link
243
+ * to a network location is not followed. Depth and count are bounded by ccdesk, since a link can
244
+ * lead anywhere; Claude Code's own limits are not documented.
245
+ */
246
+ export async function walkMarkdown(dir) {
247
+ const out = [];
248
+ const seen = new Set();
249
+ const walk = async (folder, depth) => {
250
+ const real = await resolvedPath(folder);
251
+ if (seen.has(real) || depth > MAX_WALK_DEPTH)
252
+ return;
253
+ seen.add(real);
254
+ for (const name of await listDir(folder)) {
255
+ if (out.length >= MAX_WALK_FILES)
256
+ return;
257
+ const path = join(folder, name);
258
+ if (await linksToNetwork(path))
259
+ continue;
260
+ if (await isDir(path))
261
+ await walk(path, depth + 1);
262
+ else if (name.endsWith(".md"))
263
+ out.push(path);
264
+ }
265
+ };
266
+ await walk(dir, 0);
267
+ return out;
268
+ }
269
+ const MAX_WALK_DEPTH = 8;
270
+ const MAX_WALK_FILES = 1000;
@@ -0,0 +1,14 @@
1
+ import { type Visibility } from "../../shared/types.ts";
2
+ export declare function gitRoot(dir: string): Promise<string | null>;
3
+ /** Every checkout of the repository `dir` is in, the main one first; empty outside git. */
4
+ export declare function worktrees(dir: string): Promise<string[]>;
5
+ /** Main worktree root: worktrees of one repository share Claude Code's auto memory. */
6
+ export declare function mainWorktreeRoot(dir: string): Promise<string | null>;
7
+ /**
8
+ * The key ~/.claude.json keeps a folder's trust answers and local MCP servers under: the main
9
+ * checkout's root, else the repository root, else the folder itself outside git. The scan reads
10
+ * under it and `claude mcp add --scope local` writes under it, so both take it from here.
11
+ */
12
+ export declare function trustKey(dir: string, mainRoot: string | null, root: string | null): string;
13
+ /** Git visibility of each path, batched into two git calls per repository. */
14
+ export declare function visibilityOf(root: string | null, paths: string[]): Promise<Map<string, Visibility>>;
@@ -0,0 +1,98 @@
1
+ import { execFile } from "node:child_process";
2
+ import { dirname, normalize, relative } from "node:path";
3
+ import { promisify } from "node:util";
4
+ import {} from "../../shared/types.js";
5
+ import { isWithin } from "./files.js";
6
+ const run = promisify(execFile);
7
+ export async function gitRoot(dir) {
8
+ try {
9
+ const { stdout } = await run("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
10
+ timeout: 5000,
11
+ });
12
+ // Git prints forward slashes on Windows too; normalise so path comparisons hold.
13
+ return stdout.trim() ? normalize(stdout.trim()) : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** Every checkout of the repository `dir` is in, the main one first; empty outside git. */
20
+ export async function worktrees(dir) {
21
+ try {
22
+ const { stdout } = await run("git", ["-C", dir, "worktree", "list", "--porcelain"], {
23
+ timeout: 5000,
24
+ });
25
+ return stdout
26
+ .split("\n")
27
+ .filter((line) => line.startsWith("worktree "))
28
+ .map((line) => normalize(line.slice("worktree ".length)));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ /** Main worktree root: worktrees of one repository share Claude Code's auto memory. */
35
+ export async function mainWorktreeRoot(dir) {
36
+ try {
37
+ const { stdout } = await run("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: 5000 });
38
+ const common = stdout.trim();
39
+ if (!common)
40
+ return null;
41
+ return common.endsWith("/.git") || common.endsWith("\\.git")
42
+ ? normalize(dirname(common))
43
+ : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * The key ~/.claude.json keeps a folder's trust answers and local MCP servers under: the main
51
+ * checkout's root, else the repository root, else the folder itself outside git. The scan reads
52
+ * under it and `claude mcp add --scope local` writes under it, so both take it from here.
53
+ */
54
+ export function trustKey(dir, mainRoot, root) {
55
+ return mainRoot ?? root ?? dir;
56
+ }
57
+ /** Git visibility of each path, batched into two git calls per repository. */
58
+ export async function visibilityOf(root, paths) {
59
+ const result = new Map();
60
+ if (paths.length === 0)
61
+ return result;
62
+ if (!root) {
63
+ for (const p of paths)
64
+ result.set(p, "no-git");
65
+ return result;
66
+ }
67
+ const inside = paths.filter((p) => p !== root && isWithin(root, p));
68
+ for (const p of paths)
69
+ if (!inside.includes(p))
70
+ result.set(p, "no-git");
71
+ if (inside.length === 0)
72
+ return result;
73
+ const rels = new Map(inside.map((p) => [p, relative(root, p).replaceAll("\\", "/")]));
74
+ const tracked = await gitLines(root, ["ls-files", "--full-name", "-z", "--", ...inside], "\0");
75
+ // check-ignore echoes the paths it matched, and on Windows an absolute path does not come back
76
+ // as written, so it gets relative ones. It accepts -z only with --stdin, so its output is split
77
+ // by line.
78
+ const ignored = await gitLines(root, ["check-ignore", "--no-index", "--", ...rels.values()], "\n");
79
+ for (const [p, rel] of rels) {
80
+ if (tracked.has(rel))
81
+ result.set(p, "tracked");
82
+ else if (ignored.has(rel))
83
+ result.set(p, "ignored");
84
+ else
85
+ result.set(p, "untracked");
86
+ }
87
+ return result;
88
+ }
89
+ // Failures (including check-ignore's exit 1 for "nothing ignored") read as an empty set.
90
+ async function gitLines(root, args, separator) {
91
+ try {
92
+ const { stdout } = await run("git", ["-C", root, ...args], { timeout: 5000 });
93
+ return new Set(stdout.split(separator).filter(Boolean));
94
+ }
95
+ catch {
96
+ return new Set();
97
+ }
98
+ }
@@ -0,0 +1,5 @@
1
+ import { type DirectoryView, type Overview, type SessionProfile } from "../../shared/types.ts";
2
+ import { type ScanEnv } from "./env.ts";
3
+ export declare function scanOverview(env: ScanEnv, launchDir: string): Promise<Overview>;
4
+ /** Everything Claude Code reads when started in `dir`. */
5
+ export declare function scanDirectory(env: ScanEnv, dir: string, profile?: SessionProfile): Promise<DirectoryView>;