返回 CodeWhale
import_claude.rs
根目录 / crates / tui / src / import_claude.rs
1 //! `/import-claude`: explicit, reviewable migration from Claude Code (#5557).
2 //!
3 //! Reads `~/.claude.json` and `~/.claude/settings.json` (bounded, read-only)
4 //! and builds a plan — never a silent import:
5 //!
6 //! - **MCP servers** are surfaced through the existing external-import consent
7 //! flow (`/mcp import <name> --approve`); nothing merges automatically.
8 //! - **Env vars** that are known-safe (timeouts, output caps, editor choices)
9 //! are proposed as a portable bundle file for `codewhale config import`,
10 //! which carries its own plan/consent/rollback. Secret-shaped or unknown
11 //! keys are named but never imported or echoed.
12 //! - **Permissions** become approval-policy recommendations in a written
13 //! report (Claude rule grammar does not map 1:1; nothing is auto-applied).
14 //! - **Hooks** and **paths** (e.g. `~/.claude/CLAUDE.md`) are listed as manual
15 //! follow-ups with exact target commands.
16 //!
17 //! The command writes a report and an *unapplied* bundle file. Applying any
18 //! part always requires its own explicit consent path.
19
20 use std::collections::BTreeMap;
21 use std::path::{Path, PathBuf};
22
23 use serde_json::Value;
24
25 /// Maximum size accepted for any single Claude source file. Generous for
26 /// real `~/.claude.json` files with many projects; bounded so a pathological
27 /// file cannot exhaust memory. Matches the bundle reader's order of magnitude.
28 pub(crate) const MAX_SOURCE_BYTES: u64 = 5 * 1024 * 1024;
29
30 /// Env keys safe to propose for the portable bundle (lowercase). Deliberately
31 /// a short allowlist: timeouts, output caps, and editor/telemetry choices.
32 /// Anything not listed stays out of the bundle and is reported by name only.
33 const SAFE_ENV_KEYS: &[&str] = &[
34 "bash_default_timeout_ms",
35 "bash_max_timeout_ms",
36 "bash_max_output_length",
37 "bash_mcp_timeout_ms",
38 "mcp_timeout_ms",
39 "mcp_tools_timeout_ms",
40 "use_builtin_ripgrep",
41 "editor",
42 "max_thinking_tokens",
43 "disable_cost_warnings",
44 "disable_nonessential_model_calls",
45 "disable_telemetry",
46 ];
47
48 /// Longest env value accepted into the bundle; guards against blob-shaped
49 /// values a migration should not carry.
50 const MAX_ENV_VALUE_LEN: usize = 200;
51
52 /// Status of one Claude source file.
53 #[derive(Debug, Clone, PartialEq, Eq)]
54 pub(crate) enum SourceStatus {
55 Found,
56 NotFound,
57 Unreadable(String),
58 Oversize(u64),
59 InvalidJson,
60 }
61
62 impl SourceStatus {
63 fn describe(&self, path: &Path) -> String {
64 match self {
65 Self::Found => format!("found: {}", crate::utils::display_path(path)),
66 Self::NotFound => format!("not found: {}", crate::utils::display_path(path)),
67 Self::Unreadable(error) => {
68 format!("unreadable: {} ({error})", crate::utils::display_path(path))
69 }
70 Self::Oversize(bytes) => format!(
71 "skipped, too large ({} bytes): {}",
72 bytes,
73 crate::utils::display_path(path)
74 ),
75 Self::InvalidJson => {
76 format!("invalid JSON: {}", crate::utils::display_path(path))
77 }
78 }
79 }
80 }
81
82 /// One parsed MCP candidate (name + one-line summary), already filtered to the
83 /// Claude sources by the caller.
84 #[derive(Debug, Clone, PartialEq, Eq)]
85 pub(crate) struct McpCandidateLine {
86 pub(crate) name: String,
87 pub(crate) summary: String,
88 pub(crate) hard_blocked: bool,
89 }
90
91 /// The full `/import-claude` plan. Pure data: building it mutates nothing.
92 #[derive(Debug, Clone, Default)]
93 pub(crate) struct ClaudeImportPlan {
94 pub(crate) sources: Vec<(PathBuf, SourceStatus)>,
95 /// MCP servers from `~/.claude.json` (top level), for `/mcp import`.
96 pub(crate) mcp_candidates: Vec<McpCandidateLine>,
97 /// Names of MCP servers found under `projects.<path>.mcpServers`, listed
98 /// for provenance only in v1.
99 pub(crate) per_project_mcp: Vec<String>,
100 /// Safe env entries proposed for the portable bundle.
101 pub(crate) env_safe: BTreeMap<String, String>,
102 /// Env keys skipped because they are secret-shaped or not allowlisted.
103 pub(crate) env_skipped: Vec<String>,
104 /// Claude `permissions.defaultMode`, when present.
105 pub(crate) permissions_default_mode: Option<String>,
106 pub(crate) permissions_allow: Vec<String>,
107 pub(crate) permissions_ask: Vec<String>,
108 pub(crate) permissions_deny: Vec<String>,
109 /// Hook event names with counts, e.g. `PreToolUse (2)`.
110 pub(crate) hook_events: Vec<String>,
111 /// True when `~/.claude/CLAUDE.md` exists.
112 pub(crate) has_claude_md: bool,
113 /// Project workspace paths recorded in `~/.claude.json` (bounded).
114 pub(crate) claude_projects: Vec<String>,
115 }
116
117 impl ClaudeImportPlan {
118 pub(crate) fn is_empty(&self) -> bool {
119 self.mcp_candidates.is_empty()
120 && self.per_project_mcp.is_empty()
121 && self.env_safe.is_empty()
122 && self.env_skipped.is_empty()
123 && self.permissions_default_mode.is_none()
124 && self.permissions_allow.is_empty()
125 && self.permissions_ask.is_empty()
126 && self.permissions_deny.is_empty()
127 && self.hook_events.is_empty()
128 && !self.has_claude_md
129 && self.claude_projects.is_empty()
130 }
131 }
132
133 fn read_bounded_json(path: &Path) -> Result<Value, SourceStatus> {
134 let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
135 std::io::ErrorKind::NotFound => SourceStatus::NotFound,
136 _ => SourceStatus::Unreadable(e.to_string()),
137 })?;
138 if !meta.is_file() {
139 return Err(SourceStatus::NotFound);
140 }
141 if meta.len() > MAX_SOURCE_BYTES {
142 return Err(SourceStatus::Oversize(meta.len()));
143 }
144 let raw = std::fs::read(path).map_err(|e| SourceStatus::Unreadable(e.to_string()))?;
145 serde_json::from_slice::<Value>(&raw).map_err(|_| SourceStatus::InvalidJson)
146 }
147
148 /// Read the Claude sources under `home` (usually `~`).
149 pub(crate) fn read_sources(
150 home: &Path,
151 ) -> (Vec<(PathBuf, SourceStatus)>, Option<Value>, Option<Value>) {
152 let claude_json = home.join(".claude.json");
153 let settings = home.join(".claude").join("settings.json");
154 let mut sources = Vec::new();
155 let mut claude_value = None;
156 let mut settings_value = None;
157 match read_bounded_json(&claude_json) {
158 Ok(value) => {
159 sources.push((claude_json.clone(), SourceStatus::Found));
160 claude_value = Some(value);
161 }
162 Err(status) => sources.push((claude_json, status)),
163 }
164 match read_bounded_json(&settings) {
165 Ok(value) => {
166 sources.push((settings.clone(), SourceStatus::Found));
167 settings_value = Some(value);
168 }
169 Err(status) => sources.push((settings, status)),
170 }
171 (sources, claude_value, settings_value)
172 }
173
174 fn safe_env_value(value: &Value) -> Option<String> {
175 let text = match value {
176 Value::String(text) => {
177 (text.len() <= MAX_ENV_VALUE_LEN && !text.contains('\n')).then(|| text.clone())
178 }
179 Value::Number(number) => Some(number.to_string()),
180 Value::Bool(flag) => Some(flag.to_string()),
181 _ => None,
182 }?;
183 (codewhale_config::persistence::redact_secrets(&text) == text).then_some(text)
184 }
185
186 /// Build the plan from already-read sources plus MCP candidates discovered by
187 /// the existing external-import machinery (so server parsing stays single-sourced).
188 pub(crate) fn build_plan(
189 sources: Vec<(PathBuf, SourceStatus)>,
190 claude: Option<Value>,
191 settings: Option<Value>,
192 home: &Path,
193 mcp_candidates: Vec<McpCandidateLine>,
194 ) -> ClaudeImportPlan {
195 let mut plan = ClaudeImportPlan {
196 sources,
197 mcp_candidates,
198 ..ClaudeImportPlan::default()
199 };
200 if let Some(claude) = claude.as_ref()
201 && let Some(projects) = claude.get("projects").and_then(Value::as_object)
202 {
203 for (path, project) in projects {
204 if let Some(servers) = project.get("mcpServers").and_then(Value::as_object) {
205 for name in servers.keys() {
206 plan.per_project_mcp
207 .push(format!("{name} (project {path})"));
208 }
209 }
210 if plan.claude_projects.len() < 8 {
211 plan.claude_projects.push(path.clone());
212 }
213 }
214 }
215 if let Some(settings) = settings.as_ref() {
216 if let Some(env) = settings.get("env").and_then(Value::as_object) {
217 for (key, value) in env {
218 if SAFE_ENV_KEYS.contains(&key.to_ascii_lowercase().as_str()) {
219 match safe_env_value(value) {
220 Some(text) => {
221 plan.env_safe.insert(key.clone(), text);
222 }
223 None => plan.env_skipped.push(format!("{key} (value not portable)")),
224 }
225 } else {
226 plan.env_skipped.push(key.clone());
227 }
228 }
229 }
230 if let Some(permissions) = settings.get("permissions").and_then(Value::as_object) {
231 plan.permissions_default_mode = permissions
232 .get("defaultMode")
233 .and_then(Value::as_str)
234 .map(str::to_string);
235 for (key, target) in [
236 ("allow", &mut plan.permissions_allow),
237 ("ask", &mut plan.permissions_ask),
238 ("deny", &mut plan.permissions_deny),
239 ] {
240 if let Some(rules) = permissions.get(key).and_then(Value::as_array) {
241 for rule in rules {
242 if let Some(text) = rule.as_str() {
243 target.push(codewhale_config::persistence::redact_secrets(text));
244 }
245 }
246 }
247 }
248 }
249 if let Some(hooks) = settings.get("hooks").and_then(Value::as_object) {
250 for (event, entries) in hooks {
251 let count = entries.as_array().map_or(1, Vec::len);
252 plan.hook_events.push(format!("{event} ({count})"));
253 }
254 }
255 }
256 plan.has_claude_md = home.join(".claude").join("CLAUDE.md").is_file();
257 plan
258 }
259
260 /// Claude `permissions.defaultMode` → the Codewhale approval-posture
261 /// recommendation (report text only; nothing is applied).
262 pub(crate) fn approval_posture_recommendation(mode: Option<&str>) -> &'static str {
263 match mode {
264 Some("acceptEdits") => "auto-review posture (/permissions) matches acceptEdits",
265 Some("plan") => "plan mode matches Claude's plan default",
266 Some("bypassPermissions") => {
267 "Full Access (yolo) matches bypassPermissions — confirm you want it"
268 }
269 _ => "the default Ask posture matches Claude's default",
270 }
271 }
272
273 /// One-line migration guidance for a Claude permission rule.
274 pub(crate) fn permission_rule_guidance(rule: &str) -> String {
275 let rule = rule.trim();
276 if rule.starts_with("Bash(") {
277 format!("{rule} → an execpolicy rule for that command family (/permissions)")
278 } else if rule.starts_with("WebFetch(domain:") || rule.starts_with("WebSearch(") {
279 format!("{rule} → a fetch/network rule for that host or surface")
280 } else if rule.starts_with("mcp__") {
281 format!("{rule} → enable that MCP tool in your mcp.json enabled_tools")
282 } else if rule.starts_with("Read(") || rule.starts_with("Edit(") || rule.starts_with("Write(") {
283 format!("{rule} → a path-scoped ask rule (reads stay workspace-bounded by default)")
284 } else {
285 format!("{rule} → review against /permissions before mapping")
286 }
287 }
288
289 /// The unapplied portable bundle document for the safe env entries. JSON is a
290 /// first-class bundle format (`config import` accepts `.json`), so the
291 /// generated file goes through the same strict parser as any hand-written one.
292 pub(crate) fn portable_bundle_json(plan: &ClaudeImportPlan) -> String {
293 let global: BTreeMap<&str, &str> = plan
294 .env_safe
295 .iter()
296 .map(|(k, v)| (k.as_str(), v.as_str()))
297 .collect();
298 serde_json::json!({
299 "schema_version": 1,
300 "kind": "codewhale.portable-config",
301 "metadata": {
302 "name": "claude-import",
303 "generator": "codewhale /import-claude",
304 },
305 "global": global,
306 })
307 .to_string()
308 }
309
310 /// Render the reviewable plan shown in the transcript.
311 pub(crate) fn render_plan(plan: &ClaudeImportPlan) -> String {
312 use std::fmt::Write as _;
313
314 let mut out = String::new();
315 out.push_str("Claude import plan (nothing is applied without its own consent):\n");
316 for (path, status) in &plan.sources {
317 let _ = writeln!(out, " · {}", status.describe(path));
318 }
319 if !plan.mcp_candidates.is_empty() {
320 out.push_str("\nMCP servers (apply each with `/mcp import <name> --approve`):\n");
321 for candidate in &plan.mcp_candidates {
322 let blocked = if candidate.hard_blocked {
323 " [blocked]"
324 } else {
325 ""
326 };
327 let _ = writeln!(
328 out,
329 " · {}{blocked} — {}",
330 candidate.name, candidate.summary
331 );
332 }
333 }
334 if !plan.per_project_mcp.is_empty() {
335 out.push_str("\nProject-scoped MCP servers (listed for provenance only):\n");
336 for name in plan.per_project_mcp.iter().take(8) {
337 let _ = writeln!(out, " · {name}");
338 }
339 }
340 if !plan.env_safe.is_empty() {
341 out.push_str("\nSafe env settings proposed for the portable bundle:\n");
342 for key in plan.env_safe.keys() {
343 let _ = writeln!(out, " · {key}");
344 }
345 }
346 if !plan.env_skipped.is_empty() {
347 out.push_str("\nEnv keys not imported (secret-shaped or unmapped; values never shown):\n");
348 for key in plan.env_skipped.iter().take(12) {
349 let _ = writeln!(out, " · {key}");
350 }
351 }
352 let _ = writeln!(
353 out,
354 "\nApproval posture: {}.",
355 approval_posture_recommendation(plan.permissions_default_mode.as_deref())
356 );
357 for (label, rules) in [
358 ("Allow", &plan.permissions_allow),
359 ("Ask", &plan.permissions_ask),
360 ("Deny", &plan.permissions_deny),
361 ] {
362 if !rules.is_empty() {
363 let _ = writeln!(out, "\n{label} rules (map manually; not auto-applied):");
364 for rule in rules.iter().take(8) {
365 let _ = writeln!(out, " · {}", permission_rule_guidance(rule));
366 }
367 }
368 }
369 if !plan.hook_events.is_empty() {
370 out.push_str("\nHook events (map manually with /hooks):\n");
371 for event in &plan.hook_events {
372 let _ = writeln!(out, " · {event}");
373 }
374 }
375 if plan.has_claude_md {
376 out.push_str("\nStanding instructions: ~/.claude/CLAUDE.md found — copy it to ~/.codewhale/instructions.md (or the repo's AGENTS.md) to carry it over.\n");
377 }
378 out
379 }
380
381 /// The written follow-through report (markdown).
382 pub(crate) fn report_markdown(plan: &ClaudeImportPlan) -> String {
383 let mut out = String::from(
384 "# Claude import report\n\nGenerated by `/import-claude`. Nothing was applied automatically.\n\n",
385 );
386 out.push_str(&render_plan(plan));
387 if !plan.env_safe.is_empty() {
388 out.push_str("\n## Apply the portable bundle\n\nReview the generated bundle, then run:\n\n codewhale config import <bundle path>\n\n");
389 }
390 out.push_str("\n## Manual follow-ups\n\n- MCP: `/mcp import <name> --approve` per server (consent is recorded).\n- Permissions: map rules in /permissions and execpolicy.\n- Hooks: re-create matching hooks with /hooks.\n- Paths: copy CLAUDE.md if you want those standing instructions.\n");
391 out
392 }
393
394 #[cfg(test)]
395 mod tests {
396 use super::*;
397 use serde_json::json;
398
399 fn temp_home(tag: &str) -> (tempfile::TempDir, PathBuf) {
400 let dir = tempfile::tempdir().expect("home");
401 let _ = tag;
402 let home = dir.path().to_path_buf();
403 (dir, home)
404 }
405
406 #[test]
407 fn missing_sources_report_not_found_and_plan_is_empty() {
408 let (dir, home) = temp_home("missing");
409 let (sources, claude, settings) = read_sources(&home);
410 let plan = build_plan(sources, claude, settings, &home, Vec::new());
411 assert!(plan.is_empty());
412 assert!(
413 plan.sources
414 .iter()
415 .any(|(_, s)| *s == SourceStatus::NotFound)
416 );
417 assert!(dir.path().exists());
418 }
419
420 #[test]
421 fn settings_env_splits_safe_and_skipped_without_ever_inlining_secret_values() {
422 let (_dir, home) = temp_home("env");
423 std::fs::create_dir_all(home.join(".claude")).expect("dir");
424 std::fs::write(
425 home.join(".claude").join("settings.json"),
426 json!({
427 "env": {
428 "BASH_DEFAULT_TIMEOUT_MS": "120000",
429 "USE_BUILTIN_RIPGREP": "1",
430 "EDITOR": "sk-editor-secret-value",
431 "ANTHROPIC_API_KEY": "sk-ant-secret-value",
432 "SOMETHING_ELSE": "x"
433 },
434 "permissions": {
435 "defaultMode": "acceptEdits",
436 "allow": ["Bash(git status:*)", "WebFetch(domain:github.com)", "mcp__demo__kick", "Bash(curl -H token=sk-rule-secret-value)"] ,
437 "deny": ["Read(./.env)"]
438 },
439 "hooks": {
440 "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo hi"}]}],
441 "PostToolUse": []
442 }
443 })
444 .to_string(),
445 )
446 .expect("settings");
447
448 let (sources, _claude, settings) = read_sources(&home);
449 let plan = build_plan(sources, None, settings, &home, Vec::new());
450 assert_eq!(
451 plan.env_safe
452 .get("BASH_DEFAULT_TIMEOUT_MS")
453 .map(String::as_str),
454 Some("120000")
455 );
456 assert!(plan.env_safe.contains_key("USE_BUILTIN_RIPGREP"));
457 assert!(!plan.env_safe.contains_key("EDITOR"));
458 assert!(!plan.env_safe.contains_key("ANTHROPIC_API_KEY"));
459 assert!(
460 plan.env_skipped
461 .iter()
462 .any(|k| k.contains("ANTHROPIC_API_KEY"))
463 );
464 assert_eq!(
465 plan.permissions_default_mode.as_deref(),
466 Some("acceptEdits")
467 );
468 assert_eq!(plan.permissions_allow.len(), 4);
469 assert!(
470 plan.permissions_allow
471 .iter()
472 .any(|rule| rule.contains("[redacted]"))
473 );
474 assert_eq!(plan.permissions_deny.len(), 1);
475 assert_eq!(plan.hook_events.len(), 2);
476
477 let rendered = render_plan(&plan);
478 assert!(!rendered.contains("sk-ant-secret-value"), "{rendered}");
479 assert!(!rendered.contains("sk-editor-secret-value"), "{rendered}");
480 assert!(!rendered.contains("sk-rule-secret-value"), "{rendered}");
481 assert!(rendered.contains("[redacted]"), "{rendered}");
482 assert!(rendered.contains("Bash(git status:*) → an execpolicy rule"));
483 assert!(rendered.contains("acceptEdits"));
484
485 let bundle = portable_bundle_json(&plan);
486 assert!(!bundle.contains("sk-ant-secret-value"), "{bundle}");
487 assert!(!bundle.contains("sk-editor-secret-value"), "{bundle}");
488 assert!(bundle.contains("codewhale.portable-config"));
489 assert!(bundle.contains("BASH_DEFAULT_TIMEOUT_MS"));
490 }
491
492 #[test]
493 fn claude_json_surfaces_projects_and_per_project_servers() {
494 let (dir, home) = temp_home("projects");
495 std::fs::write(
496 home.join(".claude.json"),
497 json!({
498 "mcpServers": {
499 "top": {"command": "node", "args": ["top.js"]}
500 },
501 "projects": {
502 "/tmp/proj-a": {"mcpServers": {"inner": {"command": "node"}}}
503 }
504 })
505 .to_string(),
506 )
507 .expect("claude.json");
508
509 let (sources, claude, _settings) = read_sources(&home);
510 let candidates = vec![McpCandidateLine {
511 name: "top".to_string(),
512 summary: "stdio server (node)".to_string(),
513 hard_blocked: false,
514 }];
515 let plan = build_plan(sources, claude, None, &home, candidates);
516 assert_eq!(plan.mcp_candidates.len(), 1);
517 assert_eq!(plan.per_project_mcp.len(), 1);
518 assert!(plan.per_project_mcp[0].contains("inner"));
519 assert!(plan.claude_projects.iter().any(|p| p.contains("proj-a")));
520 assert!(dir.path().exists());
521 }
522
523 #[test]
524 fn oversize_and_invalid_sources_fail_closed_with_named_status() {
525 let (_dir, home) = temp_home("bad");
526 std::fs::write(home.join(".claude.json"), "not json").expect("bad json");
527 let (sources, claude, _settings) = read_sources(&home);
528 assert!(claude.is_none());
529 assert!(
530 sources
531 .iter()
532 .any(|(_, s)| matches!(s, SourceStatus::InvalidJson))
533 );
534 }
535
536 #[test]
537 fn posture_recommendations_cover_the_four_claude_modes() {
538 assert!(approval_posture_recommendation(Some("acceptEdits")).contains("auto-review"));
539 assert!(approval_posture_recommendation(Some("plan")).contains("plan"));
540 assert!(approval_posture_recommendation(Some("bypassPermissions")).contains("Full Access"));
541 assert!(approval_posture_recommendation(None).contains("Ask"));
542 }
543 }
544
544 lines RUST