ccdesk 0.0.0-stage → 0.2.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 (56) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -2
  4. package/dist/server/agent-edit.js +77 -0
  5. package/dist/server/backups.js +303 -0
  6. package/dist/server/cli.js +105 -0
  7. package/dist/server/mcp-edit.js +263 -0
  8. package/dist/server/mutations.js +193 -0
  9. package/dist/server/open.js +139 -0
  10. package/dist/server/scan/compare.js +34 -0
  11. package/dist/server/scan/context.js +140 -0
  12. package/dist/server/scan/env.js +28 -0
  13. package/dist/server/scan/files.js +478 -0
  14. package/dist/server/scan/index.js +280 -0
  15. package/dist/server/scan/instructions.js +289 -0
  16. package/dist/server/scan/inventory.js +452 -0
  17. package/dist/server/scan/mcp.js +313 -0
  18. package/dist/server/scan/memory.js +90 -0
  19. package/dist/server/scan/path-test.js +121 -0
  20. package/dist/server/scan/permissions.js +101 -0
  21. package/dist/server/scan/plugins.js +124 -0
  22. package/dist/server/scan/projects.js +101 -0
  23. package/dist/server/scan/sandbox.js +161 -0
  24. package/dist/server/scan/settings.js +330 -0
  25. package/dist/server/scan/tools.js +111 -0
  26. package/dist/server/scan/usage.js +514 -0
  27. package/dist/server/schema.js +15 -0
  28. package/dist/server/secrets.js +63 -0
  29. package/dist/server/server.js +352 -0
  30. package/dist/server/settings-edit.js +221 -0
  31. package/dist/server/watch.js +214 -0
  32. package/dist/server/write.js +233 -0
  33. package/dist/shared/builtin-tools.json +235 -0
  34. package/dist/shared/compare.js +1 -0
  35. package/dist/shared/hook-events.json +35 -0
  36. package/dist/shared/mcp-server.schema.json +25 -0
  37. package/dist/shared/memory.js +4 -0
  38. package/dist/shared/scopes.js +20 -0
  39. package/dist/shared/settings-scopes.json +208 -0
  40. package/dist/shared/settings.schema.json +4623 -0
  41. package/dist/shared/types.js +2 -0
  42. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  43. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  44. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  45. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  46. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  47. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  48. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  49. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  50. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  51. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  52. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  53. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  54. package/dist/ui/index.html +15 -0
  55. package/dist/ui/logo.png +0 -0
  56. package/package.json +86 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,90 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/ayagmar/ccdesk/compare/v0.1.0...v0.2.0) (2026-10-12)
4
+
5
+ ### Features
6
+
7
+ * **agents:** create, change and delete subagent files ([1d8d011](https://github.com/ayagmar/ccdesk/commit/1d8d011c93c08dc8435ad304f5061378e8c4c1b7)), references [sub-agents#supported-frontmatter-fields](https://github.com/sub-agents/issues/supported-frontmatter-fields)
8
+ * **cli:** add --config-dir to read another Claude folder ([777712a](https://github.com/ayagmar/ccdesk/commit/777712aeb91da62f3f323292c8050b600ff59f5a))
9
+ * compare the settings of two directories ([9ec5e56](https://github.com/ayagmar/ccdesk/commit/9ec5e56b0d4f1cfc85b48accaea00ac59cf592c2))
10
+ * describe a claude -p or SDK session as well as an interactive one ([45c0ad1](https://github.com/ayagmar/ccdesk/commit/45c0ad180c1a26074d151375f99b80ddb20dd99e)), references [permissions#what-runs-before-you-trust-a-folder](https://github.com/permissions/issues/what-runs-before-you-trust-a-folder) [#when-your-local-settings-file-needs-trust](https://github.com/ayagmar/ccdesk/issues/when-your-local-settings-file-needs-trust) [hooks#workspace-trust](https://github.com/hooks/issues/workspace-trust)
11
+ * **hooks:** add, change and remove hooks in settings files ([7307b6b](https://github.com/ayagmar/ccdesk/commit/7307b6b1f3b7baa1ced8db4be6695e8456c87ef8))
12
+ * list claude mcp edits in Recent changes ([bfc153c](https://github.com/ayagmar/ccdesk/commit/bfc153c5f3489143d73c2cfcc75a5fc2c1175e93))
13
+ * **mcp:** add, change and remove MCP servers ([40f88a6](https://github.com/ayagmar/ccdesk/commit/40f88a6cce41f4c04d3816dfbd540cb2435482e9))
14
+ * **mcp:** check server entries against a schema of the documented fields ([3bfac6c](https://github.com/ayagmar/ccdesk/commit/3bfac6c884c07c03430fd896fe5ec2ba558cbc43))
15
+ * **mcp:** say what approved, rejected or holds a .mcp.json server ([1a42288](https://github.com/ayagmar/ccdesk/commit/1a4228880a81a24468caebab84aa27e85fdbde62))
16
+ * **site:** add the documentation site with llms.txt ([25148e4](https://github.com/ayagmar/ccdesk/commit/25148e43f4f5190c000f0e3f4aaeb98872c9ef53))
17
+ * **site:** capture frames for a video with screenshots --video ([e754f1f](https://github.com/ayagmar/ccdesk/commit/e754f1fd910834044158ca3c2125d706e3ca5d1c))
18
+ * **site:** light up each scope's rows in the hero screenshot ([af20413](https://github.com/ayagmar/ccdesk/commit/af204130ea0e1eee8cd428ba7a67df10bb0a6ffa))
19
+ * **site:** open each landing page question to the rows that answer it ([29d6815](https://github.com/ayagmar/ccdesk/commit/29d6815b628e68751d9de91d10c07a5805d27bc1))
20
+ * **site:** open screenshots in place, add motion and load faster ([340ee81](https://github.com/ayagmar/ccdesk/commit/340ee817449d06910fbc3ae3f6f2003c804014d9))
21
+ * **ui:** say that the page describes an interactive session ([c618a77](https://github.com/ayagmar/ccdesk/commit/c618a776a1313d44f8b88c05c9a660e64e55bb38))
22
+ * undo a change that created its file by removing the file ([e6297e3](https://github.com/ayagmar/ccdesk/commit/e6297e35296b0c2a9f4fd222bfff71f78216211e))
23
+
24
+ ### Bug Fixes
25
+
26
+ * **agents:** save a new agent typed as reviewer.md as reviewer.md, not reviewer.md.md ([2d07508](https://github.com/ayagmar/ccdesk/commit/2d07508eeb50dd9fff98e9816e58c1eaa25abc21))
27
+ * **backups:** record restored memories and restore halfway deletes ([3bfcbcf](https://github.com/ayagmar/ccdesk/commit/3bfcbcfd487956dd9eddd9f7252c9093887f95d9))
28
+ * hide keys in MCP server args ([922475c](https://github.com/ayagmar/ccdesk/commit/922475c067b313f75aee2332f01066bc4739ab55))
29
+ * **hooks:** hide the user name, password and query of an http hook's URL ([8b40d25](https://github.com/ayagmar/ccdesk/commit/8b40d25867ca6cbf76164a27584a15309803bb44))
30
+ * **instructions:** head your user CLAUDE.md as ~/.claude/CLAUDE.md, not .claude/CLAUDE.md ([683b1c8](https://github.com/ayagmar/ccdesk/commit/683b1c88bd244f1b033b3f546f7944599a0327de))
31
+ * **instructions:** say why a linked rule with paths does not load ([922788d](https://github.com/ayagmar/ccdesk/commit/922788d34fdc9333679d8792eb1865aa70acd831))
32
+ * match what Claude Code loads and keep hidden values out of edits ([9997c26](https://github.com/ayagmar/ccdesk/commit/9997c2652be271f1c6b3326f84218cfbe27f031d))
33
+ * **mcp:** count a "use all servers" answer to the .mcp.json prompt ([3008f9c](https://github.com/ayagmar/ccdesk/commit/3008f9c9792174d2334ce641555167962b7f67e3))
34
+ * **mcp:** hide a server's secrets in claude's own error message ([2b7882b](https://github.com/ayagmar/ccdesk/commit/2b7882b5e1df3d3540c06a5c8d7c559ec6ce4328))
35
+ * **mcp:** pass names safely to claude mcp and keep servers when it fails ([69b9353](https://github.com/ayagmar/ccdesk/commit/69b9353be3ecd96de2c29ed91183bfce0dfc6274))
36
+ * **mcp:** read and add local servers under the repository root only ([c536737](https://github.com/ayagmar/ccdesk/commit/c536737a8e8d05946d43b0bbe74364a890141cd8))
37
+ * **memory:** remove a [[name]] index line when its memory is deleted ([a08fbdf](https://github.com/ayagmar/ccdesk/commit/a08fbdf899c17e958c0ac98678d44e4b9b259dd5))
38
+ * **memory:** stop flagging a 200-line MEMORY.md that ends in a newline as cut off ([07a670b](https://github.com/ayagmar/ccdesk/commit/07a670b209593d315e7850e6145e8443b5c01554))
39
+ * **permissions:** say that ccdesk reads commands and addresses to match rules ([ca60592](https://github.com/ayagmar/ccdesk/commit/ca605920235512d041b03b0fbea57c4967f1ac70))
40
+ * **readme:** link each screenshot to its full-size image ([18837fc](https://github.com/ayagmar/ccdesk/commit/18837fc055aede576926f15d90b031c3b6a19fa9))
41
+ * **scan:** hold back settings-file hooks until the folder is trusted ([ab9ea97](https://github.com/ayagmar/ccdesk/commit/ab9ea973e153b343530ae5c945b8f71a28dd7895)), references [hooks#workspace-trust](https://github.com/hooks/issues/workspace-trust)
42
+ * **scan:** name plugin skills and commands with their plugin ([43d178a](https://github.com/ayagmar/ccdesk/commit/43d178a0f14c9479b576c6260eea1541f5fb6d53)), references [skills#how-a-skill-gets-its-command-name](https://github.com/skills/issues/how-a-skill-gets-its-command-name) [plugins-reference#name](https://github.com/plugins-reference/issues/name)
43
+ * **server:** put a file back when ccdesk cannot read it while removing it ([bfa4931](https://github.com/ayagmar/ccdesk/commit/bfa49318dc4df99605cc570e004cf6ed0b41543e))
44
+ * **server:** refuse a hard link to the credentials file ([8689378](https://github.com/ayagmar/ccdesk/commit/86893788ee20e1c0fc53464306167a5329968d6e))
45
+ * **server:** refuse a restore that would change hidden keys in args ([5ab6b40](https://github.com/ayagmar/ccdesk/commit/5ab6b409db633d82ac2cea37833639eff1d88c8d))
46
+ * **server:** refuse to save a file that has other hard links ([4fe7445](https://github.com/ayagmar/ccdesk/commit/4fe7445daba710babba8e5bc3826060389857f24))
47
+ * **server:** stop on SIGTERM and answer a malformed URL with 400 ([a519fcb](https://github.com/ayagmar/ccdesk/commit/a519fcb7b0f34c21ac35b81b606ec0bc350119d6))
48
+ * **server:** watch what a scan read even when the watch starts after it ([554a3d5](https://github.com/ayagmar/ccdesk/commit/554a3d58e9361c209313d258f665b54a09792757))
49
+ * **settings:** leave out sandbox fields Claude Code ignores from a file ([97bf3a9](https://github.com/ayagmar/ccdesk/commit/97bf3a95407a21682b0579482bd558071698f40a))
50
+ * **settings:** say why a value that may only tighten a key is ignored ([6e01ce8](https://github.com/ayagmar/ccdesk/commit/6e01ce882f08d3a34d1a2725f5371852e6c5b9ce))
51
+ * **settings:** show a value set only in the older local file in the details ([57cbc45](https://github.com/ayagmar/ccdesk/commit/57cbc453449eb1b3562062817091f8baa449ccef))
52
+ * **site:** draw the GitHub icon in the text colour ([f8dce1c](https://github.com/ayagmar/ccdesk/commit/f8dce1c184b98ed168e3f516f040ce22269a4dd3))
53
+ * **site:** line the header search up with the landing page's wider column ([95a1762](https://github.com/ayagmar/ccdesk/commit/95a176252a28e8f98253bd816212af0f8f54111e))
54
+ * **site:** name the app section each landing page question is answered in ([977a65c](https://github.com/ayagmar/ccdesk/commit/977a65c41b43fe03320a9181cbb02e1011c3be64))
55
+ * **tools:** list a whole-Bash rule under Monitor, whose commands it covers ([52e09b5](https://github.com/ayagmar/ccdesk/commit/52e09b5ead6570c620d0ef9ba9ba5cb8a880ac28))
56
+ * **tools:** show a failed count as unknown instead of counting forever ([b7b628d](https://github.com/ayagmar/ccdesk/commit/b7b628d2d6d672a245d9940d51edcfbc42f29f3a))
57
+ * **ui:** give the session switch its own line so the directory name keeps its room ([c43db64](https://github.com/ayagmar/ccdesk/commit/c43db642f6d980f8efea19387d09f11d3082272c))
58
+ * **ui:** give the tables the room until a row is picked and keep the directory name whole ([8059bea](https://github.com/ayagmar/ccdesk/commit/8059bea410755683637338814413fcc7f1b7844a))
59
+ * **ui:** keep an edited draft when the file changes underneath it ([5f72626](https://github.com/ayagmar/ccdesk/commit/5f726268c96160ba703fcec684fd420b0c7a9d3d))
60
+ * **ui:** keep the save toast above sticky table headers ([711c670](https://github.com/ayagmar/ccdesk/commit/711c6706fa276511ebe4968f05d105f7244722cf))
61
+ * **ui:** line up the columns on Recent changes ([112e732](https://github.com/ayagmar/ccdesk/commit/112e732a3eb776ce121e479538521c2f078551cc))
62
+ * **ui:** offer no undo for a change that created its file ([8103ddc](https://github.com/ayagmar/ccdesk/commit/8103ddc8c78ac323039a778be83c3d89f830851a))
63
+ * **ui:** offer only settings files that exist here when adding a rule or hook ([34bc591](https://github.com/ayagmar/ccdesk/commit/34bc5914cd23d87da8b2574bf7e4a5402a1571f8))
64
+ * **ui:** say that claude mcp edits pass values on claude's command line ([06d971e](https://github.com/ayagmar/ccdesk/commit/06d971e3ff9c7af693bbf9b1d9226b82dcf16100))
65
+ * **ui:** ship the small font files instead of inlining them past the CSP ([166ad6a](https://github.com/ayagmar/ccdesk/commit/166ad6a29df96b5df8c6b043e6ed23654226e978))
66
+ * **usage:** answer a stopped count even while one of its reads hangs ([9495017](https://github.com/ayagmar/ccdesk/commit/9495017641d050f15ba33d19e13db0b2fc9c0ed9))
67
+ * **usage:** stop tool-call counts nobody waits for or that run too long ([cc89847](https://github.com/ayagmar/ccdesk/commit/cc898474b3588c5a64c38fca1635e9bed20f1a27))
68
+
69
+ ### Performance Improvements
70
+
71
+ * **server:** cap the usage cache and keep only what matching reads ([6e5eeea](https://github.com/ayagmar/ccdesk/commit/6e5eeea66ce209c9598a417540ae0eb3415986b9))
72
+ * **server:** reuse a current scan for the path tester ([cb48481](https://github.com/ayagmar/ccdesk/commit/cb484810daa5a5469cef5688d740ba7a418a3756))
73
+
74
+ ### Reverts
75
+
76
+ * **site:** drop screenshots --video ([0009af4](https://github.com/ayagmar/ccdesk/commit/0009af451f02da3a2d41847e1ce21fbda2d6e892))
77
+
78
+ ## 0.1.0 (2026-10-11)
79
+
80
+ ### Features
81
+
82
+ * **scan:** read Claude Code config the way Claude Code loads it ([1410e35](https://github.com/ayagmar/ccdesk/commit/1410e35647bd3ce361e81e8e81dfb4fd2a67245a))
83
+ * **server:** serve the UI locally with safe, reviewable edits ([c2fe6b6](https://github.com/ayagmar/ccdesk/commit/c2fe6b626d2b7ce42c4076a87307b34f0280f123))
84
+ * **shared:** add config types and Claude Code data generated from upstream ([c084af7](https://github.com/ayagmar/ccdesk/commit/c084af74246caf7e2224b7a480453e4baab198a1))
85
+ * **ui:** add the timetable UI for browsing and fixing config ([cb25c0f](https://github.com/ayagmar/ccdesk/commit/cb25c0f9edb081f323ca6aa7fc477a7c0db2de69))
86
+ * **usage:** count tool calls from transcripts and suggest allow rules ([84687e5](https://github.com/ayagmar/ccdesk/commit/84687e5fabeaea62eaf2b405eee7236f4bd289f5))
87
+
88
+ ### Bug Fixes
89
+
90
+ * **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,132 @@
1
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ # ccdesk
7
+
8
+ [![npm](https://img.shields.io/npm/v/ccdesk?style=flat-square&color=141414)](https://www.npmjs.com/package/ccdesk)
9
+ [![CI](https://img.shields.io/github/actions/workflow/status/ayagmar/ccdesk/ci.yml?branch=master&style=flat-square&label=ci)](https://github.com/ayagmar/ccdesk/actions/workflows/ci.yml)
10
+ [![node](https://img.shields.io/node/v/ccdesk?style=flat-square&color=141414)](https://github.com/ayagmar/ccdesk/blob/master/package.json)
11
+ [![license](https://img.shields.io/github/license/ayagmar/ccdesk?style=flat-square&color=141414)](https://github.com/ayagmar/ccdesk/blob/master/LICENSE)
12
+
13
+ See and change everything Claude Code loads on your machine: instruction files, settings, permissions, hooks, MCP servers, subagents, skills, plugins and auto memory.
14
+
15
+ ```sh
16
+ npx ccdesk
17
+ ```
18
+
19
+ Each piece lives in its own file, at one of six scopes: managed, user, project, local, auto memory or a plugin. ccdesk opens in your browser on the directory you ran it from, lists everything that applies there in the order Claude Code loads it, and names the file behind each piece. When something is wrong, change it on the same page: you review the diff first, and every edit has a backup and an undo. It is not a session viewer or a usage dashboard.
20
+
21
+ **Docs:** [ayagmar.github.io/ccdesk](https://ayagmar.github.io/ccdesk/)
22
+
23
+ <a href="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/directory-dark.webp"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/directory-dark.webp" /><img alt="ccdesk on a made-up project: the load order across the top, then instruction files and settings, each marked with the scope it comes from" src="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/directory-light.webp" /></picture></a>
24
+
25
+ ## Questions it answers
26
+
27
+ - Why does Claude ask me before every `git push` here? ([Permissions](https://ayagmar.github.io/ccdesk/permissions/))
28
+ - Which model does this repository use, and which file set it? ([Settings](https://ayagmar.github.io/ccdesk/settings/))
29
+ - Is my `AGENTS.md` loaded at all? ([Instructions](https://ayagmar.github.io/ccdesk/instructions/))
30
+ - How much context does my config take before I type anything? ([What Claude sees](https://ayagmar.github.io/ccdesk/what-claude-sees/))
31
+ - What do I keep approving by hand, and which allow rules have I stopped needing? ([Permissions](https://ayagmar.github.io/ccdesk/permissions/#suggested-rules))
32
+ - Which project MCP servers still wait for approval? ([MCP servers](https://ayagmar.github.io/ccdesk/mcp-servers/))
33
+
34
+ ## What it covers
35
+
36
+ | | What you see | What you can change | Docs |
37
+ | --- | --- | --- | --- |
38
+ | **Instructions** | Every `CLAUDE.md`, `AGENTS.md` and `.claude/rules/*.md` that applies, in load order, with why a file is skipped and who else sees it | Open it in your editor | [Instructions](https://ayagmar.github.io/ccdesk/instructions/) |
39
+ | **Settings** | The value Claude Code ends up using for each key, the file it comes from, and the values it overrides | Set or remove any documented key in user, project or local settings, picked from the schema | [Settings](https://ayagmar.github.io/ccdesk/settings/) |
40
+ | **Permissions** | Every allow, ask and deny rule, which ones never take effect, and allow rules that covered no call | Add, move or remove rules; accept allow rules suggested from the calls you approved | [Permissions](https://ayagmar.github.io/ccdesk/permissions/) |
41
+ | **Built-in tools** | What happens when Claude reaches for each tool here, and how often it did | Turn a tool off with a deny rule, or back on | [Tool usage](https://ayagmar.github.io/ccdesk/tool-usage/) |
42
+ | **Hooks** | Every hook with its scope, and the ones that are turned off | Add, change or remove hooks in user, project or local settings | [Hooks](https://ayagmar.github.io/ccdesk/hooks/) |
43
+ | **MCP servers** | Every server, and the ones waiting for approval, off, rejected or overridden | Add, change or remove servers in `.mcp.json`, or through `claude mcp` for user and local ones | [MCP servers](https://ayagmar.github.io/ccdesk/mcp-servers/) |
44
+ | **Subagents** | Every agent from managed, project, user and plugin folders, and which definition wins | Create, change or delete agent files | [Subagents](https://ayagmar.github.io/ccdesk/subagents/) |
45
+ | **Skills and plugins** | Skills, commands, output styles and plugins, by scope | Open them in your editor | [Skills and plugins](https://ayagmar.github.io/ccdesk/skills-and-plugins/) |
46
+ | **Memory** | Auto memory for the directory, with memories missing from `MEMORY.md`, broken links and duplicates flagged | Delete a memory and its index line, fix the index | [Memory](https://ayagmar.github.io/ccdesk/memory/) |
47
+ | **What Claude sees** | What your config puts in context when a session starts, with sizes and estimated tokens | | [What Claude sees](https://ayagmar.github.io/ccdesk/what-claude-sees/) |
48
+
49
+ <table>
50
+ <tr>
51
+ <td width="50%"><a href="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/permissions-dark.webp"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/permissions-dark.webp" /><img alt="The permissions table with rules from user, project and local settings, and three suggested allow rules" src="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/permissions-light.webp" /></picture></a></td>
52
+ <td width="50%"><a href="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/context-dark.webp"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/context-dark.webp" /><img alt="What Claude sees: instruction files, memory and skill listing in load order, with sizes and token estimates" src="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/context-light.webp" /></picture></a></td>
53
+ </tr>
54
+ <tr>
55
+ <td>Permissions, with allow rules suggested from the calls you approved</td>
56
+ <td>What Claude sees when a session starts here</td>
57
+ </tr>
58
+ <tr>
59
+ <td><a href="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/memory-dark.webp"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/memory-dark.webp" /><img alt="Auto memory for a directory, with checks on the MEMORY.md index" src="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/memory-light.webp" /></picture></a></td>
60
+ <td><a href="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/changes-dark.webp"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/changes-dark.webp" /><img alt="Recent changes: every edit ccdesk made, each one restorable" src="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/changes-light.webp" /></picture></a></td>
61
+ </tr>
62
+ <tr>
63
+ <td>Auto memory and its index</td>
64
+ <td>Every change ccdesk made, ready to restore</td>
65
+ </tr>
66
+ </table>
67
+
68
+ ## How changes are made
69
+
70
+ <a href="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/review-dark.webp"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/review-dark.webp" /><img align="right" width="300" alt="Changing the model setting: the files that set it, the value Claude Code uses, and the diff to project settings before saving" src="https://raw.githubusercontent.com/ayagmar/ccdesk/master/site/src/assets/screenshots/review-light.webp" /></picture></a>
71
+
72
+ - You see the exact diff before anything is written, and Save writes exactly that diff. If the file changed in the meantime, ccdesk asks you to review again.
73
+ - Settings and hooks are checked against Claude Code's settings schema. Other keys in the file stay as they are.
74
+ - The old file is backed up to `~/.claude/backups/ccdesk/` first. Undo is one click, and **Recent changes** can restore any earlier edit.
75
+ - A settings file that is a symlink, such as one in a dotfiles repository, is written through and stays a link.
76
+ - A file with other hard links is not saved: replacing it would split it from its other names. Edit it in your editor.
77
+ - ccdesk never writes `~/.claude.json`. User and local MCP server changes go through Claude Code's own `claude mcp` command, so they need `claude` on your PATH.
78
+
79
+ Details: [Recent changes, backups and undo](https://ayagmar.github.io/ccdesk/recent-changes/)
80
+
81
+ <br clear="right" />
82
+
83
+ ## Keyboard
84
+
85
+ | Key | Action |
86
+ | --- | --- |
87
+ | `j` / `k` | Next / previous row |
88
+ | `1` to `9` | Jump to a section |
89
+ | `/` | Filter rows |
90
+ | `Ctrl K` / `⌘ K` | Jump to any directory, file, setting, rule or memory |
91
+ | `Esc` | Close details |
92
+
93
+ More in [Reading the interface](https://ayagmar.github.io/ccdesk/interface/).
94
+
95
+ ## Options
96
+
97
+ ```
98
+ npx ccdesk [options]
99
+
100
+ --port <n> Port to listen on (default: a free port)
101
+ --no-open Print the URL instead of opening a browser
102
+ --config-dir <path> Claude folder to read, as CLAUDE_CONFIG_DIR sets it for Claude Code
103
+ (wins over $CLAUDE_CONFIG_DIR)
104
+ -h, --help Show this help
105
+ ```
106
+
107
+ ccdesk reads `~/.claude` and `~/.claude.json`, or `$CLAUDE_CONFIG_DIR` when it is set, as Claude Code does. To look at another profile without exporting the variable, pass `--config-dir <path>`. Edits made in another editor or by Claude show up within a second. Requires Node.js 22.19 or newer. See [Platform notes](https://ayagmar.github.io/ccdesk/platforms/) for Windows, macOS and WSL.
108
+
109
+ ## Privacy and safety
110
+
111
+ - The server listens on `127.0.0.1` only, and every API call needs a token generated for that run. Nothing leaves your machine.
112
+ - Values under `env` and `headers`, and keys in an MCP server's `args` (after flags such as `--token` or `--api-key`, or starting like `sk-` or `ghp_`), are hidden everywhere in the UI, diffs included, and ccdesk refuses any edit that would change them.
113
+ - ccdesk never reads `~/.claude/.credentials.json`.
114
+ - From your transcripts, only counts and rule patterns (a command's program and subcommand, a fetch's domain) reach the page, never commands or file contents.
115
+ - Backups are readable only by you on Linux and macOS.
116
+
117
+ The full list is in [Safety and privacy](https://ayagmar.github.io/ccdesk/safety-and-privacy/).
118
+
119
+ ## Development
120
+
121
+ ```sh
122
+ pnpm install
123
+ pnpm dev # API on 127.0.0.1:4319
124
+ pnpm dev:ui # UI with hot reload on localhost:4320
125
+ pnpm check # typecheck, lint, format, tests, build
126
+ ```
127
+
128
+ Tests never touch your real `~/.claude`; each one builds a throwaway home directory. The settings schema, scopes and built-in tools are generated from [`@anthropic-ai/claude-agent-sdk`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) and Claude Code's docs, and a daily workflow opens a pull request when they change. [Contributing](https://ayagmar.github.io/ccdesk/contributing/) covers the generators, the docs site and releases.
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,77 @@
1
+ import { join, resolve } from "node:path";
2
+ import {} from "../shared/types.js";
3
+ import {} from "./scan/env.js";
4
+ import { gitRoot, revisionOf, shownText } from "./scan/files.js";
5
+ import { scanDirectory } from "./scan/index.js";
6
+ import { agentProblems } from "./scan/inventory.js";
7
+ import { RefusedError, readIfExists, recordChange, removeExactly, serially, StaleError, writeAtomic, } from "./write.js";
8
+ /** The file an edit changes: an agent the scan shows here, or a new one in your or the project's folder. */
9
+ async function targetOf(env, edit) {
10
+ const dir = resolve(edit.dir);
11
+ if (edit.path !== undefined) {
12
+ const agent = (await scanDirectory(env, dir)).inventory.agents.find((a) => a.path === edit.path);
13
+ if (!agent)
14
+ throw new RefusedError("That agent is no longer here; review it again.");
15
+ if (agent.scope !== "user" && agent.scope !== "project")
16
+ throw new RefusedError(agent.scope === "plugin"
17
+ ? "This agent comes with its plugin; change it in the plugin."
18
+ : "Managed agents are set by your organisation.");
19
+ return agent.path;
20
+ }
21
+ if (!edit.create)
22
+ throw new RefusedError("An agent edit needs the agent or where a new one goes.");
23
+ const { scope } = edit.create;
24
+ // The folder gives the .md; a name typed with it would otherwise end in .md.md.
25
+ const file = edit.create.file.replace(/\.md$/i, "");
26
+ if (!/^[\w][\w.-]*$/.test(file))
27
+ throw new RefusedError("A file name has letters, digits, '.', '-' or '_', and no folders.");
28
+ const folder = scope === "user"
29
+ ? join(env.claudeDir, "agents")
30
+ : join((await gitRoot(dir)) ?? dir, ".claude", "agents");
31
+ return join(folder, `${file}.md`);
32
+ }
33
+ async function plan(env, edit) {
34
+ const path = await targetOf(env, edit);
35
+ const raw = await readIfExists(env, path);
36
+ if (!edit.path && raw !== null)
37
+ throw new RefusedError(`${path} already exists; open that agent to change it.`);
38
+ const before = shownText(raw);
39
+ // The page shows the file with env, header and URL values hidden, so a changed file written back
40
+ // from it would lose them; it can still be deleted, as a whole.
41
+ if (edit.text !== null && before !== raw)
42
+ throw new RefusedError(`${path} has env, header or URL values ccdesk does not show; edit it in your editor instead.`);
43
+ const errors = edit.text === null ? [] : agentProblems(edit.text);
44
+ return {
45
+ path,
46
+ raw,
47
+ preview: {
48
+ path,
49
+ before,
50
+ after: errors.length > 0 ? (before ?? "") : (shownText(edit.text) ?? ""),
51
+ revision: revisionOf(raw),
52
+ reformats: false,
53
+ errors,
54
+ },
55
+ };
56
+ }
57
+ export async function previewAgentEdit(env, edit) {
58
+ return (await plan(env, edit)).preview;
59
+ }
60
+ /** Writes or deletes an agent file, backed up first, only while it is as reviewed. */
61
+ export function saveAgentEdit(env, edit, expectedRevision) {
62
+ return serially(async () => {
63
+ const { path, raw, preview } = await plan(env, edit);
64
+ if (preview.errors.length > 0)
65
+ throw new RefusedError(preview.errors.join(" "));
66
+ if (preview.revision !== expectedRevision)
67
+ throw new RefusedError("The agent changed since you reviewed this; review it again.");
68
+ const { text } = edit;
69
+ return recordChange(env, { kind: "agent", target: path, before: raw }, async () => {
70
+ if (text !== null)
71
+ await writeAtomic(env, path, text, expectedRevision);
72
+ else if (!(await removeExactly(path, expectedRevision)))
73
+ throw new StaleError(`${path} changed while ccdesk deleted it; nothing was deleted.`);
74
+ return { target: path, revision: revisionOf(text) };
75
+ });
76
+ });
77
+ }
@@ -0,0 +1,303 @@
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, revisionOf, shownText } from "./scan/files.js";
7
+ import { hiddenValues, shown } from "./secrets.js";
8
+ import { backupRoot, forgetChange, MANIFEST, RefusedError, readIfExists, recordChange, removeExactly, SHOWN_AFTER, SNAPSHOT, StaleError, serially, writeAtomic, } from "./write.js";
9
+ const KINDS = new Set([
10
+ "settings",
11
+ "mcp",
12
+ "agent",
13
+ "memory-index",
14
+ "memory-delete",
15
+ "memory-restore",
16
+ "mcp-claude",
17
+ ]);
18
+ function strings(value) {
19
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
20
+ }
21
+ /** A backup folder's manifest; anything that does not look like one ccdesk wrote is skipped. */
22
+ async function readManifest(env, id) {
23
+ // ccdesk's ids are letters, digits and dashes, so no id can name a folder outside the root.
24
+ if (!/^[\w-]+$/.test(id))
25
+ return null;
26
+ const text = await readIfExists(env, join(backupRoot(env), id, MANIFEST)).catch(() => null);
27
+ if (text === null)
28
+ return null;
29
+ let data;
30
+ try {
31
+ data = JSON.parse(text);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ if (!isRecord(data) ||
37
+ typeof data.kind !== "string" ||
38
+ !KINDS.has(data.kind) ||
39
+ typeof data.target !== "string" ||
40
+ typeof data.createdAt !== "string" ||
41
+ typeof data.before !== "string" ||
42
+ typeof data.after !== "string" ||
43
+ (data.removedIndexLines !== undefined && !strings(data.removedIndexLines)) ||
44
+ (data.undoCommands !== undefined && !strings(data.undoCommands)))
45
+ return null;
46
+ return data;
47
+ }
48
+ /** The changes ccdesk made, newest first, a page at a time. */
49
+ export async function listBackups(env, { before, limit }) {
50
+ const ids = (await readdir(backupRoot(env)).catch(() => [])).sort().reverse();
51
+ const changes = [];
52
+ for (const id of ids) {
53
+ if (before !== undefined && id >= before)
54
+ continue;
55
+ const manifest = await readManifest(env, id);
56
+ if (!manifest)
57
+ continue;
58
+ if (changes.length === limit)
59
+ return { changes, more: true };
60
+ changes.push({
61
+ id,
62
+ kind: manifest.kind,
63
+ target: manifest.target,
64
+ createdAt: manifest.createdAt,
65
+ created: manifest.before === revisionOf(null),
66
+ ...(manifest.after === "pending" || manifest.after === "partial" ? { incomplete: true } : {}),
67
+ });
68
+ }
69
+ return { changes, more: false };
70
+ }
71
+ /** A settings file Claude Code reads: your user file, or settings(.local).json in a .claude folder. */
72
+ function isSettingsFile(env, path) {
73
+ if (path === join(env.claudeDir, "settings.json"))
74
+ return true;
75
+ return (["settings.json", "settings.local.json"].includes(basename(path)) &&
76
+ basename(dirname(path)) === ".claude");
77
+ }
78
+ /** A `.md` file in your agents folder or a project's `.claude/agents`, or a subfolder of one. */
79
+ function isAgentFile(env, path) {
80
+ if (!path.endsWith(".md"))
81
+ return false;
82
+ if (isWithin(join(env.claudeDir, "agents"), path))
83
+ return true;
84
+ const parts = path.split(/[/\\]/);
85
+ return parts.some((part, i) => part === ".claude" && parts[i + 1] === "agents" && i + 2 < parts.length);
86
+ }
87
+ /** Settings are shown parsed with env and header values hidden, as everywhere else in ccdesk. */
88
+ function shownSettings(text) {
89
+ if (text === null)
90
+ return null;
91
+ try {
92
+ return shown(JSON.parse(text));
93
+ }
94
+ catch {
95
+ return "(not valid JSON; open the file to see it)\n";
96
+ }
97
+ }
98
+ /** A backup's manifest and its folder, which must be inside the backup folder. */
99
+ async function backupAt(env, id) {
100
+ const manifest = await readManifest(env, id);
101
+ if (!manifest)
102
+ throw new RefusedError("That backup is gone or was not made by ccdesk.");
103
+ const root = await realpath(backupRoot(env));
104
+ const dir = await realpath(join(root, id));
105
+ if (!isWithin(root, dir) || dir === root)
106
+ throw new RefusedError("Not a ccdesk backup.");
107
+ return { manifest, dir };
108
+ }
109
+ const MADE_BY_CLAUDE = "Claude Code made this change through claude mcp, so ccdesk cannot undo it. Run the commands below to put it back.";
110
+ /**
111
+ * What a change made through `claude mcp` did, as ccdesk recorded it: the servers before and
112
+ * after, values hidden, and the commands that put them back.
113
+ */
114
+ async function claudeMcpPreview(id, manifest, dir) {
115
+ const read = async (name, revision) => {
116
+ const text = await readFile(join(dir, name), "utf8").catch(() => null);
117
+ if (text === null || revisionOf(text) !== revision)
118
+ throw new RefusedError("The record of this change is missing or was changed.");
119
+ return text;
120
+ };
121
+ return {
122
+ id,
123
+ kind: manifest.kind,
124
+ target: manifest.target,
125
+ before: await read(SNAPSHOT, manifest.before),
126
+ after: await read(SHOWN_AFTER, manifest.after),
127
+ revision: manifest.after,
128
+ blocked: MADE_BY_CLAUDE,
129
+ undoCommands: manifest.undoCommands ?? [],
130
+ };
131
+ }
132
+ async function planRestore(env, id) {
133
+ const { manifest, dir } = await backupAt(env, id);
134
+ if (manifest.kind === "mcp-claude")
135
+ throw new RefusedError(MADE_BY_CLAUDE);
136
+ let target = manifest.target;
137
+ if (manifest.kind === "settings" && !isSettingsFile(env, target))
138
+ throw new RefusedError("This backup does not point at a settings file.");
139
+ if (manifest.kind === "mcp" && basename(target) !== ".mcp.json")
140
+ throw new RefusedError("This backup does not point at a .mcp.json.");
141
+ if (manifest.kind === "agent" && !isAgentFile(env, target))
142
+ throw new RefusedError("This backup does not point at a subagent file.");
143
+ if (manifest.kind === "memory-index" && basename(target) !== "MEMORY.md")
144
+ throw new RefusedError("This backup does not point at a MEMORY.md.");
145
+ if (manifest.kind !== "settings" && manifest.kind !== "mcp" && manifest.kind !== "agent") {
146
+ const store = await assertMemoryDir(env, dirname(target));
147
+ // The same confinement as any other MEMORY.md change: a link out of the folder is refused.
148
+ target =
149
+ manifest.kind === "memory-index" ? await indexPathIn(store) : join(store, basename(target));
150
+ // A deleted memory comes back only as a topic file, as ccdesk deletes only those.
151
+ if (manifest.kind !== "memory-index" &&
152
+ (!target.endsWith(".md") || basename(target) === "MEMORY.md"))
153
+ throw new RefusedError("This backup does not point at a memory file.");
154
+ }
155
+ let saved = null;
156
+ if (manifest.before !== revisionOf(null)) {
157
+ const snapshot = join(dir, SNAPSHOT);
158
+ if (!(await lstat(snapshot).catch(() => null))?.isFile())
159
+ throw new RefusedError("The backed-up file is missing from the backup folder.");
160
+ saved = await readFile(snapshot, "utf8");
161
+ if (revisionOf(saved) !== manifest.before)
162
+ throw new RefusedError("The backed-up file was changed after ccdesk saved it.");
163
+ }
164
+ const plan = {
165
+ manifest: { ...manifest, target },
166
+ current: await readIfExists(env, target),
167
+ saved,
168
+ linked: (await lstat(target).catch(() => null))?.isSymbolicLink() ?? false,
169
+ };
170
+ if (manifest.kind === "memory-delete") {
171
+ const path = await indexPathIn(dirname(target));
172
+ const text = await readIfExists(env, path);
173
+ const missing = (manifest.removedIndexLines ?? []).filter((l) => !(text ?? "").split("\n").includes(l));
174
+ plan.index = { path, text, missing };
175
+ }
176
+ return plan;
177
+ }
178
+ /** What a restore must find unchanged: the file, and for a deleted memory MEMORY.md as well. */
179
+ function planRevision({ current, index }) {
180
+ return index ? revisionOfBoth(current, index.text) : revisionOf(current);
181
+ }
182
+ /**
183
+ * A delete that stopped halfway, after Claude rewrote the memory: the memory was never removed, so
184
+ * only the MEMORY.md lines the delete took out come back, and the memory stays as it is now.
185
+ */
186
+ function onlyIndexLines({ manifest, current, saved, index }) {
187
+ return (manifest.kind === "memory-delete" &&
188
+ manifest.after === "partial" &&
189
+ current !== null &&
190
+ current !== saved &&
191
+ (index?.missing.length ?? 0) > 0);
192
+ }
193
+ function blockedReason(plan) {
194
+ const { manifest, current, saved } = plan;
195
+ if (saved === null && manifest.kind === "memory-restore")
196
+ return "This put a deleted memory back. To undo it, delete the memory again.";
197
+ // Undoing a change that created its file removes the file, but only the one ccdesk wrote.
198
+ if (saved === null) {
199
+ if (manifest.after === "pending" || manifest.after === "partial")
200
+ return "This change stopped halfway, so ccdesk cannot tell what it wrote. Remove the file by hand if you need to.";
201
+ if (current === null)
202
+ return "The file ccdesk created is gone already.";
203
+ if (plan.linked || revisionOf(current) !== manifest.after)
204
+ return "The file changed after ccdesk created it, so ccdesk does not remove it. Change it in place instead.";
205
+ return undefined;
206
+ }
207
+ if (onlyIndexLines(plan))
208
+ return undefined;
209
+ // A file broken since the backup has no values ccdesk could keep; the backup is the way out of it,
210
+ // and the broken file is itself backed up before the restore replaces it.
211
+ const now = hiddenValues(current);
212
+ if ((manifest.kind === "settings" || manifest.kind === "mcp") &&
213
+ current !== null &&
214
+ now !== null &&
215
+ now !== hiddenValues(saved))
216
+ return "Hidden values in this file (env, headers, a URL's key or a key in args) changed since the backup. ccdesk does not show or change those, so restore this one by hand.";
217
+ // A retry after a restore that wrote the memory but not MEMORY.md finds the memory already back.
218
+ if (manifest.kind === "memory-delete" && current !== null && current !== saved)
219
+ return "A memory with this file name exists again; delete or rename it first.";
220
+ return undefined;
221
+ }
222
+ export async function previewRestore(env, id) {
223
+ const { manifest: recorded, dir } = await backupAt(env, id);
224
+ if (recorded.kind === "mcp-claude")
225
+ return claudeMcpPreview(id, recorded, dir);
226
+ const plan = await planRestore(env, id);
227
+ const { manifest, current, saved, index } = plan;
228
+ const show = manifest.kind === "settings" || manifest.kind === "mcp" ? shownSettings : shownText;
229
+ const before = show(current);
230
+ const after = (onlyIndexLines(plan) ? before : show(saved)) ?? "";
231
+ const blocked = blockedReason(plan);
232
+ return {
233
+ id,
234
+ kind: manifest.kind,
235
+ target: manifest.target,
236
+ before,
237
+ after,
238
+ revision: planRevision(plan),
239
+ ...(saved === null ? { removes: true } : {}),
240
+ ...(blocked ? { blocked } : {}),
241
+ ...(index && index.missing.length > 0
242
+ ? { indexLines: index.missing.map((line) => shownText(line) ?? line) }
243
+ : {}),
244
+ };
245
+ }
246
+ /**
247
+ * Puts a backed-up file back, or removes a file ccdesk created, if the file is still as it was
248
+ * when the restore was reviewed. A removed file is backed up first, so it can be put back too.
249
+ */
250
+ export function restoreBackup(env, id, expectedRevision) {
251
+ return serially(async () => {
252
+ const plan = await planRestore(env, id);
253
+ const { manifest, current, saved, index } = plan;
254
+ const blocked = blockedReason(plan);
255
+ if (blocked)
256
+ throw new RefusedError(blocked);
257
+ if (planRevision(plan) !== expectedRevision)
258
+ throw new RefusedError("The file changed since you reviewed this restore; review it again.");
259
+ const { target } = manifest;
260
+ if (saved === null) {
261
+ await recordChange(env, { kind: manifest.kind, target, before: current }, async () => {
262
+ if (!(await removeExactly(target, revisionOf(current))))
263
+ throw new StaleError(`${target} changed while ccdesk was removing it; nothing was removed. Review it again.`);
264
+ return { revision: revisionOf(null) };
265
+ });
266
+ return { target };
267
+ }
268
+ if (manifest.kind !== "memory-delete") {
269
+ await recordChange(env, { kind: manifest.kind, target, before: current }, async () => {
270
+ await writeAtomic(env, target, saved, revisionOf(current));
271
+ return { revision: revisionOf(saved) };
272
+ });
273
+ return { target };
274
+ }
275
+ // A memory and its MEMORY.md lines come back together: when the index cannot be written, the
276
+ // memory just put back is removed again, with its record, so a refused restore leaves both as
277
+ // they were.
278
+ const created = current === null
279
+ ? await recordChange(env, { kind: "memory-restore", target, before: null }, async () => {
280
+ await writeAtomic(env, target, saved, revisionOf(null));
281
+ return { revision: revisionOf(saved) };
282
+ })
283
+ : null;
284
+ try {
285
+ if (index && index.missing.length > 0) {
286
+ const lines = (index.text ?? "").split("\n");
287
+ while (lines.at(-1) === "")
288
+ lines.pop();
289
+ const next = `${[...lines, ...index.missing].join("\n")}\n`;
290
+ await recordChange(env, { kind: "memory-index", target: index.path, before: index.text }, async () => {
291
+ await writeAtomic(env, index.path, next, revisionOf(index.text));
292
+ return { revision: revisionOf(next) };
293
+ });
294
+ }
295
+ }
296
+ catch (error) {
297
+ if (created && (await removeExactly(target, revisionOf(saved))))
298
+ await forgetChange(env, created.id);
299
+ throw error;
300
+ }
301
+ return { target };
302
+ });
303
+ }