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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/server/backups.js +226 -0
- package/dist/server/cli.js +92 -0
- package/dist/server/mutations.js +188 -0
- package/dist/server/open.js +139 -0
- package/dist/server/scan/context.js +139 -0
- package/dist/server/scan/env.js +23 -0
- package/dist/server/scan/files.js +390 -0
- package/dist/server/scan/index.js +266 -0
- package/dist/server/scan/instructions.js +285 -0
- package/dist/server/scan/inventory.js +360 -0
- package/dist/server/scan/mcp.js +255 -0
- package/dist/server/scan/memory.js +91 -0
- package/dist/server/scan/path-test.js +99 -0
- package/dist/server/scan/permissions.js +85 -0
- package/dist/server/scan/plugins.js +117 -0
- package/dist/server/scan/projects.js +100 -0
- package/dist/server/scan/settings.js +317 -0
- package/dist/server/scan/tools.js +107 -0
- package/dist/server/scan/usage.js +441 -0
- package/dist/server/server.js +258 -0
- package/dist/server/settings-edit.js +152 -0
- package/dist/server/watch.js +172 -0
- package/dist/server/write.js +195 -0
- package/dist/shared/builtin-tools.json +235 -0
- package/dist/shared/scopes.js +18 -0
- package/dist/shared/settings-scopes.json +208 -0
- package/dist/shared/settings.schema.json +4623 -0
- package/dist/shared/types.js +2 -0
- package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/dist/ui/assets/index-BM4K7hSO.css +1 -0
- package/dist/ui/assets/index-CN3vkVjv.js +45 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/ui/index.html +15 -0
- package/dist/ui/logo.png +0 -0
- package/package.json +80 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
* **scan:** read Claude Code config the way Claude Code loads it ([1410e35](https://github.com/ayagmar/ccdesk/commit/1410e35647bd3ce361e81e8e81dfb4fd2a67245a))
|
|
8
|
+
* **server:** serve the UI locally with safe, reviewable edits ([c2fe6b6](https://github.com/ayagmar/ccdesk/commit/c2fe6b626d2b7ce42c4076a87307b34f0280f123))
|
|
9
|
+
* **shared:** add config types and Claude Code data generated from upstream ([c084af7](https://github.com/ayagmar/ccdesk/commit/c084af74246caf7e2224b7a480453e4baab198a1))
|
|
10
|
+
* **ui:** add the timetable UI for browsing and fixing config ([cb25c0f](https://github.com/ayagmar/ccdesk/commit/cb25c0f9edb081f323ca6aa7fc477a7c0db2de69))
|
|
11
|
+
* **usage:** count tool calls from transcripts and suggest allow rules ([84687e5](https://github.com/ayagmar/ccdesk/commit/84687e5fabeaea62eaf2b405eee7236f4bd289f5))
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **scan:** show git-ignored files as ignored on Windows ([38982e3](https://github.com/ayagmar/ccdesk/commit/38982e3ab6a9d841ef147c270ee285bc526124a5))
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ayagmar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ayagmar/ccdesk/master/assets/brand/banner-dark.webp" />
|
|
3
|
+
<img alt="Murmeli the marmot, ccdesk's station master, waving from a station desk beside a line of five coloured stops" src="https://raw.githubusercontent.com/ayagmar/ccdesk/master/assets/brand/banner-light.webp" />
|
|
4
|
+
</picture>
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
# ccdesk
|
|
7
|
+
|
|
8
|
+
[](https://www.npmjs.com/package/ccdesk)
|
|
9
|
+
[](https://github.com/ayagmar/ccdesk/actions/workflows/ci.yml)
|
|
10
|
+
[](package.json)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
|
|
13
|
+
See and change everything that shapes Claude Code on your machine: instruction files, settings, hooks, MCP servers, subagents, skills, plugins and auto memory.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx ccdesk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
ccdesk opens in your browser on the directory you ran it from and answers one question: **what is Claude told here, and where does each piece come from?**
|
|
20
|
+
|
|
21
|
+
It is not a session viewer or a usage dashboard. It reads your config, shows it in the order Claude Code loads it, and lets you fix it.
|
|
22
|
+
|
|
23
|
+
## What you see
|
|
24
|
+
|
|
25
|
+
For any directory Claude Code has been used in:
|
|
26
|
+
|
|
27
|
+
- **Instructions.** Every `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md` and `.claude/rules/*.md` that applies, in load order. Each file shows whether it is loaded, skipped (and why), or loaded only for matching paths. It also lists the `@imports` it pulls in, whether it is tracked in git, untracked, git-ignored or personal, and whether it is a link into `~/.agents` that other coding agents read too.
|
|
28
|
+
- **Settings.** Every key with the value Claude Code ends up using and the file it comes from: managed (including `managed-settings.d/`), user, project or local. Local settings are read from the repository root, as Claude Code does. Values overridden by a higher file are struck through, lists that merge across files are marked as merged, and a key set in a file Claude Code does not read it from is marked ignored.
|
|
29
|
+
- **Permissions.** Every `allow`, `ask` and `deny` rule from every settings file, plus `defaultMode` and extra folders. Claude Code checks deny, then ask, then allow across all files, so an allow rule that the same rule in deny or ask overrides is flagged. Rules that wait for you to trust the folder, or that managed settings switch off, are marked as not in effect. An allow rule that covered no call in those sessions is marked, and calls Claude Code asked you about and you allowed show up as **suggested** allow rules, such as `Bash(pnpm test *)` or `WebFetch(domain:nodejs.org)`, with how many times you allowed them. A rule is not suggested when it would also cover a call you refused, or when your rules already decide those calls. Bash rules are suggested only for read-only commands such as `ls` or `jq`, for subcommands such as `git status` or `gh pr view` whose flags cannot run another command or write a file you did not name, and for script runners such as `pnpm test` or `git commit`, the same rule Claude Code's own prompt offers. A script runner runs your project's scripts or hooks, and npm and pnpm flags such as `--node-options` can make it run other code. A rule for `sed`, `make`, `go test`, `gh repo`, `git stash` or an install could allow far more than you approved.
|
|
30
|
+
- **Built-in tools.** Every tool Claude Code ships with, what it does, and what happens when Claude reaches for it here: removed by a deny rule, asks first, or runs without asking. Rules that cover only some uses, such as `Bash(git push *)` or an `Edit` path, and `PreToolUse` hooks that match the tool are listed with it. Outcomes are as in Manual mode; the header says which mode sessions here start in. **Used** counts how often Claude called each tool in the sessions started here, or anywhere in the repository and its worktrees, since rules at a repository's root apply there too.
|
|
31
|
+
- **Memory.** Auto memory for the directory, with filters by type (user, feedback, project, reference). Checks flag memories missing from `MEMORY.md`, broken `[[links]]`, a `MEMORY.md` too long to load in full, and memories saved in more than one directory. `autoMemoryEnabled`, `autoMemoryDirectory` and `CLAUDE_CODE_DISABLE_AUTO_MEMORY` are taken into account.
|
|
32
|
+
- **Hooks, MCP servers, subagents, skills, commands, output styles and plugins**, each marked with the scope it comes from. Hooks turned off by `disableAllHooks` or `allowManagedHooksOnly`, MCP servers that wait for approval, are turned off, rejected or overridden by a closer definition, and plugins that are off are marked. Project skills come from the start directory and every parent up to the repository root. Subagents, skills and plugins can be filtered by kind.
|
|
33
|
+
- **All settings.** Every key in the settings reference, set or not, grouped by area: what it does, which files Claude Code reads it from, its documented values and default, and the value it has here. Pick one to set it in the file you choose.
|
|
34
|
+
- **Health checks.** Instruction files longer than the 200 lines the docs suggest, paragraphs repeated across files, and imports of files that do not exist.
|
|
35
|
+
|
|
36
|
+
The header says when you have not yet accepted Claude Code's trust dialog for the folder, which is read from `~/.claude.json`. Settings files and `.mcp.json` files that are not valid JSON are listed at the top.
|
|
37
|
+
|
|
38
|
+
If the directory is in a git repository, a strip under the line shows which of these files are tracked in git (everyone who clones gets them), not tracked yet, or git-ignored.
|
|
39
|
+
|
|
40
|
+
**What Claude sees** switches to what your config puts in context when a session starts here, in load order: a custom output style, instruction files and their imports (without frontmatter and HTML comments, which Claude Code strips), the part of `MEMORY.md` that loads, the skill listing and your subagents' descriptions. Each shows its size and an estimated token count. Claude Code's system prompt and the tool names of MCP servers are listed but not counted. ccdesk does not show the text of an import outside the project and your Claude folders, and imports that wait for your approval are not counted.
|
|
41
|
+
|
|
42
|
+
Below it, **When Claude touches a file** takes a path and lists what joins the session once Claude reads or edits it: path rules, skills with matching `paths`, and the `CLAUDE.md` files of the folders on the way.
|
|
43
|
+
|
|
44
|
+
The line across the top is the load order: **M**anaged, **U**ser, **P**roject, **L**ocal, **A**uto memory and plugins (**X**). Click a stop to show only that scope.
|
|
45
|
+
|
|
46
|
+
Every table can be filtered with `/`, narrowed with the chips above it, and sorted by clicking a column. Long tables show 50 rows at a time. Filters and sorting are kept in the URL, so a reload or a shared link opens the same view.
|
|
47
|
+
|
|
48
|
+
**All directories** lists every directory Claude Code has sessions or memory for, and memories that exist in more than one of them.
|
|
49
|
+
|
|
50
|
+
## What you can change
|
|
51
|
+
|
|
52
|
+
- **Settings.** Pick a key from the schema, which comes with autocomplete and descriptions, or open an existing one. Choose which file to write: user, project or local; files Claude Code does not read the key from are not offered. Each value gets the control that fits its type: a checkbox, a choice list, a number field, a text field with the documented values one click away, one entry per line, or JSON. Before anything is written you see the exact diff, and Save writes exactly that diff: if the file changed in the meantime, ccdesk asks you to review again. Values are checked against Claude Code's settings schema, other keys are left as they are, and the old file is backed up first. A file that is a symlink, such as one in a dotfiles repository, is written through and stays a link. Undo is one click.
|
|
53
|
+
- **Permission rules.** Move a rule to another list, remove it, or add a new one to the file you choose, with the same diff review and backup. A suggested rule goes into local settings by default, as Claude Code's "don't ask again" does.
|
|
54
|
+
- **Built-in tools.** Turn a tool off for Claude with a deny rule in the file you choose, and turn it back on by removing the rules that remove it. A rule from managed settings cannot be removed, so the tool stays off.
|
|
55
|
+
- **Memory.** Delete a memory you no longer want. ccdesk also removes its line from `MEMORY.md`, warns you if other memories link to it, keeps a backup, and offers undo. A memory missing from `MEMORY.md` can be added to it, and lines pointing at deleted files can be removed. Each of these shows the exact change to `MEMORY.md` first and is refused if the memory or the index changed after you reviewed it.
|
|
56
|
+
- **Open in your editor.** Every file path has an open button. ccdesk uses `$CCDESK_EDITOR`, then `$VISUAL` or `$EDITOR` when they name a graphical editor, and otherwise your system's default app.
|
|
57
|
+
|
|
58
|
+
Backups go to `~/.claude/backups/ccdesk/`. **Recent changes** lists every change ccdesk made. Each one can be restored after you review the diff, and the current file is backed up before it is replaced.
|
|
59
|
+
|
|
60
|
+
ccdesk never deletes sessions and never writes `~/.claude.json`.
|
|
61
|
+
|
|
62
|
+
## Keyboard
|
|
63
|
+
|
|
64
|
+
| Key | Action |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `j` / `k` | Next / previous row |
|
|
67
|
+
| `1`–`9` | Jump to a section |
|
|
68
|
+
| `/` | Filter rows |
|
|
69
|
+
| `Ctrl K` / `⌘ K` | Jump to any directory, file, setting or rule, or a memory saved for any directory |
|
|
70
|
+
| `Esc` | Close details |
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
npx ccdesk [options]
|
|
76
|
+
|
|
77
|
+
--port <n> Port to listen on (default: a free port)
|
|
78
|
+
--no-open Print the URL instead of opening a browser
|
|
79
|
+
-h, --help Show this help
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
ccdesk reads `~/.claude`, or `$CLAUDE_CONFIG_DIR` when it is set. It watches the config files for the open directory, so edits made in another editor or by Claude show up within a second. `~/.claude.json` is not watched, because Claude Code rewrites it during every session; changes to it show up when you return to ccdesk's tab. Requires Node.js 22.19 or newer.
|
|
83
|
+
|
|
84
|
+
## Privacy and safety
|
|
85
|
+
|
|
86
|
+
- The server listens on `127.0.0.1` only. Every API call needs a token generated for that run, which the browser receives in the URL fragment. Requests from other hosts or origins are refused.
|
|
87
|
+
- Nothing leaves your machine.
|
|
88
|
+
- Values under `env` and `headers` (in settings and MCP servers) are hidden in the UI, including in diffs and in a settings file reached through an import or a link. ccdesk never changes them: an edit that touches them is refused, and so is a restore that would put back older values. The user name, password and query string of a `url` (such as an MCP server's) are hidden too, wherever ccdesk shows it, and errors about a broken JSON file give only the line and column.
|
|
89
|
+
- ccdesk never reads `~/.claude/.credentials.json`, even through a link or an import, and never writes `~/.claude.json`, even through a settings file that links to it. A `MEMORY.md` that links outside its memory folder is left alone.
|
|
90
|
+
- To count tool calls and your answers to permission prompts, ccdesk reads the open directory's transcripts in `~/.claude/projects/`. Only counts and rule patterns (a command's program and subcommand, a fetch's domain) reach the page, never commands, file contents or anything else from a session. Your answers come from a transcript field Claude Code does not document, so older transcripts may not have them.
|
|
91
|
+
- Backups can hold settings with secrets, so their folders and files are readable only by you.
|
|
92
|
+
- Markdown is rendered without raw HTML, so an `AGENTS.md` from a cloned repo cannot run script in the page.
|
|
93
|
+
|
|
94
|
+
## Settings schema
|
|
95
|
+
|
|
96
|
+
The settings schema is generated from the `Settings` type in [`@anthropic-ai/claude-agent-sdk`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk), which is published with each Claude Code release. Which files each key is read from comes from the Scope column of the [settings reference](https://code.claude.com/docs/en/settings-reference). The built-in tools come from the [tools reference](https://code.claude.com/docs/en/tools-reference). The settings catalog comes from the same reference. A daily workflow regenerates all of them and opens a pull request when any of them changed. Some of Claude Code's behaviour, such as how rules match commands, is only described in the docs, so ccdesk follows it by hand and cites the docs section in the code; the workflow also fingerprints every cited section and lists the ones that changed in the pull request.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
pnpm install
|
|
102
|
+
pnpm dev # API on 127.0.0.1:4319
|
|
103
|
+
pnpm dev:ui # UI with hot reload on 127.0.0.1:4320
|
|
104
|
+
pnpm check # typecheck, lint, format, tests, build
|
|
105
|
+
pnpm schema # regenerate src/shared/settings.schema.json
|
|
106
|
+
pnpm scopes # regenerate src/shared/settings-scopes.json from the settings reference
|
|
107
|
+
pnpm tools # regenerate src/shared/builtin-tools.json from the tools reference
|
|
108
|
+
pnpm docs:fingerprints # fingerprint the docs sections the code cites; prints the ones that changed
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Tests never touch your real `~/.claude`. Each one builds a throwaway home directory.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import {} from "../shared/types.js";
|
|
4
|
+
import { assertMemoryDir, indexPathIn, revisionOfBoth } from "./mutations.js";
|
|
5
|
+
import {} from "./scan/env.js";
|
|
6
|
+
import { isRecord, isWithin, redactSecrets, revisionOf, shownText, shownUrl, } from "./scan/files.js";
|
|
7
|
+
import { backupRoot, MANIFEST, RefusedError, readIfExists, recordChange, removeExactly, SNAPSHOT, serially, writeAtomic, } from "./write.js";
|
|
8
|
+
const KINDS = new Set(["settings", "memory-index", "memory-delete"]);
|
|
9
|
+
function strings(value) {
|
|
10
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
11
|
+
}
|
|
12
|
+
/** A backup folder's manifest; anything that does not look like one ccdesk wrote is skipped. */
|
|
13
|
+
async function readManifest(env, id) {
|
|
14
|
+
// ccdesk's ids are letters, digits and dashes, so no id can name a folder outside the root.
|
|
15
|
+
if (!/^[\w-]+$/.test(id))
|
|
16
|
+
return null;
|
|
17
|
+
const text = await readIfExists(env, join(backupRoot(env), id, MANIFEST)).catch(() => null);
|
|
18
|
+
if (text === null)
|
|
19
|
+
return null;
|
|
20
|
+
let data;
|
|
21
|
+
try {
|
|
22
|
+
data = JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (!isRecord(data) ||
|
|
28
|
+
typeof data.kind !== "string" ||
|
|
29
|
+
!KINDS.has(data.kind) ||
|
|
30
|
+
typeof data.target !== "string" ||
|
|
31
|
+
typeof data.createdAt !== "string" ||
|
|
32
|
+
typeof data.before !== "string" ||
|
|
33
|
+
typeof data.after !== "string" ||
|
|
34
|
+
(data.removedIndexLines !== undefined && !strings(data.removedIndexLines)))
|
|
35
|
+
return null;
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
/** The changes ccdesk made, newest first, a page at a time. */
|
|
39
|
+
export async function listBackups(env, { before, limit }) {
|
|
40
|
+
const ids = (await readdir(backupRoot(env)).catch(() => [])).sort().reverse();
|
|
41
|
+
const changes = [];
|
|
42
|
+
for (const id of ids) {
|
|
43
|
+
if (before !== undefined && id >= before)
|
|
44
|
+
continue;
|
|
45
|
+
const manifest = await readManifest(env, id);
|
|
46
|
+
if (!manifest)
|
|
47
|
+
continue;
|
|
48
|
+
if (changes.length === limit)
|
|
49
|
+
return { changes, more: true };
|
|
50
|
+
changes.push({
|
|
51
|
+
id,
|
|
52
|
+
kind: manifest.kind,
|
|
53
|
+
target: manifest.target,
|
|
54
|
+
createdAt: manifest.createdAt,
|
|
55
|
+
created: manifest.before === revisionOf(null),
|
|
56
|
+
...(manifest.after === "pending" || manifest.after === "partial" ? { incomplete: true } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return { changes, more: false };
|
|
60
|
+
}
|
|
61
|
+
/** A settings file Claude Code reads: your user file, or settings(.local).json in a .claude folder. */
|
|
62
|
+
function isSettingsFile(env, path) {
|
|
63
|
+
if (path === join(env.claudeDir, "settings.json"))
|
|
64
|
+
return true;
|
|
65
|
+
return (["settings.json", "settings.local.json"].includes(basename(path)) &&
|
|
66
|
+
basename(dirname(path)) === ".claude");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Every value ccdesk shows hidden and never changes in a settings file: env and headers objects,
|
|
70
|
+
* and URLs with a user, password or query.
|
|
71
|
+
*/
|
|
72
|
+
function hiddenValues(text) {
|
|
73
|
+
const found = [];
|
|
74
|
+
const walk = (value) => {
|
|
75
|
+
if (Array.isArray(value))
|
|
76
|
+
value.forEach(walk);
|
|
77
|
+
if (!isRecord(value))
|
|
78
|
+
return;
|
|
79
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
80
|
+
if ((key === "env" || key === "headers") && isRecord(inner))
|
|
81
|
+
found.push(inner);
|
|
82
|
+
else if (key === "url" && typeof inner === "string" && shownUrl(inner) !== inner)
|
|
83
|
+
found.push(inner);
|
|
84
|
+
else
|
|
85
|
+
walk(inner);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
walk(text === null ? null : JSON.parse(text));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return text ?? "";
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify(found);
|
|
95
|
+
}
|
|
96
|
+
/** Settings are shown parsed with env and header values hidden, as everywhere else in ccdesk. */
|
|
97
|
+
function shownSettings(text) {
|
|
98
|
+
if (text === null)
|
|
99
|
+
return null;
|
|
100
|
+
try {
|
|
101
|
+
return `${JSON.stringify(redactSecrets(JSON.parse(text)), null, 2)}\n`;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "(not valid JSON; open the file to see it)\n";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function planRestore(env, id) {
|
|
108
|
+
const manifest = await readManifest(env, id);
|
|
109
|
+
if (!manifest)
|
|
110
|
+
throw new RefusedError("That backup is gone or was not made by ccdesk.");
|
|
111
|
+
const root = await realpath(backupRoot(env));
|
|
112
|
+
const dir = await realpath(join(root, id));
|
|
113
|
+
if (!isWithin(root, dir) || dir === root)
|
|
114
|
+
throw new RefusedError("Not a ccdesk backup.");
|
|
115
|
+
let target = manifest.target;
|
|
116
|
+
if (manifest.kind === "settings" && !isSettingsFile(env, target))
|
|
117
|
+
throw new RefusedError("This backup does not point at a settings file.");
|
|
118
|
+
if (manifest.kind === "memory-index" && basename(target) !== "MEMORY.md")
|
|
119
|
+
throw new RefusedError("This backup does not point at a MEMORY.md.");
|
|
120
|
+
if (manifest.kind !== "settings") {
|
|
121
|
+
const store = await assertMemoryDir(env, dirname(target));
|
|
122
|
+
// The same confinement as any other MEMORY.md change: a link out of the folder is refused.
|
|
123
|
+
target =
|
|
124
|
+
manifest.kind === "memory-index" ? await indexPathIn(store) : join(store, basename(target));
|
|
125
|
+
// A deleted memory comes back only as a topic file, as ccdesk deletes only those.
|
|
126
|
+
if (manifest.kind === "memory-delete" &&
|
|
127
|
+
(!target.endsWith(".md") || basename(target) === "MEMORY.md"))
|
|
128
|
+
throw new RefusedError("This backup does not point at a memory file.");
|
|
129
|
+
}
|
|
130
|
+
let saved = null;
|
|
131
|
+
if (manifest.before !== revisionOf(null)) {
|
|
132
|
+
const snapshot = join(dir, SNAPSHOT);
|
|
133
|
+
if (!(await lstat(snapshot).catch(() => null))?.isFile())
|
|
134
|
+
throw new RefusedError("The backed-up file is missing from the backup folder.");
|
|
135
|
+
saved = await readFile(snapshot, "utf8");
|
|
136
|
+
if (revisionOf(saved) !== manifest.before)
|
|
137
|
+
throw new RefusedError("The backed-up file was changed after ccdesk saved it.");
|
|
138
|
+
}
|
|
139
|
+
const plan = {
|
|
140
|
+
manifest: { ...manifest, target },
|
|
141
|
+
current: await readIfExists(env, target),
|
|
142
|
+
saved,
|
|
143
|
+
};
|
|
144
|
+
if (manifest.kind === "memory-delete") {
|
|
145
|
+
const path = await indexPathIn(dirname(target));
|
|
146
|
+
const text = await readIfExists(env, path);
|
|
147
|
+
const missing = (manifest.removedIndexLines ?? []).filter((l) => !(text ?? "").split("\n").includes(l));
|
|
148
|
+
plan.index = { path, text, missing };
|
|
149
|
+
}
|
|
150
|
+
return plan;
|
|
151
|
+
}
|
|
152
|
+
/** What a restore must find unchanged: the file, and for a deleted memory MEMORY.md as well. */
|
|
153
|
+
function planRevision({ current, index }) {
|
|
154
|
+
return index ? revisionOfBoth(current, index.text) : revisionOf(current);
|
|
155
|
+
}
|
|
156
|
+
function blockedReason({ manifest, current, saved }) {
|
|
157
|
+
if (saved === null)
|
|
158
|
+
return "ccdesk created this file, so there is no earlier version to put back. Change it in place instead.";
|
|
159
|
+
if (manifest.kind === "settings" && hiddenValues(current) !== hiddenValues(saved))
|
|
160
|
+
return "Hidden values in this file (env, headers or a URL's key) changed since the backup. ccdesk does not show or change those, so restore this one by hand.";
|
|
161
|
+
// A retry after a restore that wrote the memory but not MEMORY.md finds the memory already back.
|
|
162
|
+
if (manifest.kind === "memory-delete" && current !== null && current !== saved)
|
|
163
|
+
return "A memory with this file name exists again; delete or rename it first.";
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
export async function previewRestore(env, id) {
|
|
167
|
+
const plan = await planRestore(env, id);
|
|
168
|
+
const { manifest, current, saved, index } = plan;
|
|
169
|
+
const shown = manifest.kind === "settings" ? shownSettings : shownText;
|
|
170
|
+
const before = shown(current);
|
|
171
|
+
const after = shown(saved) ?? "";
|
|
172
|
+
const blocked = blockedReason(plan);
|
|
173
|
+
return {
|
|
174
|
+
id,
|
|
175
|
+
kind: manifest.kind,
|
|
176
|
+
target: manifest.target,
|
|
177
|
+
before,
|
|
178
|
+
after,
|
|
179
|
+
revision: planRevision(plan),
|
|
180
|
+
...(blocked ? { blocked } : {}),
|
|
181
|
+
...(index && index.missing.length > 0 ? { indexLines: index.missing } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Puts a backed-up file back, if the file is still as it was when the restore was reviewed. */
|
|
185
|
+
export function restoreBackup(env, id, expectedRevision) {
|
|
186
|
+
return serially(async () => {
|
|
187
|
+
const plan = await planRestore(env, id);
|
|
188
|
+
const { manifest, current, saved, index } = plan;
|
|
189
|
+
const blocked = blockedReason(plan);
|
|
190
|
+
if (blocked || saved === null)
|
|
191
|
+
throw new RefusedError(blocked);
|
|
192
|
+
if (planRevision(plan) !== expectedRevision)
|
|
193
|
+
throw new RefusedError("The file changed since you reviewed this restore; review it again.");
|
|
194
|
+
const { target } = manifest;
|
|
195
|
+
if (manifest.kind !== "memory-delete") {
|
|
196
|
+
await recordChange(env, { kind: manifest.kind, target, before: current }, async () => {
|
|
197
|
+
await writeAtomic(env, target, saved, revisionOf(current));
|
|
198
|
+
return { revision: revisionOf(saved) };
|
|
199
|
+
});
|
|
200
|
+
return { target };
|
|
201
|
+
}
|
|
202
|
+
// A memory and its MEMORY.md lines come back together: when the index cannot be written, the
|
|
203
|
+
// memory just put back is removed again, so a refused restore leaves both as they were.
|
|
204
|
+
const created = current === null;
|
|
205
|
+
if (created)
|
|
206
|
+
await writeAtomic(env, target, saved, revisionOf(null));
|
|
207
|
+
try {
|
|
208
|
+
if (index && index.missing.length > 0) {
|
|
209
|
+
const lines = (index.text ?? "").split("\n");
|
|
210
|
+
while (lines.at(-1) === "")
|
|
211
|
+
lines.pop();
|
|
212
|
+
const next = `${[...lines, ...index.missing].join("\n")}\n`;
|
|
213
|
+
await recordChange(env, { kind: "memory-index", target: index.path, before: index.text }, async () => {
|
|
214
|
+
await writeAtomic(env, index.path, next, revisionOf(index.text));
|
|
215
|
+
return { revision: revisionOf(next) };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (created)
|
|
221
|
+
await removeExactly(target, revisionOf(saved));
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return { target };
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { scanEnvFromProcess } from "./scan/env.js";
|
|
7
|
+
import { createCcdeskServer } from "./server.js";
|
|
8
|
+
const HELP = `ccdesk: see and manage what shapes Claude Code on this machine
|
|
9
|
+
|
|
10
|
+
Usage: npx ccdesk [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--port <n> Port to listen on (default: a free port)
|
|
14
|
+
--no-open Print the URL instead of opening a browser
|
|
15
|
+
-h, --help Show this help
|
|
16
|
+
|
|
17
|
+
ccdesk reads ~/.claude (or $CLAUDE_CONFIG_DIR) and opens on the directory you run it from.
|
|
18
|
+
It listens on 127.0.0.1 only.`;
|
|
19
|
+
const DEV_PORT = 4319;
|
|
20
|
+
const DEV_TOKEN = "dev";
|
|
21
|
+
function options() {
|
|
22
|
+
try {
|
|
23
|
+
return parseArgs({
|
|
24
|
+
options: {
|
|
25
|
+
port: { type: "string" },
|
|
26
|
+
open: { type: "boolean", default: true },
|
|
27
|
+
dev: { type: "boolean", default: false },
|
|
28
|
+
help: { type: "boolean", short: "h", default: false },
|
|
29
|
+
},
|
|
30
|
+
allowNegative: true,
|
|
31
|
+
}).values;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
console.error(`ccdesk: ${error.message}\n\n${HELP}`);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function main() {
|
|
39
|
+
const values = options();
|
|
40
|
+
if (values.help) {
|
|
41
|
+
console.log(HELP);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const dev = values.dev;
|
|
45
|
+
const port = values.port ? Number(values.port) : dev ? DEV_PORT : 0;
|
|
46
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
47
|
+
console.error(`ccdesk: invalid --port ${values.port}`);
|
|
48
|
+
process.exitCode = 2;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const token = dev ? DEV_TOKEN : randomBytes(18).toString("base64url");
|
|
52
|
+
const server = createCcdeskServer({
|
|
53
|
+
env: scanEnvFromProcess(),
|
|
54
|
+
launchDir: process.cwd(),
|
|
55
|
+
token,
|
|
56
|
+
uiDir: dev ? null : fileURLToPath(new URL("../ui", import.meta.url)),
|
|
57
|
+
});
|
|
58
|
+
server.on("error", (error) => {
|
|
59
|
+
console.error(error.code === "EADDRINUSE"
|
|
60
|
+
? `ccdesk: port ${port} is already in use`
|
|
61
|
+
: `ccdesk: ${error.message}`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
});
|
|
64
|
+
server.listen(port, "127.0.0.1", () => {
|
|
65
|
+
const address = server.address();
|
|
66
|
+
const actual = typeof address === "object" && address ? address.port : port;
|
|
67
|
+
if (dev) {
|
|
68
|
+
console.log(`ccdesk API on http://127.0.0.1:${actual} (dev token "${DEV_TOKEN}"); run \`pnpm dev:ui\` for the UI`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const url = `http://127.0.0.1:${actual}/#token=${token}`;
|
|
72
|
+
console.log(`ccdesk is running at ${url}\nPress Ctrl+C to stop.`);
|
|
73
|
+
if (values.open)
|
|
74
|
+
openBrowser(url);
|
|
75
|
+
});
|
|
76
|
+
process.once("SIGINT", () => {
|
|
77
|
+
// An open browser tab keeps a connection alive, which would hold close() open.
|
|
78
|
+
server.close(() => process.exit(0));
|
|
79
|
+
server.closeAllConnections();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function openBrowser(url) {
|
|
83
|
+
const [command, args] = process.platform === "darwin"
|
|
84
|
+
? ["open", [url]]
|
|
85
|
+
: process.platform === "win32"
|
|
86
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
87
|
+
: ["xdg-open", [url]];
|
|
88
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
89
|
+
child.on("error", () => console.log("Could not open a browser; open the URL above."));
|
|
90
|
+
child.unref();
|
|
91
|
+
}
|
|
92
|
+
main();
|