ctxman 3.0.0 → 3.1.1

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 (224) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +381 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -67
  18. package/bin/cm-audit.js +121 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +687 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +772 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +116 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +312 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +53 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/ask.js +122 -0
  108. package/lib/rag/sources/FileDataSource.js +12 -9
  109. package/lib/rag/stores/BaseVectorStore.js +5 -5
  110. package/lib/rag/stores/ChromaDBStore.js +10 -10
  111. package/lib/rag/stores/LanceDBStore.js +29 -19
  112. package/lib/rag/stores/PineconeStore.js +11 -9
  113. package/lib/search/bm25.js +433 -0
  114. package/lib/search/code-units.js +201 -0
  115. package/lib/search/symbols.js +189 -0
  116. package/lib/ui/dashboard.js +120 -89
  117. package/lib/ui/index.js +1 -6
  118. package/lib/ui/init-wizard-ui.js +550 -0
  119. package/lib/ui/progress-bar.js +94 -88
  120. package/lib/ui/select-input.js +40 -32
  121. package/lib/ui/template-selector.js +99 -0
  122. package/lib/ui/wizard.js +374 -329
  123. package/lib/utils/claude-token-counter.js +151 -0
  124. package/lib/utils/cli-output.js +17 -0
  125. package/lib/utils/clipboard-utils.js +49 -50
  126. package/lib/utils/config-utils.js +81 -66
  127. package/lib/utils/context-versioning.js +532 -0
  128. package/lib/utils/error-handler.js +112 -112
  129. package/lib/utils/file-utils.js +153 -69
  130. package/lib/utils/format-converter.js +356 -283
  131. package/lib/utils/git-utils.js +366 -294
  132. package/lib/utils/llm-cost-estimator.js +281 -0
  133. package/lib/utils/llm-detector.js +242 -78
  134. package/lib/utils/logger.js +246 -237
  135. package/lib/utils/messages.js +355 -0
  136. package/lib/utils/multi-repo-manager.js +364 -0
  137. package/lib/utils/profile-manager.js +380 -0
  138. package/lib/utils/safe-read.js +61 -0
  139. package/lib/utils/secret-redactor.js +61 -0
  140. package/lib/utils/snapshot-manager.js +679 -0
  141. package/lib/utils/template-manager.js +378 -0
  142. package/lib/utils/token-utils.js +201 -170
  143. package/lib/utils/tokenizer-adapter.js +421 -377
  144. package/lib/utils/updater.js +539 -506
  145. package/lib/watch/FileWatcher.js +12 -8
  146. package/lib/watch/IncrementalAnalyzer.js +7 -7
  147. package/lib/why/coupling.js +81 -0
  148. package/lib/why/errors.js +23 -0
  149. package/lib/why/git.js +169 -0
  150. package/lib/why/history.js +108 -0
  151. package/lib/why/index.js +720 -0
  152. package/lib/why/ownership.js +79 -0
  153. package/lib/why/paths.js +207 -0
  154. package/lib/why/references.js +259 -0
  155. package/lib/why/render.js +722 -0
  156. package/lib/why/search.js +358 -0
  157. package/lib/why/target.js +389 -0
  158. package/lib/wizards/config-generator.js +257 -0
  159. package/lib/wizards/project-detector.js +572 -0
  160. package/package.json +44 -10
  161. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  224. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * MCP client configuration: where each client keeps its MCP servers, the entry that starts
3
+ * ctxman, and merging that entry into (or removing it from) the client's JSON file.
4
+ *
5
+ * Locations follow the clients' documentation (see docs/commands/mcp.md). Everything that
6
+ * depends on the machine (platform, home directory, environment, project directory) is
7
+ * passed in, so tests never touch the real home directory.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ /** Absolute path of bin/cli.js, which `ctxman mcp` runs */
16
+ export const CLI_PATH = fileURLToPath(new URL('../../../bin/cli.js', import.meta.url));
17
+ export const DEFAULT_SERVER_NAME = 'ctxman';
18
+ export const SCOPES = ['user', 'project'];
19
+
20
+ /**
21
+ * Supported clients.
22
+ * - key: object in the config file that holds the servers
23
+ * - scopes: scopes with a config file ctxman writes
24
+ * - manualScopes: scopes for which ctxman prints a command or instructions instead
25
+ * - stdioType: whether entries carry `"type": "stdio"`
26
+ */
27
+ export const CLIENTS = {
28
+ 'claude-desktop': {
29
+ label: 'Claude Desktop',
30
+ key: 'mcpServers',
31
+ defaultScope: 'user',
32
+ scopes: ['user'],
33
+ manualScopes: [],
34
+ stdioType: false,
35
+ docs: 'https://modelcontextprotocol.io/docs/2026-07-28/develop/connect-local-servers',
36
+ },
37
+ 'claude-code': {
38
+ label: 'Claude Code',
39
+ key: 'mcpServers',
40
+ defaultScope: 'project',
41
+ scopes: ['project'],
42
+ manualScopes: ['user'],
43
+ stdioType: true,
44
+ docs: 'https://code.claude.com/docs/en/mcp',
45
+ },
46
+ cursor: {
47
+ label: 'Cursor',
48
+ key: 'mcpServers',
49
+ defaultScope: 'user',
50
+ scopes: ['user', 'project'],
51
+ manualScopes: [],
52
+ stdioType: false,
53
+ docs: 'https://cursor.com/docs/mcp',
54
+ },
55
+ vscode: {
56
+ label: 'VS Code',
57
+ key: 'servers',
58
+ defaultScope: 'project',
59
+ scopes: ['project'],
60
+ manualScopes: ['user'],
61
+ stdioType: true,
62
+ docs: 'https://code.visualstudio.com/docs/agents/reference/mcp-configuration',
63
+ },
64
+ windsurf: {
65
+ label: 'Windsurf',
66
+ key: 'mcpServers',
67
+ defaultScope: 'user',
68
+ // The documented location differs between the Cascade docs and the editor's discovery
69
+ // file, so ctxman only writes it when given --file
70
+ scopes: [],
71
+ manualScopes: ['user'],
72
+ stdioType: false,
73
+ docs: 'https://docs.windsurf.com/windsurf/cascade/mcp',
74
+ },
75
+ };
76
+
77
+ /**
78
+ * Validate a client name
79
+ * @param {string} client
80
+ * @returns {Object} The client description
81
+ * @throws {Error} For an unknown client
82
+ */
83
+ export function getClient(client) {
84
+ const info = CLIENTS[client];
85
+ if (!info) {
86
+ throw new Error(
87
+ `Unknown client "${client ?? ''}" (expected one of: ${Object.keys(CLIENTS).join(', ')})`
88
+ );
89
+ }
90
+ return info;
91
+ }
92
+
93
+ /**
94
+ * Quote an argument for the user's shell when it needs quoting
95
+ * @param {string} arg
96
+ * @param {string} [platform=process.platform]
97
+ * @returns {string}
98
+ */
99
+ export function shellQuote(arg, platform = process.platform) {
100
+ if (/^[\w@%+=:,./\\-]+$/.test(arg)) return arg;
101
+ if (platform === 'win32') {
102
+ // Windows argument parsing: backslashes are literal unless a quote follows them, so
103
+ // only the runs before a quote, or before the closing quote, are doubled
104
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
105
+ }
106
+ return `'${arg.replace(/'/g, "'\\''")}'`;
107
+ }
108
+
109
+ /**
110
+ * The server entry for a client's config file.
111
+ *
112
+ * @param {string} client
113
+ * @param {Object} [options]
114
+ * @param {boolean} [options.npx=false] - Start with `npx -y ctxman mcp` instead of this install
115
+ * @param {string} [options.execPath=process.execPath] - Absolute path of node
116
+ * @param {string} [options.cliPath=CLI_PATH] - Absolute path of bin/cli.js
117
+ * @param {string} [options.root] - Pass `--root <dir>` to the server
118
+ * @param {string} [options.platform=process.platform] - npx goes through `cmd /c` on Windows
119
+ * @returns {{type?: string, command: string, args: string[]}}
120
+ */
121
+ export function buildServerEntry(
122
+ client,
123
+ {
124
+ npx = false,
125
+ execPath = process.execPath,
126
+ cliPath = CLI_PATH,
127
+ root,
128
+ platform = process.platform,
129
+ } = {}
130
+ ) {
131
+ const info = getClient(client);
132
+ let command;
133
+ let args;
134
+ if (!npx) {
135
+ command = execPath;
136
+ args = [cliPath, 'mcp'];
137
+ } else if (platform === 'win32') {
138
+ // npx is a .cmd script on Windows, which clients cannot spawn directly
139
+ command = 'cmd';
140
+ args = ['/c', 'npx', '-y', 'ctxman', 'mcp'];
141
+ } else {
142
+ command = 'npx';
143
+ args = ['-y', 'ctxman', 'mcp'];
144
+ }
145
+ if (root) args.push('--root', root);
146
+ return info.stdioType ? { type: 'stdio', command, args } : { command, args };
147
+ }
148
+
149
+ /**
150
+ * Where a client keeps its MCP servers for a scope.
151
+ *
152
+ * @param {string} client
153
+ * @param {string} scope - 'user' or 'project'
154
+ * @param {Object} [context]
155
+ * @param {string} [context.platform=process.platform]
156
+ * @param {string} [context.homeDir=os.homedir()]
157
+ * @param {Object} [context.env=process.env] - APPDATA (Windows), XDG_CONFIG_HOME (Linux)
158
+ * @param {string} [context.projectDir=process.cwd()]
159
+ * @returns {{path: string|null, reason?: string}} `path` is null when ctxman does not write
160
+ * that configuration; `reason` then says why
161
+ */
162
+ export function resolveConfigPath(
163
+ client,
164
+ scope,
165
+ {
166
+ platform = process.platform,
167
+ homeDir = os.homedir(),
168
+ env = process.env,
169
+ projectDir = process.cwd(),
170
+ } = {}
171
+ ) {
172
+ getClient(client);
173
+ if (!SCOPES.includes(scope)) {
174
+ throw new Error(`Unknown scope "${scope}" (expected user or project)`);
175
+ }
176
+ const p = platform === 'win32' ? path.win32 : path.posix;
177
+
178
+ switch (client) {
179
+ case 'claude-desktop': {
180
+ if (scope === 'project') {
181
+ return { path: null, reason: 'Claude Desktop has no project configuration.' };
182
+ }
183
+ let dir;
184
+ if (platform === 'darwin') {
185
+ dir = p.join(homeDir, 'Library', 'Application Support', 'Claude');
186
+ } else if (platform === 'win32') {
187
+ dir = p.join(env.APPDATA || p.join(homeDir, 'AppData', 'Roaming'), 'Claude');
188
+ } else {
189
+ dir = p.join(env.XDG_CONFIG_HOME || p.join(homeDir, '.config'), 'Claude');
190
+ }
191
+ return { path: p.join(dir, 'claude_desktop_config.json') };
192
+ }
193
+ case 'claude-code':
194
+ if (scope === 'project') return { path: p.join(projectDir, '.mcp.json') };
195
+ return {
196
+ path: null,
197
+ reason:
198
+ 'Claude Code keeps user-scope servers in ~/.claude.json together with its other ' +
199
+ 'state; ctxman does not edit that file. Add the server with the claude CLI instead.',
200
+ };
201
+ case 'cursor':
202
+ return {
203
+ path:
204
+ scope === 'project'
205
+ ? p.join(projectDir, '.cursor', 'mcp.json')
206
+ : p.join(homeDir, '.cursor', 'mcp.json'),
207
+ };
208
+ case 'vscode':
209
+ if (scope === 'project') return { path: p.join(projectDir, '.vscode', 'mcp.json') };
210
+ return {
211
+ path: null,
212
+ reason:
213
+ 'VS Code keeps user-level MCP servers in the user profile; add the server with ' +
214
+ '`code --add-mcp` or the "MCP: Open User Configuration" command.',
215
+ };
216
+ case 'windsurf':
217
+ default:
218
+ if (scope === 'project') {
219
+ return { path: null, reason: 'Windsurf has no project configuration.' };
220
+ }
221
+ return {
222
+ path: null,
223
+ reason:
224
+ 'The Windsurf docs name ~/.config/devin/mcp_config.json ($XDG_CONFIG_HOME/devin, ' +
225
+ '%APPDATA%\\devin on Windows) for Cascade, while the editor reads ' +
226
+ '~/.codeium/windsurf/mcp_config.json; open the file from Cascade > MCPs > ' +
227
+ '"Open MCP config file", or pass it with --file.',
228
+ };
229
+ }
230
+ }
231
+
232
+ /**
233
+ * The fragment of a config file that holds the entry: `{ [key]: { [name]: entry } }`
234
+ * @param {string} client
235
+ * @param {string} name
236
+ * @param {Object} entry
237
+ * @returns {Object}
238
+ */
239
+ export function buildSnippet(client, name, entry) {
240
+ return { [getClient(client).key]: { [name]: entry } };
241
+ }
242
+
243
+ /**
244
+ * Commands that add the server where ctxman does not write the file itself
245
+ * @param {string} client
246
+ * @param {string} scope
247
+ * @param {string} name
248
+ * @param {Object} entry
249
+ * @param {string} [platform=process.platform]
250
+ * @returns {string[]} Shell commands (empty when there is none)
251
+ */
252
+ export function manualCommands(client, scope, name, entry, platform = process.platform) {
253
+ const q = (arg) => shellQuote(arg, platform);
254
+ if (client === 'claude-code' && scope === 'user') {
255
+ return [
256
+ [
257
+ 'claude mcp add --scope user --transport stdio',
258
+ q(name),
259
+ '--',
260
+ q(entry.command),
261
+ ...entry.args.map(q),
262
+ ].join(' '),
263
+ ];
264
+ }
265
+ if (client === 'vscode' && scope === 'user') {
266
+ return [`code --add-mcp ${q(JSON.stringify({ name, ...entry }))}`];
267
+ }
268
+ return [];
269
+ }
270
+
271
+ /**
272
+ * Key-order-independent equality of JSON values
273
+ * @private
274
+ */
275
+ function sameJson(a, b) {
276
+ const canonical = (value) => {
277
+ if (Array.isArray(value)) return value.map(canonical);
278
+ if (value && typeof value === 'object') {
279
+ return Object.fromEntries(
280
+ Object.keys(value)
281
+ .sort()
282
+ .map((k) => [k, canonical(value[k])])
283
+ );
284
+ }
285
+ return value;
286
+ };
287
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
288
+ }
289
+
290
+ const isPlainObject = (value) =>
291
+ value !== null && typeof value === 'object' && !Array.isArray(value);
292
+
293
+ /**
294
+ * Read a client config file
295
+ * @param {string} file
296
+ * @param {string} key - Object that holds the servers
297
+ * @returns {{exists: boolean, config: Object}}
298
+ * @throws {Error} When the file is not a JSON object (comments are not supported)
299
+ */
300
+ export function readConfigFile(file, key) {
301
+ let text;
302
+ try {
303
+ text = fs.readFileSync(file, 'utf-8');
304
+ } catch (error) {
305
+ if (error.code === 'ENOENT') return { exists: false, config: {} };
306
+ throw error;
307
+ }
308
+ if (!text.trim()) return { exists: true, config: {} };
309
+
310
+ let config;
311
+ try {
312
+ config = JSON.parse(text);
313
+ } catch (error) {
314
+ throw new Error(
315
+ `${file} is not valid JSON (${error.message}); ctxman does not rewrite files with ` +
316
+ 'comments or syntax errors. Fix it, or add the entry by hand (ctxman mcp config).'
317
+ );
318
+ }
319
+ if (!isPlainObject(config)) {
320
+ throw new Error(`${file} does not hold a JSON object`);
321
+ }
322
+ if (config[key] !== undefined && !isPlainObject(config[key])) {
323
+ throw new Error(`"${key}" in ${file} is not an object`);
324
+ }
325
+ return { exists: true, config };
326
+ }
327
+
328
+ /**
329
+ * Write a config file as 2-space JSON: back up the previous version to `<file>.bak`, create
330
+ * missing directories, replace the file in one rename
331
+ * @param {string} file
332
+ * @param {Object} config
333
+ * @param {boolean} existed - Whether there is a previous version to back up
334
+ * @returns {string|null} Path of the backup
335
+ */
336
+ export function writeConfigFile(file, config, existed) {
337
+ fs.mkdirSync(path.dirname(file), { recursive: true });
338
+ let backup = null;
339
+ // Keep the permissions of the file being replaced
340
+ let mode = 0o644;
341
+ if (existed) {
342
+ backup = `${file}.bak`;
343
+ fs.copyFileSync(file, backup);
344
+ mode = fs.statSync(file).mode & 0o777;
345
+ }
346
+ const temp = `${file}.${process.pid}.tmp`;
347
+ fs.writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode });
348
+ fs.renameSync(temp, file);
349
+ return backup;
350
+ }
351
+
352
+ /**
353
+ * Add the ctxman entry to a client config file.
354
+ *
355
+ * @param {Object} options
356
+ * @param {string} options.client
357
+ * @param {string} options.file - Config file to change
358
+ * @param {Object} options.entry - From buildServerEntry()
359
+ * @param {string} [options.name='ctxman']
360
+ * @param {boolean} [options.force=false] - Replace a different existing entry of that name
361
+ * @param {boolean} [options.dryRun=false] - Report without writing
362
+ * @returns {{status: 'created'|'added'|'updated'|'unchanged'|'conflict', file: string,
363
+ * content: string, backup: string|null, existing?: Object}} `content` is the file as it
364
+ * is (or would be) written; `conflict` means an entry exists and force was not given
365
+ */
366
+ export function installServer({
367
+ client,
368
+ file,
369
+ entry,
370
+ name = DEFAULT_SERVER_NAME,
371
+ force = false,
372
+ dryRun = false,
373
+ }) {
374
+ const { key } = getClient(client);
375
+ const { exists, config } = readConfigFile(file, key);
376
+ const servers = config[key] ?? {};
377
+ const existing = servers[name];
378
+
379
+ let status;
380
+ if (existing === undefined) status = exists ? 'added' : 'created';
381
+ else if (sameJson(existing, entry)) status = 'unchanged';
382
+ else if (force) status = 'updated';
383
+ else status = 'conflict';
384
+
385
+ if (status === 'unchanged' || status === 'conflict') {
386
+ return {
387
+ status,
388
+ file,
389
+ content: `${JSON.stringify(config, null, 2)}\n`,
390
+ backup: null,
391
+ ...(existing !== undefined ? { existing } : {}),
392
+ };
393
+ }
394
+
395
+ // Assigning to an existing key keeps its position among the other servers
396
+ const next = { ...config, [key]: { ...servers, [name]: entry } };
397
+ const content = `${JSON.stringify(next, null, 2)}\n`;
398
+ const backup = dryRun ? null : writeConfigFile(file, next, exists);
399
+ return { status, file, content, backup };
400
+ }
401
+
402
+ /**
403
+ * Remove the ctxman entry from a client config file.
404
+ *
405
+ * @param {Object} options
406
+ * @param {string} options.client
407
+ * @param {string} options.file
408
+ * @param {string} [options.name='ctxman']
409
+ * @param {boolean} [options.dryRun=false]
410
+ * @returns {{status: 'removed'|'absent', file: string, content: string|null,
411
+ * backup: string|null}}
412
+ */
413
+ export function uninstallServer({ client, file, name = DEFAULT_SERVER_NAME, dryRun = false }) {
414
+ const { key } = getClient(client);
415
+ const { exists, config } = readConfigFile(file, key);
416
+ if (!exists || !config[key] || !(name in config[key])) {
417
+ return { status: 'absent', file, content: null, backup: null };
418
+ }
419
+ const { [name]: _removed, ...servers } = config[key];
420
+ const next = { ...config, [key]: servers };
421
+ const content = `${JSON.stringify(next, null, 2)}\n`;
422
+ const backup = dryRun ? null : writeConfigFile(file, next, true);
423
+ return { status: 'removed', file, content, backup };
424
+ }