ccdesk 0.2.2 → 0.2.4

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 (131) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +7 -4
  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 +64 -48
  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 +23 -0
  12. package/dist/{server → core}/mcp-edit.js +121 -100
  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 +28 -0
  16. package/dist/{server → core}/open.js +38 -13
  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 +14 -0
  26. package/dist/core/scan/data.js +26 -0
  27. package/dist/core/scan/env.d.ts +28 -0
  28. package/dist/{server → core}/scan/env.js +15 -4
  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 +20 -0
  32. package/dist/core/scan/git.js +106 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +119 -82
  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 +153 -95
  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 +7 -5
  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 +37 -0
  54. package/dist/{server → core}/scan/settings.js +98 -93
  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 +19 -0
  58. package/dist/{server → core}/scan/usage.js +122 -97
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +45 -0
  62. package/dist/core/secrets.js +207 -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 +117 -0
  68. package/dist/{server → core}/write.js +61 -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 +6 -0
  81. package/dist/shared/memory.js +18 -2
  82. package/dist/shared/requests.d.ts +104 -0
  83. package/dist/shared/requests.js +73 -0
  84. package/dist/shared/scopes.d.ts +10 -0
  85. package/dist/shared/settings-catalog.json +2199 -0
  86. package/dist/shared/types.d.ts +535 -0
  87. package/dist/shared/types.js +11 -0
  88. package/dist/ui/assets/index-C9qqEI5Z.js +60 -0
  89. package/dist/ui/index.html +1 -1
  90. package/dist/view/diff.d.ts +16 -0
  91. package/dist/view/diff.js +85 -0
  92. package/dist/view/format.d.ts +17 -0
  93. package/dist/view/format.js +47 -0
  94. package/dist/view/index.d.ts +11 -0
  95. package/dist/view/index.js +13 -0
  96. package/dist/view/lines.d.ts +9 -0
  97. package/dist/view/lines.js +12 -0
  98. package/dist/view/path-test.d.ts +3 -0
  99. package/dist/view/path-test.js +7 -0
  100. package/dist/view/sections/catalog.d.ts +9 -0
  101. package/dist/view/sections/catalog.js +47 -0
  102. package/dist/view/sections/extensions.d.ts +1 -0
  103. package/dist/view/sections/extensions.js +48 -0
  104. package/dist/view/sections/hooks.d.ts +1 -0
  105. package/dist/view/sections/hooks.js +32 -0
  106. package/dist/view/sections/index.d.ts +6 -0
  107. package/dist/view/sections/index.js +27 -0
  108. package/dist/view/sections/instructions.d.ts +6 -0
  109. package/dist/view/sections/instructions.js +38 -0
  110. package/dist/view/sections/mcp.d.ts +3 -0
  111. package/dist/view/sections/mcp.js +38 -0
  112. package/dist/view/sections/memory.d.ts +1 -0
  113. package/dist/view/sections/memory.js +31 -0
  114. package/dist/view/sections/permissions.d.ts +17 -0
  115. package/dist/view/sections/permissions.js +87 -0
  116. package/dist/view/sections/settings.d.ts +1 -0
  117. package/dist/view/sections/settings.js +33 -0
  118. package/dist/view/sections/tools.d.ts +6 -0
  119. package/dist/view/sections/tools.js +57 -0
  120. package/dist/view/sections/types.d.ts +123 -0
  121. package/dist/view/sections/types.js +10 -0
  122. package/dist/view/settings.d.ts +12 -0
  123. package/dist/view/settings.js +31 -0
  124. package/dist/view/usage.d.ts +8 -0
  125. package/dist/view/usage.js +18 -0
  126. package/package.json +13 -2
  127. package/dist/server/scan/files.js +0 -480
  128. package/dist/server/scan/path-test.js +0 -136
  129. package/dist/server/schema.js +0 -15
  130. package/dist/server/secrets.js +0 -63
  131. 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,14 @@
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
+ /**
7
+ * JSON.parse after dropping a leading byte order mark, as Claude Code does before it parses a
8
+ * settings, MCP or state file (observed in v2.1.296 and the SDK's safeParseJSON; the docs do not
9
+ * say). Windows PowerShell 5.1 and some editors write one.
10
+ */
11
+ export declare function parseJson(text: string): unknown;
12
+ export declare const FRONTMATTER: RegExp;
13
+ export declare function parseFrontmatter(text: string): Frontmatter;
14
+ export {};
@@ -0,0 +1,26 @@
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
+ /**
7
+ * JSON.parse after dropping a leading byte order mark, as Claude Code does before it parses a
8
+ * settings, MCP or state file (observed in v2.1.296 and the SDK's safeParseJSON; the docs do not
9
+ * say). Windows PowerShell 5.1 and some editors write one.
10
+ */
11
+ export function parseJson(text) {
12
+ return JSON.parse(text.startsWith("\uFEFF") ? text.slice(1) : text);
13
+ }
14
+ export const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
15
+ export function parseFrontmatter(text) {
16
+ const match = FRONTMATTER.exec(text);
17
+ if (!match)
18
+ return { data: {}, body: text };
19
+ try {
20
+ const data = parseYaml(match[1] ?? "");
21
+ return { data: isRecord(data) ? data : {}, body: text.slice(match[0].length) };
22
+ }
23
+ catch {
24
+ return { data: {}, body: text.slice(match[0].length) };
25
+ }
26
+ }
@@ -0,0 +1,28 @@
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
+ /** macOS configuration profiles that deliver managed settings (MDM), which outrank managedDir's files. */
13
+ managedProfiles?: string[] | undefined;
14
+ platform: NodeJS.Platform;
15
+ /**
16
+ * The environment ccdesk runs in: where it finds an editor and `claude` (PATH, VISUAL, EDITOR,
17
+ * CCDESK_EDITOR), and what the programs it starts inherit.
18
+ */
19
+ processEnv: NodeJS.ProcessEnv;
20
+ /** CLAUDE_CODE_DISABLE_AUTO_MEMORY from the shell ccdesk runs in; it outranks autoMemoryEnabled. */
21
+ disableAutoMemory?: string | undefined;
22
+ /** CLAUDE_CODE_PROJECT_DIR_NAME, read only beside CLAUDE_CONFIG_DIR: one memory store for every project. */
23
+ projectDirName?: string | undefined;
24
+ }
25
+ /** `configDirFlag`, from `--config-dir`, stands in for CLAUDE_CONFIG_DIR and wins over it. */
26
+ export declare function scanEnvFromProcess(configDirFlag?: string): ScanEnv;
27
+ /** Your configuration home: your home directory, or the folder whose .claude is CLAUDE_CONFIG_DIR. */
28
+ export declare function isConfigHome(env: ScanEnv, path: string): boolean;
@@ -1,10 +1,13 @@
1
- import { homedir } from "node:os";
1
+ import { homedir, userInfo } from "node:os";
2
2
  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,
@@ -13,15 +16,23 @@ export function scanEnvFromProcess(configDirFlag) {
13
16
  // Claude Code keeps .claude.json beside the config dir it was given.
14
17
  claudeJson: configDir ? join(configDir, ".claude.json") : join(home, ".claude.json"),
15
18
  managedDir: managedDirFor(process.platform),
19
+ managedProfiles: process.platform === "darwin" ? managedProfiles() : undefined,
16
20
  platform: process.platform,
17
- disableAutoMemory: process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY,
18
- projectDirName: configDir ? process.env.CLAUDE_CODE_PROJECT_DIR_NAME || undefined : undefined,
21
+ processEnv,
22
+ disableAutoMemory: processEnv.CLAUDE_CODE_DISABLE_AUTO_MEMORY,
23
+ projectDirName: configDir ? processEnv.CLAUDE_CODE_PROJECT_DIR_NAME || undefined : undefined,
19
24
  };
20
25
  }
21
26
  /** Your configuration home: your home directory, or the folder whose .claude is CLAUDE_CONFIG_DIR. */
22
27
  export function isConfigHome(env, path) {
23
28
  return path === env.home || join(path, ".claude") === env.claudeDir;
24
29
  }
30
+ /** Where macOS puts the com.anthropic.claudecode profile: for this user, then for the device. */
31
+ function managedProfiles() {
32
+ const dir = "/Library/Managed Preferences";
33
+ const profile = "com.anthropic.claudecode.plist";
34
+ return [join(dir, userInfo().username, profile), join(dir, profile)];
35
+ }
25
36
  function managedDirFor(platform) {
26
37
  if (platform === "darwin")
27
38
  return "/Library/Application Support/ClaudeCode";
@@ -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, parseJson } 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 = parseJson(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,20 @@
1
+ import { type Visibility } from "../../shared/types.ts";
2
+ import { type ScanEnv } from "./env.ts";
3
+ export declare function gitRoot(dir: string): Promise<string | null>;
4
+ /** Every checkout of the repository `dir` is in, the main one first; empty outside git. */
5
+ export declare function worktrees(dir: string): Promise<string[]>;
6
+ /** Main worktree root: worktrees of one repository share Claude Code's auto memory. */
7
+ export declare function mainWorktreeRoot(dir: string): Promise<string | null>;
8
+ /**
9
+ * The key ~/.claude.json keeps a folder's trust answers and local MCP servers under: the main
10
+ * checkout's root, else the repository root, else the folder itself outside git. The scan reads
11
+ * under it and `claude mcp add --scope local` writes under it, so both take it from here.
12
+ */
13
+ export declare function trustKey(dir: string, mainRoot: string | null, root: string | null): string;
14
+ /**
15
+ * A folder as ~/.claude.json names it under `projects`: Claude Code writes Windows paths with
16
+ * forward slashes (observed in v2.1.296; the docs do not say), so lookups go through this.
17
+ */
18
+ export declare function claudeJsonKey(env: ScanEnv, path: string): string;
19
+ /** Git visibility of each path, batched into two git calls per repository. */
20
+ export declare function visibilityOf(root: string | null, paths: string[]): Promise<Map<string, Visibility>>;