ccdesk 0.0.0-stage → 0.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +114 -2
  4. package/dist/server/backups.js +226 -0
  5. package/dist/server/cli.js +92 -0
  6. package/dist/server/mutations.js +188 -0
  7. package/dist/server/open.js +139 -0
  8. package/dist/server/scan/context.js +139 -0
  9. package/dist/server/scan/env.js +23 -0
  10. package/dist/server/scan/files.js +390 -0
  11. package/dist/server/scan/index.js +266 -0
  12. package/dist/server/scan/instructions.js +285 -0
  13. package/dist/server/scan/inventory.js +360 -0
  14. package/dist/server/scan/mcp.js +255 -0
  15. package/dist/server/scan/memory.js +91 -0
  16. package/dist/server/scan/path-test.js +99 -0
  17. package/dist/server/scan/permissions.js +85 -0
  18. package/dist/server/scan/plugins.js +117 -0
  19. package/dist/server/scan/projects.js +100 -0
  20. package/dist/server/scan/settings.js +317 -0
  21. package/dist/server/scan/tools.js +107 -0
  22. package/dist/server/scan/usage.js +441 -0
  23. package/dist/server/server.js +258 -0
  24. package/dist/server/settings-edit.js +152 -0
  25. package/dist/server/watch.js +172 -0
  26. package/dist/server/write.js +195 -0
  27. package/dist/shared/builtin-tools.json +235 -0
  28. package/dist/shared/scopes.js +18 -0
  29. package/dist/shared/settings-scopes.json +208 -0
  30. package/dist/shared/settings.schema.json +4623 -0
  31. package/dist/shared/types.js +2 -0
  32. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  33. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  34. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  35. package/dist/ui/assets/index-BM4K7hSO.css +1 -0
  36. package/dist/ui/assets/index-CN3vkVjv.js +45 -0
  37. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  38. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  39. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  40. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  41. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  42. package/dist/ui/index.html +15 -0
  43. package/dist/ui/logo.png +0 -0
  44. package/package.json +80 -4
@@ -0,0 +1,188 @@
1
+ import { access, constants, readdir, realpath, stat } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import {} from "../shared/types.js";
4
+ import {} from "./scan/env.js";
5
+ import { isWithin, parseFrontmatter, revisionOf, shownText, stringField } from "./scan/files.js";
6
+ import { deadLinks } from "./scan/memory.js";
7
+ import { RefusedError, readIfExists, recordChange, removeExactly, serially, writeAtomic, } from "./write.js";
8
+ async function projectsRoot(env) {
9
+ return realpath(join(env.claudeDir, "projects")).catch(() => {
10
+ throw new RefusedError("There is no auto memory folder.");
11
+ });
12
+ }
13
+ /** A real `<config>/projects/<store>/memory` folder, after following links. */
14
+ export async function assertMemoryDir(env, dir) {
15
+ const real = await realpath(dir).catch(() => {
16
+ throw new RefusedError("That memory folder no longer exists.");
17
+ });
18
+ const root = await projectsRoot(env);
19
+ if (real === root || !isWithin(root, real) || basename(real) !== "memory")
20
+ throw new RefusedError("ccdesk only changes auto memory under ~/.claude/projects.");
21
+ return real;
22
+ }
23
+ /**
24
+ * A memory folder's MEMORY.md. When it is a link, the file it leads to must be in the same folder,
25
+ * so changing the index can never rewrite a file elsewhere.
26
+ */
27
+ export async function indexPathIn(store) {
28
+ const path = join(store, "MEMORY.md");
29
+ const real = await realpath(path).catch(() => path);
30
+ if (dirname(real) !== store)
31
+ throw new RefusedError("This MEMORY.md links outside its memory folder; ccdesk leaves it alone.");
32
+ return path;
33
+ }
34
+ /** A topic file inside a memory folder: the only kind of file ccdesk deletes. */
35
+ async function assertMemoryFile(env, path) {
36
+ const real = await realpath(path).catch(() => {
37
+ throw new RefusedError("That memory file no longer exists.");
38
+ });
39
+ await assertMemoryDir(env, dirname(real));
40
+ if (!real.endsWith(".md") || basename(real) === "MEMORY.md")
41
+ throw new RefusedError("MEMORY.md is the index Claude maintains; delete individual memories instead.");
42
+ if (!(await stat(real)).isFile())
43
+ throw new RefusedError("Not a file.");
44
+ return real;
45
+ }
46
+ /** One fingerprint for two files, so a change can require both as they were reviewed. */
47
+ export function revisionOfBoth(first, second) {
48
+ return `${revisionOf(first)}+${revisionOf(second)}`;
49
+ }
50
+ const reviewAgain = () => new RefusedError("The memory or MEMORY.md changed since you reviewed this; review it again.");
51
+ /** Runs a plan only while everything it was worked out from is as reviewed. */
52
+ function saveReviewed(plan, expectedRevision) {
53
+ return serially(async () => {
54
+ const planned = await plan();
55
+ if (planned.preview.revision !== expectedRevision)
56
+ throw reviewAgain();
57
+ return planned.save();
58
+ });
59
+ }
60
+ /** MEMORY.md's lines without the empty ones a trailing newline leaves. */
61
+ function indexLines(index) {
62
+ const lines = (index ?? "").split("\n");
63
+ while (lines.at(-1) === "")
64
+ lines.pop();
65
+ return lines;
66
+ }
67
+ /** Deletes a memory with the MEMORY.md lines that point at it. */
68
+ async function planDelete(env, path) {
69
+ const real = await assertMemoryFile(env, path);
70
+ const text = await readIfExists(env, real);
71
+ const indexPath = await indexPathIn(dirname(real));
72
+ const index = await readIfExists(env, indexPath);
73
+ const removedIndexLines = [];
74
+ const kept = (index ?? "").split("\n").filter((line) => {
75
+ if (!line.includes(`(${basename(real)})`))
76
+ return true;
77
+ removedIndexLines.push(line);
78
+ return false;
79
+ });
80
+ const next = removedIndexLines.length > 0 ? kept.join("\n") : index;
81
+ const revision = revisionOfBoth(text, index);
82
+ const save = async () => {
83
+ // A folder that refuses the delete would leave MEMORY.md changed with the memory still there.
84
+ await access(dirname(real), constants.W_OK);
85
+ // Nothing of the delete is left when the index is as reviewed and the memory is still there,
86
+ // even if Claude rewrote the memory meanwhile.
87
+ const unchanged = async () => revisionOf(await readIfExists(env, indexPath)) === revisionOf(index) &&
88
+ (await readIfExists(env, real)) !== null;
89
+ return recordChange(env, { kind: "memory-delete", target: real, before: text, removedIndexLines, unchanged }, async () => {
90
+ // The index goes first: if it changed meanwhile, nothing has been removed yet.
91
+ if (next !== index)
92
+ await writeAtomic(env, indexPath, next ?? "", revisionOf(index));
93
+ let removed = false;
94
+ try {
95
+ removed = await removeExactly(real, revisionOf(text));
96
+ }
97
+ finally {
98
+ // Claude may have rewritten the memory while the index was saved; then the index goes back.
99
+ if (!removed && next !== index)
100
+ await writeAtomic(env, indexPath, index ?? "", revisionOf(next)).catch((error) => {
101
+ // The index was already changed, so this is not a "nothing written" refusal.
102
+ throw new RefusedError(`MEMORY.md changed again, so ccdesk could not put its line back: ${error instanceof Error ? error.message : error}`);
103
+ });
104
+ }
105
+ if (!removed)
106
+ throw reviewAgain();
107
+ return { target: real, revision: revisionOfBoth(null, next), removedIndexLines };
108
+ });
109
+ };
110
+ return {
111
+ preview: {
112
+ files: [
113
+ { path: real, before: text, after: null },
114
+ ...(next !== index ? [{ path: indexPath, before: index, after: next }] : []),
115
+ ],
116
+ revision,
117
+ },
118
+ save,
119
+ };
120
+ }
121
+ /** Rewrites MEMORY.md through `edit`, recorded in Recent changes. */
122
+ async function planIndex(env, store, edit,
123
+ /** Other text the new index was worked out from, bound into the revision. */
124
+ source) {
125
+ const path = await indexPathIn(store);
126
+ const before = await readIfExists(env, path);
127
+ const after = `${edit(indexLines(before)).join("\n")}\n`;
128
+ if (after === before)
129
+ throw new RefusedError("MEMORY.md already says that.");
130
+ const save = () => recordChange(env, { kind: "memory-index", target: path, before }, async () => {
131
+ await writeAtomic(env, path, after, revisionOf(before));
132
+ return { target: path, revision: revisionOf(after) };
133
+ });
134
+ return {
135
+ preview: { files: [{ path, before, after }], revision: revisionOfBoth(before, source) },
136
+ save,
137
+ };
138
+ }
139
+ /** One line of text: frontmatter can hold multi-line strings, an index line cannot. */
140
+ function oneLine(text) {
141
+ return text.replace(/\s+/g, " ").trim();
142
+ }
143
+ /** Adds a memory that MEMORY.md does not mention, in the `- [name](file) — description` form. */
144
+ async function planAdd(env, memoryPath) {
145
+ const real = await assertMemoryFile(env, memoryPath);
146
+ const text = await readIfExists(env, real);
147
+ const { data } = parseFrontmatter(text ?? "");
148
+ const name = oneLine(stringField(data, "name")) || basename(real, ".md");
149
+ const description = oneLine(stringField(data, "description"));
150
+ const line = `- [${name}](${basename(real)})${description ? ` — ${description}` : ""}`;
151
+ return planIndex(env, dirname(real), (lines) => [...lines, line], text);
152
+ }
153
+ /** Removes every MEMORY.md line that points at a memory file that no longer exists. */
154
+ async function planRemoveDead(env, indexPath) {
155
+ if (basename(indexPath) !== "MEMORY.md")
156
+ throw new RefusedError("Only a memory folder's MEMORY.md can be changed.");
157
+ const store = await assertMemoryDir(env, dirname(indexPath));
158
+ const files = (await readdir(store)).sort();
159
+ return planIndex(env, store, (lines) => lines.filter((line) => deadLinks(line, files).length === 0), files.join("\n"));
160
+ }
161
+ const PLANS = {
162
+ delete: planDelete,
163
+ add: planAdd,
164
+ "remove-dead": planRemoveDead,
165
+ };
166
+ export function isMemoryAction(value) {
167
+ return Object.hasOwn(PLANS, value);
168
+ }
169
+ /** What a memory action would change, for review. */
170
+ export async function previewMemoryChange(env, action, path) {
171
+ // Shown like any other text: a memory folder could hold a JSON file with secrets.
172
+ const { files, revision } = (await PLANS[action](env, path)).preview;
173
+ return {
174
+ files: files.map((f) => ({
175
+ path: f.path,
176
+ before: shownText(f.before),
177
+ after: shownText(f.after),
178
+ })),
179
+ revision,
180
+ };
181
+ }
182
+ /**
183
+ * Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
184
+ * action runs only while everything its review was worked out from is unchanged.
185
+ */
186
+ export function changeMemory(env, action, path, expectedRevision) {
187
+ return saveReviewed(() => PLANS[action](env, path), expectedRevision);
188
+ }
@@ -0,0 +1,139 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
3
+ import { delimiter, join, posix, win32 } from "node:path";
4
+ /** Editors that need a terminal; started from a background server they would have none. */
5
+ const TERMINAL_EDITORS = new Set([
6
+ "vi",
7
+ "vim",
8
+ "nvim",
9
+ "nano",
10
+ "emacs",
11
+ "hx",
12
+ "helix",
13
+ "micro",
14
+ "kak",
15
+ "ed",
16
+ ]);
17
+ /** Graphical editors ccdesk looks for when VISUAL and EDITOR name none. */
18
+ const GUI_EDITORS = [
19
+ "code",
20
+ "cursor",
21
+ "zed",
22
+ "codium",
23
+ "subl",
24
+ "kate",
25
+ "gnome-text-editor",
26
+ "gedit",
27
+ ];
28
+ /** An editor still running this long after it started is taken to have opened the file. */
29
+ const START_MS = 1500;
30
+ /** Whether `name` is an executable on PATH. */
31
+ function onPath(env, platform, name) {
32
+ const extensions = platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
33
+ for (const dir of (env.PATH ?? "").split(platform === "win32" ? ";" : delimiter)) {
34
+ for (const ext of extensions) {
35
+ try {
36
+ accessSync(join(dir, name + ext), platform === "win32" ? constants.F_OK : constants.X_OK);
37
+ return true;
38
+ }
39
+ catch {
40
+ // Not in this folder.
41
+ }
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ /**
47
+ * The commands to try, in order: `CCDESK_EDITOR` alone when set; else `VISUAL` or `EDITOR` when
48
+ * they name a graphical editor, then graphical editors found on PATH, then the system's opener.
49
+ * The system opener comes last because on Linux it often picks a terminal editor or nothing for
50
+ * JSON and Markdown.
51
+ */
52
+ export function editorCommands(env, platform, found = (name) => onPath(env, platform, name)) {
53
+ const split = (value) => {
54
+ const [command = "", ...args] = words(value);
55
+ // A wait flag would keep the editor tied to this server's process.
56
+ return [command, args.filter((a) => a !== "-w" && a !== "--wait")];
57
+ };
58
+ const name = (command) => (platform === "win32" ? win32 : posix)
59
+ .basename(command)
60
+ .replace(/\.(exe|cmd|bat)$/i, "")
61
+ .toLowerCase();
62
+ if (env.CCDESK_EDITOR)
63
+ return [split(env.CCDESK_EDITOR)];
64
+ const out = [];
65
+ for (const value of [env.VISUAL, env.EDITOR]) {
66
+ if (!value)
67
+ continue;
68
+ const command = split(value);
69
+ if (!TERMINAL_EDITORS.has(name(command[0])))
70
+ out.push(command);
71
+ }
72
+ for (const editor of GUI_EDITORS)
73
+ if (found(editor))
74
+ out.push([editor, []]);
75
+ out.push(platform === "darwin"
76
+ ? ["open", []]
77
+ : platform === "win32"
78
+ ? ["explorer", []]
79
+ : ["xdg-open", []]);
80
+ return out.filter((c, i) => out.findIndex((o) => o[0] === c[0]) === i);
81
+ }
82
+ /** Splits an editor setting into words, keeping quoted parts such as "C:\Program Files\…" whole. */
83
+ function words(value) {
84
+ return [...value.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
85
+ }
86
+ /**
87
+ * How to start `command` on `path`. On Windows a .cmd or .bat file (VS Code's `code`) or a bare
88
+ * name that may resolve to one cannot be started directly, so it goes through cmd.exe, quoted.
89
+ */
90
+ export function spawnPlan(command, args, path, platform) {
91
+ const direct = { file: command, args: [...args, path], verbatim: false };
92
+ if (platform !== "win32" || /\.(exe|com)$/i.test(command) || command === "explorer")
93
+ return direct;
94
+ // Inside cmd.exe quotes, " cannot be escaped and %NAME% still expands.
95
+ if ([command, ...args, path].some((part) => /["%]/.test(part)))
96
+ throw new Error("This path cannot be passed safely to a .cmd editor; open it yourself.");
97
+ const line = [command, ...args, path].map((part) => `"${part}"`).join(" ");
98
+ return { file: "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
99
+ }
100
+ /** Starts one command; resolves null once it is running or exited cleanly, else why it failed. */
101
+ function launch([command, args], path) {
102
+ return new Promise((resolve) => {
103
+ let plan;
104
+ try {
105
+ plan = spawnPlan(command, args, path, process.platform);
106
+ }
107
+ catch (error) {
108
+ return resolve(error.message);
109
+ }
110
+ const child = spawn(plan.file, plan.args, {
111
+ detached: true,
112
+ stdio: "ignore",
113
+ windowsVerbatimArguments: plan.verbatim,
114
+ });
115
+ const timer = setTimeout(() => {
116
+ child.unref();
117
+ resolve(null);
118
+ }, START_MS);
119
+ child.once("error", (error) => {
120
+ clearTimeout(timer);
121
+ resolve(`${command}: ${error.message}`);
122
+ });
123
+ child.once("exit", (code) => {
124
+ clearTimeout(timer);
125
+ resolve(code === 0 ? null : `${command} exited with code ${code}`);
126
+ });
127
+ });
128
+ }
129
+ /** Opens a file or folder in the first editor that starts; resolves with the command used. */
130
+ export async function openInEditor(path) {
131
+ const failures = [];
132
+ for (const command of editorCommands(process.env, process.platform)) {
133
+ const failure = await launch(command, path);
134
+ if (failure === null)
135
+ return command[0];
136
+ failures.push(failure);
137
+ }
138
+ throw new Error(`Could not open it (${failures.join("; ")}). Set CCDESK_EDITOR to your editor.`);
139
+ }
@@ -0,0 +1,139 @@
1
+ import { basename, join } from "node:path";
2
+ import {} from "../../shared/types.js";
3
+ import {} from "./env.js";
4
+ import { isWithin, parseFrontmatter, readTextFile } from "./files.js";
5
+ /**
6
+ * Imports can name any file, so a cloned repo's CLAUDE.md could point at private files.
7
+ * ccdesk shows an import's text only from the project, your Claude folders and ~/.agents.
8
+ */
9
+ function showable(env, projectRoot, path) {
10
+ if (basename(path) === ".credentials.json")
11
+ return false;
12
+ return [projectRoot, env.claudeDir, join(env.home, ".agents"), env.managedDir].some((root) => isWithin(root, path));
13
+ }
14
+ /** What Claude Code injects from an instruction file: no frontmatter, no block-level HTML comments. */
15
+ function injectedText(text) {
16
+ const body = parseFrontmatter(text).body;
17
+ // Comments inside code fences are kept, so only text outside fences is stripped.
18
+ return body
19
+ .split(/(^(?:```|~~~)[\s\S]*?^(?:```|~~~)[^\n]*$)/m)
20
+ .map((part, i) => (i % 2 === 1 ? part : part.replace(/^[ \t]*<!--[\s\S]*?-->[ \t]*\n?/gm, "")))
21
+ .join("");
22
+ }
23
+ function block(fields, text) {
24
+ return {
25
+ ...fields,
26
+ text,
27
+ lines: text === null || text === "" ? 0 : text.split("\n").length,
28
+ bytes: text === null ? 0 : Buffer.byteLength(text, "utf8"),
29
+ };
30
+ }
31
+ /** Claude Code caps each skill's description and when_to_use at this many characters in its listing. */
32
+ const SKILL_DESCRIPTION_CAP = 1536;
33
+ const BUILT_IN_STYLES = ["default", "explanatory", "learning"];
34
+ /** Skills and command files Claude can pick on its own; skillOverrides "name-only" drops the description. */
35
+ function skillListing(skills) {
36
+ return skills
37
+ .map((s) => {
38
+ if (s.override === "name-only")
39
+ return `- ${s.name}`;
40
+ const text = [s.description, s.whenToUse].filter(Boolean).join(" ");
41
+ return `- ${s.name}: ${text.slice(0, SKILL_DESCRIPTION_CAP)}`;
42
+ })
43
+ .join("\n");
44
+ }
45
+ /** The scope a listing shows: the one its entries share, else user. */
46
+ function commonScope(entries) {
47
+ const first = entries[0]?.scope ?? "user";
48
+ return entries.every((e) => e.scope === first) ? first : "user";
49
+ }
50
+ /** What Claude Code puts in context at session start in this directory, in load order. */
51
+ export async function buildContext(input) {
52
+ const { env, projectRoot, instructions, memory, inventory, effective } = input;
53
+ const blocks = [
54
+ block({
55
+ kind: "not-counted",
56
+ path: "Claude Code's system prompt and built-in tools",
57
+ scope: "managed",
58
+ note: "Part of every session; their size depends on your Claude Code version and model",
59
+ }, null),
60
+ ];
61
+ const style = effective.find((s) => s.key === "outputStyle")?.value;
62
+ if (typeof style === "string" && !BUILT_IN_STYLES.includes(style.toLowerCase())) {
63
+ const custom = inventory.outputStyles.find((s) => s.name === style);
64
+ blocks.push(custom
65
+ ? block({ kind: "output-style", path: custom.path, scope: custom.scope }, parseFrontmatter(custom.text).body)
66
+ : block({
67
+ kind: "not-counted",
68
+ path: `Output style "${style}"`,
69
+ scope: "user",
70
+ note: "ccdesk could not find this style's file",
71
+ }, null));
72
+ }
73
+ // A file both imported and loaded on its own (a rule, an AGENTS.md) is counted once, as itself.
74
+ const loaded = new Set(instructions.filter((i) => i.status === "loaded").map((i) => i.path));
75
+ const added = new Set();
76
+ // An imported file comes before the text that imports it ("Claude reads the imported file
77
+ // first, then the rest"), and its own imports before it.
78
+ const importsOf = async (file, via) => {
79
+ for (const ref of file.imports.filter((r) => r.via === via)) {
80
+ if (!ref.exists || loaded.has(ref.resolved) || added.has(ref.resolved))
81
+ continue;
82
+ added.add(ref.resolved);
83
+ await importsOf(file, ref.resolved);
84
+ const fields = { kind: "import", path: ref.resolved, scope: file.scope, via };
85
+ if (ref.pending)
86
+ blocks.push(block({ ...fields, note: "Not loaded until you approve imports from outside this project" }, null));
87
+ else if (ref.tooLarge)
88
+ blocks.push(block({ ...fields, note: "Over 4 MiB, so Claude Code does not load it" }, null));
89
+ else if (!showable(env, projectRoot, ref.resolved))
90
+ blocks.push(block({
91
+ ...fields,
92
+ note: "Outside this project and your Claude folders, so ccdesk does not show or count it. Claude Code loads it.",
93
+ }, null));
94
+ else {
95
+ const text = await readTextFile(ref.resolved);
96
+ blocks.push(block(fields, text ? injectedText(text.text) : null));
97
+ }
98
+ }
99
+ };
100
+ for (const file of instructions) {
101
+ if (file.status !== "loaded")
102
+ continue;
103
+ await importsOf(file, file.path);
104
+ blocks.push(block({ kind: "instruction", path: file.path, scope: file.scope }, injectedText(file.text)));
105
+ }
106
+ const index = memory?.index;
107
+ if (memory?.loaded && index) {
108
+ blocks.push(block({ kind: "memory-index", path: index.path, scope: "memory" }, index.text.split("\n").slice(0, index.loadedLines).join("\n")));
109
+ }
110
+ const listed = [...inventory.skills, ...inventory.commands].filter((s) => s.listed !== false);
111
+ const listing = skillListing(listed);
112
+ if (listing)
113
+ blocks.push(block({
114
+ kind: "skills",
115
+ path: "Skill listing",
116
+ scope: commonScope(listed),
117
+ note: "Names and descriptions only; a skill's body loads when it is used. Claude Code drops descriptions if the listing passes 1% of the context window.",
118
+ }, listing));
119
+ const agents = inventory.agents
120
+ .filter((a) => !a.shadowedBy)
121
+ .map((a) => `- ${a.id ?? a.name}: ${a.description}`)
122
+ .join("\n");
123
+ if (agents)
124
+ blocks.push(block({
125
+ kind: "agents",
126
+ path: "Subagent descriptions",
127
+ scope: commonScope(inventory.agents),
128
+ note: "Your subagents' descriptions; built-in subagents are not counted",
129
+ }, agents));
130
+ const servers = inventory.mcpServers.filter((s) => s.status === "active").length;
131
+ if (servers > 0)
132
+ blocks.push(block({
133
+ kind: "not-counted",
134
+ path: `Tool names from ${servers} MCP ${servers === 1 ? "server" : "servers"}`,
135
+ scope: "user",
136
+ note: "ccdesk does not connect to MCP servers, so it cannot count their tools",
137
+ }, null));
138
+ return blocks;
139
+ }
@@ -0,0 +1,23 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ export function scanEnvFromProcess() {
4
+ const home = homedir();
5
+ const configDir = process.env.CLAUDE_CONFIG_DIR;
6
+ return {
7
+ home,
8
+ claudeDir: configDir ?? join(home, ".claude"),
9
+ // Claude Code keeps .claude.json beside the config dir it was given.
10
+ claudeJson: configDir ? join(configDir, ".claude.json") : join(home, ".claude.json"),
11
+ managedDir: managedDirFor(process.platform),
12
+ platform: process.platform,
13
+ disableAutoMemory: process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY,
14
+ projectDirName: configDir ? process.env.CLAUDE_CODE_PROJECT_DIR_NAME || undefined : undefined,
15
+ };
16
+ }
17
+ function managedDirFor(platform) {
18
+ if (platform === "darwin")
19
+ return "/Library/Application Support/ClaudeCode";
20
+ if (platform === "win32")
21
+ return "C:\\Program Files\\ClaudeCode";
22
+ return "/etc/claude-code";
23
+ }