@supportpages.io/wtfm 0.0.0-stage → 0.3.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 (275) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +23 -0
  3. package/README.md +392 -2
  4. package/dist/actions.d.ts +98 -0
  5. package/dist/actions.js +86 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/agent-settings.d.ts +22 -0
  8. package/dist/agent-settings.js +35 -0
  9. package/dist/agent-settings.js.map +1 -0
  10. package/dist/api.d.ts +14 -0
  11. package/dist/api.js +159 -0
  12. package/dist/api.js.map +1 -0
  13. package/dist/article-link.d.ts +16 -0
  14. package/dist/article-link.js +25 -0
  15. package/dist/article-link.js.map +1 -0
  16. package/dist/artifacts.d.ts +45 -0
  17. package/dist/artifacts.js +144 -0
  18. package/dist/artifacts.js.map +1 -0
  19. package/dist/brand.d.ts +4 -0
  20. package/dist/brand.js +9 -0
  21. package/dist/brand.js.map +1 -0
  22. package/dist/bridge.d.ts +2964 -0
  23. package/dist/bridge.js +1036 -0
  24. package/dist/bridge.js.map +1 -0
  25. package/dist/capacity.d.ts +5 -0
  26. package/dist/capacity.js +11 -0
  27. package/dist/capacity.js.map +1 -0
  28. package/dist/credentials.d.ts +13 -0
  29. package/dist/credentials.js +88 -0
  30. package/dist/credentials.js.map +1 -0
  31. package/dist/development-tls.d.ts +8 -0
  32. package/dist/development-tls.js +38 -0
  33. package/dist/development-tls.js.map +1 -0
  34. package/dist/errors.d.ts +15 -0
  35. package/dist/errors.js +18 -0
  36. package/dist/errors.js.map +1 -0
  37. package/dist/export.d.ts +11 -0
  38. package/dist/export.js +91 -0
  39. package/dist/export.js.map +1 -0
  40. package/dist/hosted-operations.d.ts +108 -0
  41. package/dist/hosted-operations.js +120 -0
  42. package/dist/hosted-operations.js.map +1 -0
  43. package/dist/hosting-benefits.d.ts +66 -0
  44. package/dist/hosting-benefits.js +67 -0
  45. package/dist/hosting-benefits.js.map +1 -0
  46. package/dist/index.d.ts +2 -0
  47. package/dist/index.js +49 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/local-inventory.d.ts +22 -0
  50. package/dist/local-inventory.js +59 -0
  51. package/dist/local-inventory.js.map +1 -0
  52. package/dist/local-setup.d.ts +208 -0
  53. package/dist/local-setup.js +140 -0
  54. package/dist/local-setup.js.map +1 -0
  55. package/dist/pairing.d.ts +71 -0
  56. package/dist/pairing.js +235 -0
  57. package/dist/pairing.js.map +1 -0
  58. package/dist/preferences.d.ts +18 -0
  59. package/dist/preferences.js +44 -0
  60. package/dist/preferences.js.map +1 -0
  61. package/dist/progress.d.ts +205 -0
  62. package/dist/progress.js +224 -0
  63. package/dist/progress.js.map +1 -0
  64. package/dist/reminders.d.ts +31 -0
  65. package/dist/reminders.js +73 -0
  66. package/dist/reminders.js.map +1 -0
  67. package/dist/replace-connection.d.ts +6 -0
  68. package/dist/replace-connection.js +77 -0
  69. package/dist/replace-connection.js.map +1 -0
  70. package/dist/repository-actions.d.ts +18 -0
  71. package/dist/repository-actions.js +9 -0
  72. package/dist/repository-actions.js.map +1 -0
  73. package/dist/repository-benefits.d.ts +96 -0
  74. package/dist/repository-benefits.js +64 -0
  75. package/dist/repository-benefits.js.map +1 -0
  76. package/dist/run-update.d.ts +17 -0
  77. package/dist/run-update.js +27 -0
  78. package/dist/run-update.js.map +1 -0
  79. package/dist/runs.d.ts +541 -0
  80. package/dist/runs.js +146 -0
  81. package/dist/runs.js.map +1 -0
  82. package/dist/runtime.d.ts +12 -0
  83. package/dist/runtime.js +58 -0
  84. package/dist/runtime.js.map +1 -0
  85. package/dist/schema.d.ts +277 -0
  86. package/dist/schema.js +66 -0
  87. package/dist/schema.js.map +1 -0
  88. package/dist/server.d.ts +4 -0
  89. package/dist/server.js +298 -0
  90. package/dist/server.js.map +1 -0
  91. package/dist/session.d.ts +1714 -0
  92. package/dist/session.js +619 -0
  93. package/dist/session.js.map +1 -0
  94. package/dist/settings.d.ts +9 -0
  95. package/dist/settings.js +21 -0
  96. package/dist/settings.js.map +1 -0
  97. package/dist/sync.d.ts +495 -0
  98. package/dist/sync.js +191 -0
  99. package/dist/sync.js.map +1 -0
  100. package/dist/telemetry-scrub.d.ts +13 -0
  101. package/dist/telemetry-scrub.js +51 -0
  102. package/dist/telemetry-scrub.js.map +1 -0
  103. package/dist/telemetry.d.ts +73 -0
  104. package/dist/telemetry.js +173 -0
  105. package/dist/telemetry.js.map +1 -0
  106. package/dist/walkthroughs.d.ts +224 -0
  107. package/dist/walkthroughs.js +109 -0
  108. package/dist/walkthroughs.js.map +1 -0
  109. package/dist/workspace.d.ts +14 -0
  110. package/dist/workspace.js +127 -0
  111. package/dist/workspace.js.map +1 -0
  112. package/dist/writer-agent.d.ts +21 -0
  113. package/dist/writer-agent.js +27 -0
  114. package/dist/writer-agent.js.map +1 -0
  115. package/dist/writer-entry.d.ts +171 -0
  116. package/dist/writer-entry.js +233 -0
  117. package/dist/writer-entry.js.map +1 -0
  118. package/dist/writing-style.d.ts +7 -0
  119. package/dist/writing-style.js +52 -0
  120. package/dist/writing-style.js.map +1 -0
  121. package/engine/SYNC.json +4 -0
  122. package/engine/VERSION +1 -0
  123. package/engine/detect-project/README.md +141 -0
  124. package/engine/detect-project/SKILL.md +1421 -0
  125. package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
  126. package/engine/detect-project/assets/game/game-frame.css +132 -0
  127. package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
  128. package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
  129. package/engine/detect-project/assets/macosui/fonts.css +15 -0
  130. package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
  131. package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
  132. package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
  133. package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
  134. package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
  135. package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
  136. package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
  137. package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
  138. package/engine/detect-project/assets/mobileui/fonts.css +16 -0
  139. package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
  140. package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
  141. package/engine/detect-project/assets/mobileui/icons.css +31 -0
  142. package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
  143. package/engine/detect-project/assets/mobileui/platforms.json +46 -0
  144. package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
  145. package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
  146. package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
  147. package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
  148. package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
  149. package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
  150. package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
  151. package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
  152. package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
  153. package/engine/detect-project/assets/webtui/themes.json +37 -0
  154. package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
  155. package/engine/detect-project/assets/win32ui/7css.css +2 -0
  156. package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
  157. package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
  158. package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
  159. package/engine/detect-project/package-lock.json +12 -0
  160. package/engine/detect-project/package.json +10 -0
  161. package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
  162. package/engine/detect-project/scripts/check_css_health.js +412 -0
  163. package/engine/detect-project/scripts/check_project_map.js +150 -0
  164. package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
  165. package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
  166. package/engine/detect-project/scripts/classify_app_type.sh +246 -0
  167. package/engine/detect-project/scripts/classify_surface.sh +95 -0
  168. package/engine/detect-project/scripts/classify_workspace.js +39 -0
  169. package/engine/detect-project/scripts/compile_css.sh +447 -0
  170. package/engine/detect-project/scripts/detect_static.js +645 -0
  171. package/engine/detect-project/scripts/detect_structure.js +187 -0
  172. package/engine/detect-project/scripts/include_census.js +451 -0
  173. package/engine/detect-project/scripts/json_get.js +142 -0
  174. package/engine/detect-project/scripts/merge_json.js +52 -0
  175. package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
  176. package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
  177. package/engine/detect-project/scripts/run_css_build.sh +40 -0
  178. package/engine/detect-project/scripts/sanitize_css.js +83 -0
  179. package/engine/detect-project/scripts/test_classify_surface.js +108 -0
  180. package/engine/detect-project/scripts/test_node_helpers.js +142 -0
  181. package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
  182. package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
  183. package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
  184. package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
  185. package/engine/detect-project/scripts/theme_overrides.js +169 -0
  186. package/engine/detect-project/scripts/write_branding.js +129 -0
  187. package/engine/generate-illustrated-article/SKILL.md +461 -0
  188. package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
  189. package/engine/generate-illustrated-article/contracts/game.md +38 -0
  190. package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
  191. package/engine/generate-illustrated-article/contracts/macos.md +79 -0
  192. package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
  193. package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
  194. package/engine/generate-illustrated-article/contracts/win32.md +43 -0
  195. package/engine/generate-illustrated-article/package-lock.json +366 -0
  196. package/engine/generate-illustrated-article/package.json +15 -0
  197. package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
  198. package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
  199. package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
  200. package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
  201. package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
  202. package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
  203. package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
  204. package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
  205. package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
  206. package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
  207. package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
  208. package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
  209. package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
  210. package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
  211. package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
  212. package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
  213. package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
  214. package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
  215. package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
  216. package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
  217. package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
  218. package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
  219. package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
  220. package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
  221. package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
  222. package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
  223. package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
  224. package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
  225. package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
  226. package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
  227. package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
  228. package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
  229. package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
  230. package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
  231. package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
  232. package/install.sh +14 -0
  233. package/package.json +57 -4
  234. package/scripts/auth.mjs +33 -0
  235. package/scripts/auto-update.mjs +7 -0
  236. package/scripts/build-plugin.mjs +58 -0
  237. package/scripts/build-release.mjs +94 -0
  238. package/scripts/check-release.mjs +40 -0
  239. package/scripts/check-runtime.mjs +10 -0
  240. package/scripts/cli.mjs +8 -0
  241. package/scripts/install.mjs +64 -0
  242. package/scripts/lib/agent-runner.mjs +181 -0
  243. package/scripts/lib/agent-settings.mjs +215 -0
  244. package/scripts/lib/article-skills.mjs +82 -0
  245. package/scripts/lib/auto-update.mjs +50 -0
  246. package/scripts/lib/brand.mjs +9 -0
  247. package/scripts/lib/browser.mjs +12 -0
  248. package/scripts/lib/claude-connection.mjs +33 -0
  249. package/scripts/lib/claude-permissions.mjs +47 -0
  250. package/scripts/lib/claude-plugin.mjs +15 -0
  251. package/scripts/lib/claude-writer.mjs +10 -0
  252. package/scripts/lib/cli-main.mjs +85 -0
  253. package/scripts/lib/cli.mjs +888 -0
  254. package/scripts/lib/codex-config.mjs +51 -0
  255. package/scripts/lib/codex-integration.mjs +61 -0
  256. package/scripts/lib/codex-skill.mjs +34 -0
  257. package/scripts/lib/harness-models.mjs +109 -0
  258. package/scripts/lib/install.mjs +269 -0
  259. package/scripts/lib/managed-writer.mjs +38 -0
  260. package/scripts/lib/planning.mjs +263 -0
  261. package/scripts/lib/prepare-update.mjs +85 -0
  262. package/scripts/lib/refresh-writers.mjs +12 -0
  263. package/scripts/lib/remove.mjs +167 -0
  264. package/scripts/lib/renderer.mjs +34 -0
  265. package/scripts/lib/terminal.mjs +252 -0
  266. package/scripts/lib/uninit.mjs +72 -0
  267. package/scripts/lib/update.mjs +55 -0
  268. package/scripts/lib/writer-recovery.mjs +35 -0
  269. package/scripts/lib/yolo.mjs +362 -0
  270. package/scripts/plugin-session.mjs +65 -0
  271. package/scripts/prepare-update.mjs +12 -0
  272. package/scripts/publish-release.mjs +92 -0
  273. package/scripts/skills/supportpages/SKILL.md +120 -0
  274. package/scripts/smoke-release.mjs +101 -0
  275. package/server.json +27 -0
@@ -0,0 +1,263 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile, rm } from 'node:fs/promises';
4
+ import { LocalSetup, cacheFiles, projectCacheFiles } from '../../dist/local-setup.js';
5
+ import { fail, publicError } from '../../dist/errors.js';
6
+ import { runAgent, openAgent } from './agent-runner.mjs';
7
+ import { ensureWriterAvailable } from './writer-recovery.mjs';
8
+ import { Cancelled } from './terminal.mjs';
9
+ import { availableAgents, readAgentSettings, executionSettings, modelDescription } from './agent-settings.mjs';
10
+ import { CLI_NAME } from './brand.mjs';
11
+
12
+ const clean = value => String(value).replace(/[\p{Cc}\p{Cf}]/gu, '');
13
+ const label = agent => agent === 'claude' ? 'Claude Code' : 'Codex';
14
+
15
+ export class Planning {
16
+ constructor(session, options, deps, clients = []) {
17
+ this.session = session; this.options = options; this.deps = deps; this.ui = deps.ui; this.clients = clients;
18
+ }
19
+ async initialize() {
20
+ this.bridge = await this.session.bridge();
21
+ this.store = new LocalSetup(this.bridge.ws, this.bridge.stateRoot);
22
+ this.destination = await this.bridge.destination();
23
+ if (this.destination === 'none') fail('project_required', `Run ${CLI_NAME} init to set up this folder first.`);
24
+ this.projectId = this.destination === 'hosted' ? (await this.bridge.binding()).project_id : undefined;
25
+ return this;
26
+ }
27
+ /** Remote project context, or the folder's own context when it saves articles locally. */
28
+ projectContext() { return this.bridge.contextFor(this.destination); }
29
+ async chooseAgent() {
30
+ if (this.agent) return this.agent;
31
+ const settingsFile = `${this.bridge.stateRoot}/setup/settings.json`;
32
+ const saved = await readAgentSettings(this.bridge);
33
+ const available = await availableAgents(this.session, this.deps, this.clients);
34
+ const preferred = this.options.agent ?? saved.agent;
35
+ if (this.options.agent && !available.includes(this.options.agent)) fail('agent_unavailable', `The selected coding agent is not installed and connected. Run ${CLI_NAME} init.`);
36
+ this.agent = available.includes(preferred) ? preferred : available.length === 1 ? available[0] : await this.ui.choose('Which agent should analyse and plan this project?', available.map(value => ({ value, label: label(value) })));
37
+ await this.bridge.ws.writeJson(settingsFile, { ...saved, agent: this.agent });
38
+ return this.agent;
39
+ }
40
+ /**
41
+ * Explain the analysis, then run it now or later. With one agent this is a yes/no
42
+ * question; with both, the answer also picks the agent (Claude Code first).
43
+ * Returns the agent to run with, or undefined to defer.
44
+ */
45
+ async analysisAgent() {
46
+ const saved = await readAgentSettings(this.bridge);
47
+ const connected = await availableAgents(this.session, this.deps, this.clients);
48
+ if (this.options.agent && !connected.includes(this.options.agent)) fail('agent_unavailable', `The selected coding agent is not installed and connected. Run ${CLI_NAME} init.`);
49
+ const available = this.options.agent ? [this.options.agent] : connected;
50
+ const models = await Promise.all(available.map(async agent => modelDescription(agent, await executionSettings(this.bridge, agent))));
51
+ this.ui.note?.(`SupportPages Writer reads this project once to map its screens, styles and branding, and writes a short product summary. Every article starts from it.\nIt runs in your coding agent with your existing account and takes about 5 minutes.\n\n${models.join('\n')}`, 'Project analysis');
52
+ let agent;
53
+ if (available.length === 1) {
54
+ if (!await this.ui.confirm(`Run the analysis now with ${label(available[0])}?`, true)) return;
55
+ agent = available[0];
56
+ } else {
57
+ const ordered = [...available].sort((a, b) => (b === 'claude') - (a === 'claude'));
58
+ agent = await this.ui.choose('Run the analysis now?', [
59
+ ...ordered.map(value => ({ value, label: `Yes, with ${label(value)}`, ...(value === 'claude' ? { hint: 'Recommended' } : {}) })),
60
+ { value: 'later', label: 'Later', hint: `Run ${CLI_NAME} analyse when you are ready` },
61
+ ], Math.max(0, ordered.indexOf(saved.agent)));
62
+ if (agent === 'later') return;
63
+ }
64
+ this.agent = agent;
65
+ await this.bridge.ws.writeJson(`${this.bridge.stateRoot}/setup/settings.json`, { ...saved, agent });
66
+ return agent;
67
+ }
68
+ async context() {
69
+ const remote = await this.projectContext();
70
+ if (remote.inventory_truncated) fail('inventory_truncated', 'The help-centre inventory is too large for a complete local planning pass.');
71
+ const analysis = await this.store.requireAnalysis();
72
+ return { ...remote, ...remote.product_context, user_context: remote.product_context ?? {}, project_name: remote.project.name,
73
+ project_overview: analysis.overview, analysis_summary: analysis.summary };
74
+ }
75
+
76
+ async task(skill, context, extra = '') {
77
+ const agent = await this.chooseAgent();
78
+ const settings = await executionSettings(this.bridge, agent);
79
+ const ws = this.bridge.ws;
80
+ const directory = `${this.bridge.stateRoot}/setup/tasks/${randomUUID()}`;
81
+ const contextPath = await ws.writeJson(`${directory}/context.json`, context);
82
+ const output = await ws.resolve(directory);
83
+ const previous = await this.store.analysis();
84
+ if (previous.status === 'ready' && await ws.exists(`${previous.output_dir}/file_tree.txt`)) await ws.write(`${directory}/file_tree.txt`, await ws.read(`${previous.output_dir}/file_tree.txt`));
85
+ let instructions;
86
+ try { instructions = await readFile(path.join(this.session.options.skillsDir, skill, 'SKILL.md'), 'utf8'); }
87
+ catch { fail('missing_dependency', `The ${skill} skill is missing. Run ${CLI_NAME} init to repair the installation.`); }
88
+ const receipt = { version: 1, skill, agent, workspace: ws.root, output_dir: directory, status: 'running', started_at: new Date().toISOString() };
89
+ this.activeTask = receipt;
90
+ await ws.writeJson(`${this.bridge.stateRoot}/setup/task.json`, receipt);
91
+ const targetContract = skill === 'detect-project' ? `\n\nLocal setup output contract: Write ${output}/analysis-target.json containing {"codebase_dir":"."} when analysing the workspace root, or the actual workspace-relative application directory (for example {"codebase_dir":"apps/web"}) when analysing a nested application. Keep branding.json, project_map.json and branding.css in that application's .rtfm directory. Do not copy its cache into the parent: source paths are relative to the application. Write summary.md and overview.txt in ${output}. Before reporting success, verify all five files exist and contain valid output, including when reusing an existing cache. Record the selected directory even when invoking the skill through the Skill tool.` : '';
92
+ const prompt = `Run the ${skill} skill for this local project. Complete the task and write its output files; do not generate articles, publish, or call SupportPages.io MCP tools.\nIf a required tool or file access needs permission, stop and report the block. Do not work around permission denials by copying scripts or switching tools.\nWorkspace: ${ws.root}\nEngine root: ${this.session.options.skillsDir} (exported as RTFM_SKILLS_DIR, which the skill paths use).\nContext: ${contextPath}\nOutput directory: ${output}\n${extra}\n\n${instructions}${targetContract}`;
93
+ const env = { ...this.deps.env, RTFM_WORKSPACE: ws.root, RTFM_CONTEXT_FILE: contextPath, RTFM_OUTPUT_DIR: output,
94
+ RTFM_ANALYZE: skill === 'detect-project' ? 'full' : '', RTFM_SKILLS_DIR: this.session.options.skillsDir };
95
+ try {
96
+ await (this.deps.runAgent ?? runAgent)({ agent, ...settings, workspace: ws.root, prompt, ui: this.ui,
97
+ env,
98
+ logPath: await ws.resolve(`${directory}/agent.log`) });
99
+ return { directory, receipt };
100
+ } catch (error) {
101
+ await ws.writeJson(`${this.bridge.stateRoot}/setup/task.json`, { ...receipt, status: error instanceof Cancelled ? 'cancelled' : 'failed', error: publicError(error), finished_at: new Date().toISOString() });
102
+ if (['agent_failed', 'agent_unavailable', 'agent_timeout', 'agent_permission_required'].includes(error.code)) {
103
+ this.ui.line(publicError(error).message);
104
+ const action = await this.ui.choose('Continue this setup stage?', [
105
+ { value: 'interactive', label: `Open ${label(agent)} to finish interactively`, hint: 'Sign in or approve tools in your agent, then exit when finished.' },
106
+ { value: 'stop', label: 'Stop and retry later' }]);
107
+ if (action === 'interactive') {
108
+ await ws.writeJson(`${this.bridge.stateRoot}/setup/task.json`, receipt);
109
+ if (await (this.deps.openAgent ?? openAgent)({ agent, ...settings, workspace: ws.root, prompt, env, ui: this.ui })) {
110
+ // The caller still validates every output; exiting the agent is not success.
111
+ return { directory, receipt };
112
+ }
113
+ }
114
+ }
115
+ await ws.writeJson(`${this.bridge.stateRoot}/setup/task.json`, { ...receipt, status: error instanceof Cancelled ? 'cancelled' : 'failed', error: publicError(error), finished_at: new Date().toISOString() });
116
+ throw error;
117
+ }
118
+ }
119
+ async finish(task) {
120
+ await this.bridge.ws.writeJson(`${this.bridge.stateRoot}/setup/task.json`, { ...task.receipt, status: 'completed', finished_at: new Date().toISOString() });
121
+ this.activeTask = undefined;
122
+ }
123
+ async failedTask(error) {
124
+ if (!this.activeTask) return;
125
+ await this.bridge.ws.writeJson(`${this.bridge.stateRoot}/setup/task.json`, { ...this.activeTask,
126
+ status: error instanceof Cancelled ? 'cancelled' : 'failed', error: publicError(error), finished_at: new Date().toISOString() });
127
+ this.activeTask = undefined;
128
+ }
129
+ async sourceCommit(codebaseDir = '.') {
130
+ const result = await this.deps.run('git', ['rev-parse', 'HEAD'], { cwd: await this.bridge.ws.resolve(codebaseDir), capture: true });
131
+ return result.code === 0 && /^[a-f0-9]{40,64}$/.test(result.stdout?.trim() ?? '') ? result.stdout.trim() : null;
132
+ }
133
+ async recoverAnalysis() {
134
+ const ws = this.bridge.ws;
135
+ const file = `${this.bridge.stateRoot}/setup/task.json`;
136
+ if (!await ws.exists(file)) return;
137
+ const task = await ws.json(file);
138
+ if (task.skill !== 'detect-project' || task.status !== 'failed' || task.workspace !== ws.root || typeof task.output_dir !== 'string') return;
139
+ if (!task.output_dir.startsWith(`${this.bridge.stateRoot}/setup/tasks/`)) return;
140
+ const candidates = [];
141
+ try { candidates.push((await this.store.validate(task.output_dir)).codebase_dir); }
142
+ catch {
143
+ // Legacy runs did not record their chosen application. Offer only complete
144
+ // immediate-child caches, then let the user identify the intended product.
145
+ if (await ws.exists(`${task.output_dir}/analysis-target.json`)) return;
146
+ for (const entry of (await ws.list('.')).slice(0, 200)) {
147
+ if (!entry.isDirectory() || entry.name.startsWith('.') || ['node_modules', 'vendor', 'output'].includes(entry.name)) continue;
148
+ try { await this.store.validate(task.output_dir, entry.name); candidates.push(entry.name); } catch { /* Incomplete or unsafe cache. */ }
149
+ }
150
+ }
151
+ if (!candidates.length) return;
152
+ this.ui.note?.((await ws.read(`${task.output_dir}/overview.txt`)).toString(), 'Previous analysis');
153
+ const selected = candidates.length === 1
154
+ ? await this.ui.confirm(`Reuse the completed analysis for ${clean(candidates[0])}?`, true) ? candidates[0] : undefined
155
+ : await this.ui.choose('Which application was analysed?', [...candidates.map(value => ({ value, label: clean(value) })), { value: '', label: 'Run analysis again' }]);
156
+ if (!selected) return;
157
+ await this.store.accept(task.output_dir, task.agent, await this.bridge.version(), await this.sourceCommit(selected), selected);
158
+ await this.finish({ receipt: task });
159
+ this.ui.ok(`Recovered project analysis from ${clean(selected)}.`);
160
+ return this.store.requireAnalysis();
161
+ }
162
+ async analyse(refresh = false, hint = this.options['app-type'], correction = '') {
163
+ const ws = this.bridge.ws;
164
+ let result, previousReady = false;
165
+ for (;;) {
166
+ try {
167
+ await ws.lock(async () => {
168
+ const existing = await this.store.analysis();
169
+ previousReady = existing.status === 'ready';
170
+ // Detection that stopped short names the app type it needs; use it rather
171
+ // than making the user repeat what the map already asked for.
172
+ if (!hint && !previousReady && existing.app_type) {
173
+ hint = existing.app_type;
174
+ this.ui.line(`The saved project map is incomplete and asks for app_type=${hint}. Detecting again with that app type.`);
175
+ }
176
+ const commit = await this.sourceCommit(existing.status === 'ready' ? existing.codebase_dir : '.');
177
+ if (existing.status === 'ready' && !refresh && commit && existing.source_commit && existing.source_commit !== commit) {
178
+ refresh = await this.ui.confirm('This checkout has changed since analysis. Refresh it now?', false);
179
+ }
180
+ if (existing.status === 'ready' && !refresh) {
181
+ // Say which analysis is in use and how to redo it, so a stale cache is
182
+ // never a silent surprise.
183
+ this.ui.line(`Using the project analysis from ${clean(existing.completed_at?.slice(0, 10) ?? 'an earlier run')}. Rerun with ${CLI_NAME} ${this.options.command === 'init' ? 'init' : 'analyse'} --refresh to redo it.`);
184
+ result = existing;
185
+ return;
186
+ }
187
+ await this.bridge.runs.assertAvailable();
188
+ if (!refresh) { result = await this.recoverAnalysis(); if (result) return; }
189
+ if (!refresh && await ws.exists('output/detect-project/summary.md')) {
190
+ try { await this.store.accept('output/detect-project', 'existing cache', await this.bridge.version()); result = await this.store.requireAnalysis(); return; }
191
+ catch { /* Incomplete legacy detection needs a full run. */ }
192
+ }
193
+ if (!await this.analysisAgent()) return;
194
+ this.ui.info?.('Loading project context…');
195
+ const backup = new Map();
196
+ const restoreFiles = [...new Set([...cacheFiles, ...(existing.status === 'ready' ? projectCacheFiles(existing.codebase_dir) : []), `${this.bridge.stateRoot}/setup/analysis.json`])];
197
+ for (const file of restoreFiles) if (await ws.exists(file)) backup.set(file, await ws.read(file, 10 * 1024 * 1024));
198
+ let task, validation;
199
+ try {
200
+ const context = await this.projectContext();
201
+ task = await this.task('detect-project', { ...context, ...context.product_context, project_name: context.project.name }, `RTFM_ANALYZE=full: summary.md and overview.txt are mandatory. ${hint ? `Explicit app_type=${hint}.` : ''} ${correction ? `User correction: ${correction}` : ''}`);
202
+ validation = this.ui.progress?.('Checking analysis files…');
203
+ const target = await this.store.validate(task.directory);
204
+ await this.store.accept(task.directory, this.agent, await this.bridge.version(), await this.sourceCommit(target.codebase_dir), target.codebase_dir);
205
+ await this.finish(task);
206
+ result = await this.store.requireAnalysis();
207
+ validation?.stop('Analysis files checked and saved.');
208
+ } catch (error) {
209
+ validation?.stop('Analysis files could not be validated.', 'error');
210
+ for (const file of restoreFiles) {
211
+ if (backup.has(file)) await ws.write(file, backup.get(file));
212
+ else await rm(await ws.resolve(file), { force: true });
213
+ }
214
+ await this.failedTask(error);
215
+ if (task && error.code === 'missing_artifact') fail('invalid_analysis', `Project analysis did not produce all required files: ${publicError(error).message}. The summary and agent log are saved in ${task.directory}. Rerun ${CLI_NAME} init to recover a completed nested-application analysis or try again.`, { output_dir: task.directory });
216
+ if (error instanceof Cancelled || error.code) throw error;
217
+ fail('invalid_analysis', `The analysis is incomplete or invalid. Run ${CLI_NAME} analyse --refresh to retry; the previous valid cache was kept.`);
218
+ }
219
+ });
220
+ break;
221
+ } catch (error) {
222
+ if (error.code !== 'generation_active') throw error;
223
+ await ensureWriterAvailable(this.bridge, this.ui);
224
+ }
225
+ }
226
+ if (!result) {
227
+ this.ui.outro?.(previousReady
228
+ ? `Refresh deferred. Your previous analysis is still ready. Run ${CLI_NAME} analyse --refresh when you want to update it.`
229
+ : `Analysis deferred. Your project settings are saved.\nRun ${CLI_NAME} analyse when you are ready; articles need it first.`);
230
+ return;
231
+ }
232
+ this.ui.note?.(`${clean(result.overview)}\n\nApplication: ${clean(result.codebase_dir)}\nApp type: ${clean(result.app_type)}\nFramework: ${clean(result.framework)}`, 'Project analysis');
233
+ this.ui.ok('Project analysis is ready.');
234
+ return result;
235
+ }
236
+ async reviewAnalysis() {
237
+ return Boolean(await this.analyse(Boolean(this.options.refresh || this.options['app-type'])));
238
+ }
239
+ async write() {
240
+ await this.store.requireAnalysis();
241
+ await ensureWriterAvailable(this.bridge, this.ui);
242
+ const agent = await this.chooseAgent();
243
+ const connection = this.session.options.dev ? 'supportpages-dev' : 'supportpages';
244
+ const other = this.session.options.dev ? 'supportpages' : 'supportpages-dev';
245
+ let disabledConnection = agent === 'claude' ? other : undefined;
246
+ if (agent === 'codex') {
247
+ // An enabled-only entry for a missing server is invalid Codex config.
248
+ const existing = await this.deps.run('codex', ['mcp', 'get', other, '--json'], { cwd: this.bridge.ws.root, capture: true });
249
+ if (existing.code === 0 && existing.stdout?.trim()) disabledConnection = other;
250
+ }
251
+ const prompt = this.destination === 'local'
252
+ ? `Use the ${connection} MCP connection for this project (${this.session.options.origin}). Project detection is complete. This project saves articles locally as Markdown and screenshots; there is no help centre and no sign-in is needed. Ask me which single article I want to write, then create it using SupportPages.io: call supportpages_prepare_article before writing and supportpages_complete_article after successful generation, then show where the article was saved. Do not upload, publish or start sign-in automatically. If I explicitly ask to host the saved articles, use supportpages_publish. Do not run the retired planning skills (suggest-sections, recommend-articles); running detect-project again is allowed and required when the project map is missing, stale or reports an incomplete detection.`
253
+ : `Use the ${connection} MCP connection for this project (${this.session.options.origin}). Project detection is complete. Ask me which single article I want to write, then create it using SupportPages.io. Use supportpages_prepare_article before writing and supportpages_complete_article after successful generation. Return the draft review link. Do not publish automatically. Sections, topic suggestions and PR analysis are available in the web app after connecting a repository, so do not run the retired planning skills (suggest-sections, recommend-articles). Running detect-project again is allowed and required when the project map is missing, stale or reports an incomplete detection.`;
254
+ this.ui.outro?.(`Opening ${label(agent)} in this project.`);
255
+ return (this.deps.openAgent ?? openAgent)({ agent, ...await executionSettings(this.bridge, agent), workspace: this.bridge.ws.root, prompt, env: this.deps.env, ui: this.ui, disabledConnection });
256
+ }
257
+ async menu() {
258
+ await ensureWriterAvailable(this.bridge, this.ui);
259
+ this.ui.ok('Ready to write.');
260
+ const agent = await this.chooseAgent();
261
+ this.ui.outro?.(`Open ${label(agent)} in this project and ask for an article, for example: “Write an illustrated guide to inviting a teammate.” Restart ${label(agent)} first if it was already open.`);
262
+ }
263
+ }
@@ -0,0 +1,85 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { chmod, copyFile, lstat, readFile, realpath, rename, rm } from 'node:fs/promises';
4
+ import { ARTICLE_SKILLS, installTraceHook } from './article-skills.mjs';
5
+ import { command, exists, privateJson } from './install.mjs';
6
+ import { ensureRenderer, rendererReady } from './renderer.mjs';
7
+ import { refreshCodexSkill } from './codex-skill.mjs';
8
+ import { refreshWriters } from './refresh-writers.mjs';
9
+
10
+ /** Did the previous release ever render? Releases with an engine count when
11
+ * their Chromium is on disk; earlier releases installed renderer dependencies
12
+ * into skills/<name>/node_modules. Unused installations download nothing. */
13
+ async function rendererUsed(previous, home, run) {
14
+ if (await exists(path.join(previous, 'engine'))) {
15
+ const node = path.resolve(previous, '../runtime/bin/node');
16
+ if (await rendererReady(path.join(previous, 'engine'), run, await exists(node) ? node : process.execPath)) return true;
17
+ }
18
+ const roots = [path.join(previous, 'skills')];
19
+ // A local CLI build may have used an older release's skills through the
20
+ // managed ~/.rtfm-skills shortcut.
21
+ try { roots.push(await realpath(path.join(home, '.rtfm-skills'))); }
22
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
23
+ for (const root of roots) for (const skill of ARTICLE_SKILLS) if (await exists(path.join(root, skill, 'node_modules'))) return true;
24
+ return false;
25
+ }
26
+
27
+ /** Point an existing SupportPages trace hook at this installation's Node hook,
28
+ * through the managed `current` link so it survives later updates. Clients
29
+ * that never had the hook are left alone. */
30
+ async function migrateTraceHook(root, { home, env }) {
31
+ const settingsFile = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), 'settings.json');
32
+ let settings;
33
+ try { settings = JSON.parse(await readFile(settingsFile, 'utf8')); } catch { return; }
34
+ const managed = [path.join(home, '.rtfm-skills'), root];
35
+ const hooks = (settings.hooks?.PostToolUse ?? []).flatMap(group => Array.isArray(group.hooks) ? group.hooks : []);
36
+ if (!hooks.some(hook => /trace_hook\.(sh|js)/.test(String(hook.command ?? '')) && managed.some(prefix => String(hook.command).includes(prefix)))) return;
37
+ const current = path.join(root, 'current');
38
+ await installTraceHook({ home, env, writeJson: privateJson, hookCommand: [
39
+ path.join(current, 'runtime', 'bin', 'node'),
40
+ path.join(current, 'mcp', 'engine', 'generate-illustrated-article', 'scripts', 'trace_hook.js'),
41
+ ] });
42
+ }
43
+
44
+ const MANAGED_LAUNCHER = '# SupportPages managed launcher';
45
+ async function managedLauncher(file) {
46
+ try {
47
+ if (!(await lstat(file)).isFile()) return false;
48
+ return (await readFile(file, 'utf8')).split('\n')[1] === MANAGED_LAUNCHER;
49
+ } catch { return false; }
50
+ }
51
+
52
+ /** Updates run the previous release's installer, which only writes the
53
+ * `supportpages` launcher. Add the identical `wtfm` launcher beside it so an
54
+ * existing installation gains the new command name. Best effort: anything
55
+ * unexpected (no receipt, an unrelated wtfm) leaves the installation as it is. */
56
+ export async function ensureCommandAlias(root) {
57
+ try {
58
+ const receipt = JSON.parse(await readFile(path.join(root, 'install.json'), 'utf8'));
59
+ if (typeof receipt.bin_dir !== 'string' || !path.isAbsolute(receipt.bin_dir)) return false;
60
+ const existing = path.join(receipt.bin_dir, 'supportpages'), alias = path.join(receipt.bin_dir, 'wtfm');
61
+ if (!await managedLauncher(existing)) return false;
62
+ try { await lstat(alias); return false; } catch (error) { if (error.code !== 'ENOENT') return false; }
63
+ const temporary = path.join(receipt.bin_dir, `.wtfm.${process.pid}.${Date.now()}`);
64
+ try {
65
+ await copyFile(existing, temporary);
66
+ await chmod(temporary, 0o755);
67
+ await rename(temporary, alias);
68
+ } finally { await rm(temporary, { force: true }); }
69
+ return true;
70
+ } catch { return false; }
71
+ }
72
+
73
+ /** Prepare the new release's renderer and hook before promoting it, then
74
+ * refresh managed writer and coordination instructions. */
75
+ export async function prepareUpdate(previous, next, { run = command, env = process.env, home = os.homedir() } = {}) {
76
+ const engine = path.join(next, 'engine');
77
+ if (await exists(engine) && await rendererUsed(previous, home, run)) {
78
+ const node = path.resolve(next, '../runtime/bin/node');
79
+ if (!await ensureRenderer(engine, run, { node })) throw Error('Could not prepare the updated article renderer. The current release is still active.');
80
+ }
81
+ if (await exists(engine)) await migrateTraceHook(path.resolve(next, '../../..'), { home, env });
82
+ await refreshWriters({ home, env });
83
+ await refreshCodexSkill({ home, env });
84
+ await ensureCommandAlias(path.resolve(next, '../../..'));
85
+ }
@@ -0,0 +1,12 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { installManagedWriter } from './managed-writer.mjs';
4
+
5
+ /** Update owned writer policies without adding integrations or changing permissions. */
6
+ export async function refreshWriters({ home = os.homedir(), env = process.env } = {}) {
7
+ const { codexWriterFile, writerAgentFile, writerAgentType } = await import('../../dist/writer-agent.js');
8
+ for (const entry of [
9
+ { config: path.resolve(env.CODEX_HOME || path.join(home, '.codex')), extension: 'toml', contents: codexWriterFile, marker: '# Managed by SupportPages: article writer v1' },
10
+ { config: path.resolve(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')), extension: 'md', contents: writerAgentFile, marker: '<!-- Managed by SupportPages: article writer v1 -->' },
11
+ ]) await installManagedWriter({ ...entry, name: writerAgentType, existingOnly: true });
12
+ }
@@ -0,0 +1,167 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { lstat, readFile, readdir, realpath, rm } from 'node:fs/promises';
4
+ import { backup, expand, privateJson } from './install.mjs';
5
+ import { retirePublicSkillLinks } from './article-skills.mjs';
6
+ import { Cancelled } from './terminal.mjs';
7
+ import { fail } from '../../dist/errors.js';
8
+ import { claudePermissionRules } from './claude-permissions.mjs';
9
+ import { CLI_NAME } from './brand.mjs';
10
+
11
+ const clean = value => String(value).replace(/[\p{Cc}\p{Cf}]/gu, '');
12
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
13
+ const canonical = async filename => { try { return await realpath(filename); } catch { return path.resolve(filename); } };
14
+
15
+ /** Remove local integrations only; never contact the API or change a product workspace. */
16
+ export async function removeIntegration(options, { home = os.homedir(), env = process.env, ui, run }) {
17
+ const configDir = expand(options['config-dir'] ?? path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'supportpages'));
18
+ const directory = path.join(configDir, 'installations');
19
+ const clients = options.agent ? [options.agent] : ['claude', 'codex'];
20
+ const claudeDir = path.resolve(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'));
21
+ const claudeFile = env.CLAUDE_CONFIG_DIR ? path.join(claudeDir, '.claude.json') : path.join(home, '.claude.json');
22
+ const codexDir = path.resolve(env.CODEX_HOME || path.join(home, '.codex'));
23
+ const snapshots = new Map(), edits = new Map(), removals = [], commands = [], descriptions = [];
24
+ async function read(filename) {
25
+ if (snapshots.has(filename)) return snapshots.get(filename);
26
+ // Refuse linked configuration paths, including linked parent directories.
27
+ const base = [configDir, claudeDir, codexDir, home].sort((a, b) => b.length - a.length).find(root => filename.startsWith(root + path.sep)) ?? path.dirname(filename);
28
+ for (let cursor = filename; cursor !== path.dirname(base) && cursor !== path.dirname(cursor); cursor = path.dirname(cursor)) {
29
+ try { if ((await lstat(cursor)).isSymbolicLink()) fail('unsafe_configuration', `Keep the linked configuration path: ${clean(cursor)}`); }
30
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
31
+ }
32
+ let contents;
33
+ try { contents = await readFile(filename, 'utf8'); }
34
+ catch (error) { if (error.code !== 'ENOENT') throw error; contents = null; }
35
+ snapshots.set(filename, contents);
36
+ return contents;
37
+ }
38
+ async function json(filename) {
39
+ const contents = await read(filename);
40
+ if (contents === null) return null;
41
+ try { const value = JSON.parse(contents); if (object(value)) return value; } catch { /* Safe error without configuration contents. */ }
42
+ fail('invalid_configuration', `Cannot read the configuration at ${clean(filename)}. Repair it before removing the integration.`);
43
+ }
44
+ async function matches(actual, expected) {
45
+ return actual && expected && Array.isArray(actual.args) && Array.isArray(expected.args) &&
46
+ JSON.stringify(actual.args) === JSON.stringify(expected.args) && typeof actual.command === 'string' && typeof expected.command === 'string' &&
47
+ await canonical(actual.command) === await canonical(expected.command);
48
+ }
49
+ let filenames;
50
+ try { filenames = await readdir(directory); } catch (error) { if (error.code !== 'ENOENT') throw error; filenames = []; }
51
+ const records = [];
52
+ for (const filename of filenames.sort()) {
53
+ if (!/^[a-z0-9][a-z0-9_-]{0,79}\.json$/.test(filename)) continue;
54
+ const receiptPath = path.join(directory, filename);
55
+ const receipt = await json(receiptPath);
56
+ if (![1, 2].includes(receipt?.version) || receipt.name !== filename.slice(0, -5) || !Array.isArray(receipt.clients) || receipt.clients.some(client => !['claude', 'codex'].includes(client))) continue;
57
+ const manualPath = path.join(directory, `${receipt.name}.mcp.json`);
58
+ const manual = await json(manualPath);
59
+ const expected = manual?.mcpServers?.[receipt.name];
60
+ const selected = (!options.dev || expected?.args?.includes('--dev') || receipt.name === 'supportpages-dev') &&
61
+ (!options['api-url'] || receipt.api_origin === options['api-url']);
62
+ records.push({ receipt, receiptPath, manualPath, expected, selected, remaining: [...receipt.clients] });
63
+ }
64
+ let claude, settings;
65
+ const rules = new Set();
66
+ if (!options['skills-only']) {
67
+ for (const record of records.filter(record => record.selected)) {
68
+ const { receipt, expected } = record;
69
+ for (const client of receipt.clients.filter(client => clients.includes(client))) {
70
+ let actual;
71
+ if (client === 'claude') {
72
+ claude ??= await json(claudeFile) ?? {};
73
+ if (claude.mcpServers !== undefined && !object(claude.mcpServers)) fail('invalid_configuration', 'Claude MCP settings are invalid. Repair them before removal.');
74
+ actual = claude.mcpServers?.[receipt.name];
75
+ } else {
76
+ const filename = path.join(codexDir, 'config.toml');
77
+ if (await read(filename) !== null) {
78
+ const result = await run('codex', ['mcp', 'get', receipt.name, '--json'], { capture: true, env: { ...env, CODEX_HOME: codexDir } });
79
+ if (result.code === 0) {
80
+ try { const value = JSON.parse(result.stdout); actual = value.transport ?? value; }
81
+ catch { fail('invalid_configuration', 'Codex returned invalid MCP configuration. No integrations were removed.'); }
82
+ } else if (result.code !== 1 || !/No MCP server .*found/i.test(result.stderr ?? '')) {
83
+ fail('client_unavailable', 'Could not inspect Codex. Restore its command, or use --agent claude to remove only Claude integration.');
84
+ }
85
+ }
86
+ }
87
+ if (actual && !await matches(actual, expected)) {
88
+ ui.line(`Kept changed or unmanaged ${client} connection: ${clean(receipt.name)}`);
89
+ continue;
90
+ }
91
+ if (actual) {
92
+ descriptions.push(`${client === 'claude' ? 'Claude Code' : 'Codex'} connection: ${receipt.name}`);
93
+ if (client === 'claude') { delete claude.mcpServers[receipt.name]; edits.set(claudeFile, claude); }
94
+ else commands.push({ name: receipt.name, filename: path.join(codexDir, 'config.toml') });
95
+ }
96
+ if (client === 'claude') {
97
+ // Older setups added only the publish rule; current ones allow the server and ask for removals.
98
+ const current = claudePermissionRules(receipt.name);
99
+ for (const rule of [`mcp__${receipt.name}__supportpages_publish_article`, ...current.allow, ...current.ask]) rules.add(rule);
100
+ }
101
+ record.remaining = record.remaining.filter(value => value !== client);
102
+ }
103
+ if (!record.remaining.length && (!options.agent || record.receipt.clients.includes(options.agent))) {
104
+ removals.push(record.receiptPath, record.manualPath);
105
+ descriptions.push(`Saved integration registration: ${record.receipt.name}`);
106
+ } else if (record.remaining.length !== record.receipt.clients.length) edits.set(record.receiptPath, { ...record.receipt, clients: record.remaining });
107
+ }
108
+ for (const client of clients) {
109
+ // Writer definitions and the trace hook are shared by all API connections.
110
+ if (records.some(record => record.remaining.includes(client)) ||
111
+ (options.dev || options['api-url']) && !records.some(record => record.selected && record.receipt.clients.includes(client))) continue;
112
+ const filename = path.join(client === 'claude' ? claudeDir : codexDir, 'agents', `supportpages-io.${client === 'claude' ? 'md' : 'toml'}`);
113
+ const contents = await read(filename);
114
+ const marker = client === 'claude' ? '<!-- Managed by SupportPages: article writer v1 -->' : '# Managed by SupportPages: article writer v1';
115
+ if (contents?.includes(marker)) { removals.push(filename); descriptions.push(`${client === 'claude' ? 'Claude Code' : 'Codex'} SupportPages Writer agent`); }
116
+ }
117
+ if (clients.includes('claude')) {
118
+ const filename = path.join(claudeDir, 'settings.json');
119
+ settings = await json(filename);
120
+ if (settings) {
121
+ const before = JSON.stringify(settings);
122
+ for (const key of ['allow', 'ask']) {
123
+ if (Array.isArray(settings.permissions?.[key])) settings.permissions[key] = settings.permissions[key].filter(rule => !rules.has(rule));
124
+ }
125
+ if ((!options.dev && !options['api-url'] || records.some(record => record.selected && record.receipt.clients.includes('claude'))) && !records.some(record => record.remaining.includes('claude')) && Array.isArray(settings.hooks?.PostToolUse)) {
126
+ const skillDirs = records.map(record => record.receipt.skills_dir).filter(value => typeof value === 'string');
127
+ // Hooks name the engine by its managed `current` path so they survive updates.
128
+ const stable = skillDirs.map(dir => dir.replace(/\/versions\/[^/]+\/mcp\/(skills|engine)$/, '/current/mcp/$1'));
129
+ const roots = [path.join(home, '.rtfm-skills'), ...skillDirs, ...stable];
130
+ const ours = command => roots.some(root => ['sh', 'js'].some(ext => String(command ?? '').includes(path.join(root, `generate-illustrated-article/scripts/trace_hook.${ext}`))));
131
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.map(group => Array.isArray(group.hooks)
132
+ ? { ...group, hooks: group.hooks.filter(item => !ours(item.command)) } : group)
133
+ .filter(group => !Array.isArray(group.hooks) || group.hooks.length);
134
+ }
135
+ if (JSON.stringify(settings) !== before) { edits.set(filename, settings); descriptions.push('SupportPages.io Claude permissions and unused trace hook'); }
136
+ }
137
+ }
138
+ }
139
+ const skillOptions = records.filter(record => record.selected).map(record => ({ home, env, clients: options.agent ? clients : [...new Set([...record.receipt.clients, 'codex', 'claude'])],
140
+ directory: record.receipt.skills_dir ?? record.expected?.args?.[record.expected.args.indexOf('--skills-dir') + 1] }));
141
+ if (!options.dev && !options['api-url']) skillOptions.push({ home, env, clients, directory: expand(options['skills-dir'] ?? env.RTFM_SKILLS_DIR ?? path.join(home, '.rtfm-skills')) });
142
+ const links = new Set();
143
+ for (const args of skillOptions) for (const filename of await retirePublicSkillLinks({ ...args, dryRun: true })) links.add(filename);
144
+ descriptions.push(...[...links].map(filename => `Skill shortcut: ${filename}`));
145
+ if (!descriptions.length && !edits.size) { ui.ok('No managed SupportPages Writer integration found to remove.'); return { status: 'unchanged' }; }
146
+ ui.intro?.('SupportPages Writer · Remove integration');
147
+ for (const description of descriptions) ui.line(clean(description));
148
+ ui.line('Projects, article files, saved repository links, downloaded engines, saved credentials and SupportPages Writer will be kept.');
149
+ if (!options.yes && !await ui.confirm(options['skills-only'] ? 'Remove these SupportPages.io skill shortcuts?' : 'Remove these SupportPages Writer integrations?', false)) throw new Cancelled('Removal cancelled. Your integration was kept.');
150
+ // Do not overwrite edits made while the user reviewed the removal list.
151
+ for (const [filename, before] of snapshots) {
152
+ let current;
153
+ try { current = await readFile(filename, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; current = null; }
154
+ if (current !== before) fail('configuration_changed', `Configuration changed during review. Rerun ${CLI_NAME} remove to review it again.`);
155
+ }
156
+ for (const item of commands) {
157
+ await backup(item.filename, path.join(configDir, 'backups'));
158
+ const result = await run('codex', ['mcp', 'remove', item.name], { capture: true, env: { ...env, CODEX_HOME: codexDir } });
159
+ if (result.code !== 0) fail('remove_failed', `Codex could not remove ${item.name}. Its installation receipt was kept; rerun ${CLI_NAME} remove to finish.`);
160
+ }
161
+ for (const [filename, value] of edits) { await backup(filename, path.join(configDir, 'backups')); await privateJson(filename, value); }
162
+ for (const args of skillOptions) await retirePublicSkillLinks(args);
163
+ for (const filename of removals) { await backup(filename, path.join(configDir, 'backups')); await rm(filename, { force: true }); }
164
+ ui.ok(options['skills-only'] ? 'SupportPages.io skill shortcuts removed.' : 'SupportPages Writer integrations removed.');
165
+ ui.line('Restart existing coding-agent sessions to unload the removed integrations and cached skills.');
166
+ return { status: 'removed' };
167
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+
3
+ // The engine's renderer resolves puppeteer from the package's node_modules.
4
+ // Plugin installs run npm without lifecycle scripts, so puppeteer's own
5
+ // Chromium download may never have happened: run it here instead.
6
+ const resolvePuppeteer = articleDir => [
7
+ '--input-type=module', '-e',
8
+ "import {createRequire} from 'node:module'; const require=createRequire(process.argv[1]+'/package.json'); process.stdout.write(require.resolve('puppeteer/package.json'));",
9
+ articleDir,
10
+ ];
11
+ const checkBrowser = articleDir => [
12
+ '--input-type=module', '-e',
13
+ "import {createRequire} from 'node:module'; import {accessSync} from 'node:fs'; const require=createRequire(process.argv[1]+'/package.json'); const p=require('puppeteer'); accessSync(await p.executablePath());",
14
+ articleDir,
15
+ ];
16
+
17
+ /** True when puppeteer loads and its Chromium is on disk. */
18
+ export async function rendererReady(skillsDir, run, node = process.execPath) {
19
+ const articleDir = path.join(skillsDir, 'generate-illustrated-article');
20
+ return (await run(node, checkBrowser(articleDir), { capture: true })).code === 0;
21
+ }
22
+
23
+ /** Download puppeteer's pinned Chromium when it is missing. Returns true when ready. */
24
+ export async function ensureRenderer(skillsDir, run, { node = process.execPath, ui } = {}) {
25
+ if (await rendererReady(skillsDir, run, node)) return true;
26
+ const articleDir = path.join(skillsDir, 'generate-illustrated-article');
27
+ const located = await run(node, resolvePuppeteer(articleDir), { capture: true });
28
+ if (located.code !== 0 || !located.stdout.trim()) return false;
29
+ ui?.line?.(' Downloading Chromium for article screenshots. This can take a few minutes the first time.');
30
+ // puppeteer's postinstall script: downloads the pinned browsers into its cache.
31
+ const installer = path.join(path.dirname(located.stdout.trim()), 'install.mjs');
32
+ const result = await run(node, [installer], { cwd: path.dirname(installer) });
33
+ return result.code === 0 && rendererReady(skillsDir, run, node);
34
+ }