返回 CodeWhale
import_claude.rs
根目录 / crates / tui / src / commands / groups / config / import_claude.rs
1 //! `/import-claude` — explicit, reviewable Claude Code migration (#5557).
2
3 use crate::commands::CommandResult;
4 use crate::import_claude::{self, McpCandidateLine};
5 use crate::tui::app::App;
6
7 pub(super) fn import_claude_command(app: &mut App, arg: Option<&str>) -> CommandResult {
8 let apply = arg.map(str::trim).is_some_and(|arg| {
9 arg.eq_ignore_ascii_case("--apply") || arg.eq_ignore_ascii_case("apply")
10 });
11 let home = crate::config::effective_home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
12 let (sources, claude, settings) = import_claude::read_sources(&home);
13
14 // MCP candidates come from the same discovery the `/mcp import` consent
15 // flow uses, filtered to the Claude sources: provenance stays single-sourced.
16 let markets = Vec::new();
17 let mcp_candidates =
18 crate::mcp::external_import::discover_external_sources(&home, &app.workspace, &markets)
19 .into_iter()
20 .filter(|candidate| {
21 matches!(
22 candidate.source_kind,
23 crate::mcp::external_import::ExternalMcpSourceKind::ClaudeJson
24 )
25 })
26 .map(|candidate| McpCandidateLine {
27 summary: candidate.summary,
28 hard_blocked: candidate.hard_blocked,
29 name: candidate.name,
30 })
31 .collect::<Vec<_>>();
32
33 let plan = import_claude::build_plan(sources, claude, settings, &home, mcp_candidates);
34 if plan.is_empty() {
35 return CommandResult {
36 message: Some(
37 "No Claude configuration found to import (looked for ~/.claude.json and \
38 ~/.claude/settings.json)."
39 .to_string(),
40 ),
41 action: None,
42 is_error: false,
43 };
44 }
45
46 // The plan is shown before anything is written; the only writes are the
47 // report and an *unapplied* bundle file. Applying always goes through a
48 // separate consent path (`/mcp import approve <review-token>`, `config import`).
49 let imports_dir = codewhale_config::codewhale_home()
50 .map(|home| home.join("imports"))
51 .unwrap_or_else(|_| std::path::PathBuf::from(".codewhale/imports"));
52 let report_path = imports_dir.join("claude-import-report.md");
53 let bundle_path = imports_dir.join("claude-portable-bundle.json");
54 let wrote_report = codewhale_config::persistence::atomic_write(
55 &report_path,
56 import_claude::report_markdown(&plan).as_bytes(),
57 )
58 .is_ok();
59 let wrote_bundle = if plan.env_safe.is_empty() {
60 false
61 } else {
62 codewhale_config::persistence::atomic_write(
63 &bundle_path,
64 import_claude::portable_bundle_json(&plan).as_bytes(),
65 )
66 .is_ok()
67 };
68
69 if apply {
70 return apply_plan(&plan, &report_path, wrote_report);
71 }
72
73 let mut message = import_claude::render_plan(&plan);
74 message.push_str(
75 "\n\nNothing above is applied yet. `/import-claude --apply` carries over the \
76 standing instructions. Review MCP servers individually with `/mcp import`; hooks and \
77 permission rules stay manual because they run code.",
78 );
79 if wrote_report {
80 message.push_str(&format!(
81 "\nFull report: {}",
82 crate::utils::display_path(&report_path)
83 ));
84 } else {
85 message.push_str("\n(the report could not be written; the plan above is complete)");
86 }
87 if wrote_bundle {
88 message.push_str(&format!(
89 "\nPortable bundle (review, then `codewhale config import {}`): apply it there for the consent/rollback path.",
90 crate::utils::display_path(&bundle_path)
91 ));
92 }
93 CommandResult {
94 message: Some(message),
95 action: None,
96 is_error: false,
97 }
98 }
99
100 /// Carry over what can be carried over safely, and say exactly what was done.
101 ///
102 /// `--apply` is the consent: the plan is printed first by the bare command, and
103 /// this path never overwrites an existing file, never imports MCP servers,
104 /// and never touches hooks or permission rules — those run code, so
105 /// they stay a human decision.
106 fn apply_plan(
107 plan: &import_claude::ClaudeImportPlan,
108 report_path: &std::path::Path,
109 wrote_report: bool,
110 ) -> CommandResult {
111 let mut done: Vec<String> = Vec::new();
112 let mut manual: Vec<String> = Vec::new();
113
114 // 1. Standing instructions: a plain file copy, and only when the
115 // destination does not already exist. Clobbering the operator's own
116 // instructions would be the one unrecoverable thing here.
117 if plan.has_claude_md {
118 match codewhale_config::codewhale_home() {
119 Ok(home) => {
120 let destination = home.join("instructions.md");
121 let source = crate::config::effective_home_dir()
122 .unwrap_or_else(|| std::path::PathBuf::from("."))
123 .join(".claude")
124 .join("CLAUDE.md");
125 if destination.exists() {
126 manual.push(format!(
127 "{} already exists — left untouched; merge from {} by hand if you want both.",
128 crate::utils::display_path(&destination),
129 crate::utils::display_path(&source)
130 ));
131 } else {
132 match std::fs::read(&source) {
133 Ok(bytes)
134 if codewhale_config::persistence::atomic_write(
135 &destination,
136 &bytes,
137 )
138 .is_ok() =>
139 {
140 done.push(format!(
141 "Standing instructions copied to {}.",
142 crate::utils::display_path(&destination)
143 ));
144 }
145 _ => manual.push(format!(
146 "Could not copy {} — copy it to {} by hand.",
147 crate::utils::display_path(&source),
148 crate::utils::display_path(&destination)
149 )),
150 }
151 }
152 }
153 Err(_) => manual.push(
154 "Could not resolve the Codewhale home, so standing instructions were not copied."
155 .to_string(),
156 ),
157 }
158 }
159
160 // MCP approval must bind the exact bytes and current managed-config
161 // revision shown by the shared preview. Blanket migration cannot grant it.
162 for candidate in &plan.mcp_candidates {
163 manual.push(if candidate.hard_blocked {
164 format!("MCP `{}` is disabled at its source and was not imported.", candidate.name)
165 } else {
166 format!("MCP `{}` needs a separate review: run /mcp import, then use its displayed approval command. It will be imported OFF.", candidate.name)
167 });
168 }
169
170 // 3. What stays human, always.
171 if !plan.hook_events.is_empty() {
172 manual.push(format!(
173 "{} hook event(s) still need mapping with /hooks — hooks run code, so they are never imported for you.",
174 plan.hook_events.len()
175 ));
176 }
177 if !plan.permissions_allow.is_empty()
178 || !plan.permissions_ask.is_empty()
179 || !plan.permissions_deny.is_empty()
180 {
181 manual.push(
182 "Permission rules are listed in the report; apply the ones you want with /permissions."
183 .to_string(),
184 );
185 }
186
187 let mut message = String::new();
188 if done.is_empty() {
189 message.push_str("Nothing was applied.\n");
190 } else {
191 message.push_str("Applied:\n");
192 for line in &done {
193 message.push_str(&format!(" · {line}\n"));
194 }
195 }
196 if !manual.is_empty() {
197 message.push_str("\nStill yours to do:\n");
198 for line in &manual {
199 message.push_str(&format!(" · {line}\n"));
200 }
201 }
202 if !done.is_empty() {
203 message.push_str("\nRun /mcp reload to connect the imported servers after reviewing them.");
204 }
205 if wrote_report {
206 message.push_str(&format!(
207 "\nFull report: {}",
208 crate::utils::display_path(report_path)
209 ));
210 }
211 CommandResult {
212 message: Some(message),
213 action: None,
214 is_error: false,
215 }
216 }
217
218 #[cfg(test)]
219 mod tests {
220 use super::*;
221 use crate::config::Config;
222 use crate::tui::app::TuiOptions;
223 use std::fs;
224 use tempfile::TempDir;
225
226 /// A home with a Claude `CLAUDE.md`, plus a Codewhale home to import into.
227 fn sandbox() -> (TempDir, App) {
228 let temp = TempDir::new().expect("tempdir");
229 let home = temp.path().join("home");
230 let claude = home.join(".claude");
231 fs::create_dir_all(&claude).expect("claude dir");
232 fs::write(claude.join("CLAUDE.md"), b"be excellent\n").expect("CLAUDE.md");
233 fs::write(claude.join("settings.json"), b"{}").expect("settings");
234 fs::write(home.join(".claude.json"), b"{}").expect("claude.json");
235
236 let workspace = temp.path().join("ws");
237 fs::create_dir_all(&workspace).expect("workspace");
238 let options = TuiOptions {
239 config_path: Some(temp.path().join("config.toml")),
240 skills_dir: temp.path().join("skills"),
241 memory_path: temp.path().join("memory.md"),
242 notes_path: temp.path().join("notes.txt"),
243 mcp_config_path: temp.path().join("mcp.json"),
244 ..crate::test_support::test_tui_options(&workspace)
245 };
246 let app = App::new(options, &Config::default());
247 (temp, app)
248 }
249
250 #[test]
251 fn the_bare_command_applies_nothing_and_says_how_to_apply() {
252 let _lock = crate::test_support::lock_test_env();
253 let (temp, mut app) = sandbox();
254 let home = temp.path().join("home");
255 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
256 let _profile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
257 let cw_home = temp.path().join("cw");
258 let _cw = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &cw_home);
259
260 let result = import_claude_command(&mut app, None);
261 let message = result.message.expect("a plan");
262 assert!(
263 message.contains("/import-claude --apply"),
264 "the plan must say how to apply it: {message}"
265 );
266 assert!(
267 !cw_home.join("instructions.md").exists(),
268 "the bare command must not write instructions.md"
269 );
270 }
271
272 #[test]
273 fn apply_carries_the_standing_instructions_and_never_clobbers_them() {
274 let _lock = crate::test_support::lock_test_env();
275 let (temp, mut app) = sandbox();
276 let home = temp.path().join("home");
277 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
278 let _profile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
279 let cw_home = temp.path().join("cw");
280 let _cw = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &cw_home);
281
282 let result = import_claude_command(&mut app, Some("--apply"));
283 let message = result.message.expect("an outcome");
284 let destination = cw_home.join("instructions.md");
285 assert!(destination.exists(), "instructions were copied: {message}");
286 assert_eq!(
287 fs::read_to_string(&destination).expect("read"),
288 "be excellent\n"
289 );
290
291 // Running it again must not overwrite the operator's own file.
292 fs::write(&destination, b"mine now\n").expect("overwrite");
293 let again = import_claude_command(&mut app, Some("--apply"));
294 let message = again.message.expect("an outcome");
295 assert_eq!(
296 fs::read_to_string(&destination).expect("read"),
297 "mine now\n",
298 "an existing instructions.md is never clobbered: {message}"
299 );
300 assert!(
301 message.contains("already exists"),
302 "and the reason is said out loud: {message}"
303 );
304 }
305 }
306
306 lines RUST