返回 CodeWhale
cli.rs
根目录 / crates / tui / src / integrations / cli.rs
1 //! `codewhale integrations …` command surface (dispatched from the outer
2 //! `codewhale` binary as a passthrough).
3
4 use std::io::IsTerminal;
5 use std::path::Path;
6
7 use anyhow::{Context, Result};
8
9 use crate::config::Config;
10 use crate::integrations::dsh::{
11 self, CLI_COMMAND, DetectEnv, DshAdapter, DshIntegrationState, DshPaths, DshPlan,
12 DshReceiptEvent, DshStatusReport, ProcessRunner, RELATIONSHIP_LABEL,
13 };
14 use crate::{DshIntegrationCommand, IntegrationsCommand};
15
16 pub(crate) fn run(config: &Config, workspace: &Path, command: IntegrationsCommand) -> Result<()> {
17 match command {
18 IntegrationsCommand::Dsh { command } => run_dsh(config, workspace, command),
19 }
20 }
21
22 fn detect_now() -> dsh::DshDetection {
23 dsh::detect::detect(&DetectEnv::from_process(), &ProcessRunner)
24 }
25
26 fn status_report(
27 config: &Config,
28 workspace: &Path,
29 allow_full_access: bool,
30 ) -> Result<(DshPaths, DshStatusReport)> {
31 let paths = DshPaths::from_process()?;
32 let detection = detect_now();
33 let identity = dsh::codewhale_route_identity(config, workspace);
34 let report = dsh::compute_status(
35 &paths,
36 detection,
37 identity,
38 allow_full_access,
39 dsh::bundle_availability_now(),
40 )?;
41 Ok((paths, report))
42 }
43
44 fn confirm(yes: bool, question: &str) -> Result<()> {
45 if yes {
46 return Ok(());
47 }
48 if !std::io::stdin().is_terminal() {
49 anyhow::bail!("stdin is not a terminal; pass --yes to confirm the disclosed plan");
50 }
51 print!("{question} [y/N] ");
52 use std::io::Write;
53 std::io::stdout().flush().ok();
54 let mut answer = String::new();
55 std::io::stdin()
56 .read_line(&mut answer)
57 .context("read confirmation")?;
58 if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
59 anyhow::bail!("not confirmed; nothing was written");
60 }
61 Ok(())
62 }
63
64 fn print_status(report: &DshStatusReport) {
65 println!("DeepSeek Harness (dsh) — {}", RELATIONSHIP_LABEL);
66 println!(" state: {}", report.state.label());
67 println!(" summary: {}", dsh::status_line(report));
68 match report.detection.binary.as_ref() {
69 Some(bin) => println!(" dsh binary: {}", bin.display()),
70 None => println!(" dsh binary: not on PATH"),
71 }
72 println!(
73 " dsh version: {} ({})",
74 report.detection.version.as_deref().unwrap_or("unknown"),
75 report.detection.compatibility.label()
76 );
77 println!(
78 " DSH_HOME: {}{}{}",
79 report.detection.dsh_home.display(),
80 if report.detection.dsh_home_from_env {
81 " (from env)"
82 } else {
83 ""
84 },
85 if report.detection.dsh_home_exists {
86 ""
87 } else {
88 " (absent)"
89 }
90 );
91 if !report.detection.profiles.is_empty() {
92 println!(" dsh profiles: {}", report.detection.profiles.join(", "));
93 }
94 println!(
95 " dsh credentials file: {}{}",
96 if report.detection.credentials_present {
97 "present"
98 } else {
99 "absent"
100 },
101 match report.detection.credentials_mode_ok {
102 Some(true) => " (0600)",
103 Some(false) => " (mode is not 0600)",
104 None => "",
105 }
106 );
107 if !report.shadowing_namespaces.is_empty() {
108 println!(
109 " dsh settings.yaml sections that can shadow the overlay: {}",
110 report.shadowing_namespaces.join(", ")
111 );
112 }
113 println!(
114 " dsh plugin path (bundle): {}",
115 report.bundle_availability.label()
116 );
117 println!(" Codewhale-owned files: {}", report.paths_root.display());
118 println!(
119 " overlay: {}{}",
120 report.overlay_path.display(),
121 if report.overlay_present {
122 ""
123 } else {
124 " (absent)"
125 }
126 );
127 if let Some(record) = report.record.as_ref() {
128 println!(
129 " connected: {} · profile {} · {}/{} via {} · permission {}{}",
130 record.connected_at,
131 record.profile,
132 record.identity.source.provider_id,
133 record.identity.source.model,
134 record.identity.dsh_provider().unwrap_or("unsupported"),
135 record.identity.permission_mode.as_str(),
136 if record.disabled { " · DISABLED" } else { "" }
137 );
138 match record.bundle.as_ref() {
139 Some(bundle) => println!(
140 " bundle: installed in DSH profile `{}` ({}) · {} {} · app {} · patch sha256 {}{}",
141 bundle.profile,
142 bundle.profile_dir.display(),
143 bundle.package_name,
144 bundle.package_version,
145 bundle.app_bundle.package_name(),
146 bundle.patch_sha256,
147 report
148 .bundle_profile_bundles
149 .as_ref()
150 .map(|list| format!(" · profile bundles [{}]", list.join(", ")))
151 .unwrap_or_else(|| " · profile manifest unreadable".to_string())
152 ),
153 None => println!(
154 " bundle: not installed · plugin path {} · `{CLI_COMMAND} install-bundle`",
155 report.bundle_availability.label()
156 ),
157 }
158 if record.skin_enabled {
159 println!(
160 " skin: on (Codewhale palette via overrideTokens in the bundle profile; `{CLI_COMMAND} update --skin false` turns it off)"
161 );
162 if record.ocean_enabled {
163 println!(
164 " ocean: on (ambient whales + glyph fish canvas behind the DSH web UI; `{CLI_COMMAND} update --ocean false` turns it off; in the browser localStorage[\"{}\"] = \"off\" or window.{}.stop())",
165 dsh::scene::OCEAN_STORAGE_KEY,
166 dsh::scene::OCEAN_WINDOW_HANDLE
167 );
168 } else {
169 println!(" ocean: off (`{CLI_COMMAND} update --ocean true` turns it on)");
170 }
171 }
172 }
173 match (&report.current_identity, &report.current_identity_error) {
174 (Some(now), _) if now.mappable() => println!(
175 " current Codewhale route: {}/{} · {} · would map via {}",
176 now.source.provider_id,
177 now.source.model,
178 now.source.base_url,
179 now.dsh_provider().unwrap_or("(not mappable)")
180 ),
181 (Some(now), _) => {
182 if let DshAdapter::Unsupported { reason } = &now.adapter {
183 println!(
184 " current Codewhale route: {}/{} · {} · cannot be carried by DSH: {reason}",
185 now.source.provider_id, now.source.model, now.source.base_url
186 );
187 }
188 }
189 (None, Some(error)) => println!(" current Codewhale route: unresolved ({error})"),
190 (None, None) => {}
191 }
192 match &report.state {
193 DshIntegrationState::Detected { .. } => {
194 println!(" next: `{CLI_COMMAND} plan` then `{CLI_COMMAND} connect`")
195 }
196 DshIntegrationState::StaleConfig { .. } => println!(" next: `{CLI_COMMAND} update`"),
197 DshIntegrationState::Disabled { .. } => println!(" next: `{CLI_COMMAND} enable`"),
198 DshIntegrationState::Connected { .. } | DshIntegrationState::StaleVersion { .. } => {
199 println!(
200 " next: `{CLI_COMMAND} launch [--profile web|headless|codewhale] [dsh app args]`"
201 )
202 }
203 _ => {}
204 }
205 }
206
207 fn print_plan(plan: &DshPlan, event: &str) {
208 println!("{RELATIONSHIP_LABEL} — {event} plan (nothing written yet)");
209 println!(
210 " identity: {}/{} → DSH provider {} · endpoint {}",
211 plan.mapped.source.provider_id,
212 plan.mapped.source.model,
213 plan.mapped.dsh_provider().unwrap_or("unsupported"),
214 plan.mapped.source.base_url
215 );
216 println!(
217 " reasoning: codewhale={} → dsh={}",
218 plan.mapped
219 .source
220 .reasoning_effort
221 .as_deref()
222 .unwrap_or("inherit"),
223 plan.mapped
224 .dsh_reasoning_effort
225 .as_deref()
226 .unwrap_or("(default)")
227 );
228 println!(
229 " permission: DSH_PERMISSION_MODE={}",
230 plan.mapped.permission_mode.as_str()
231 );
232 println!(" workspace: {}", plan.mapped.source.workspace);
233 println!(" will write: {}", plan.overlay_path.display());
234 println!(" will write: {}", plan.receipt_path.display());
235 if let Some(skin) = plan.skin_path.as_ref() {
236 println!(" will write: {} (+ preview html)", skin.display());
237 }
238 println!(
239 " will NOT write: anything under $DSH_HOME, any API key, OAuth file, or file contents"
240 );
241 println!(" overlay sha256: {}", plan.overlay_sha256);
242 println!(" launch: {}", plan.launch_command);
243 for line in &plan.disclosures {
244 println!(" note: {line}");
245 }
246 println!(" overlay preview:");
247 for line in plan.overlay_text.lines() {
248 println!(" {line}");
249 }
250 }
251
252 fn run_dsh(config: &Config, workspace: &Path, command: DshIntegrationCommand) -> Result<()> {
253 match command {
254 DshIntegrationCommand::Status { json } => {
255 let (_paths, report) = status_report(config, workspace, false)?;
256 if json {
257 println!("{}", serde_json::to_string_pretty(&report)?);
258 } else {
259 print_status(&report);
260 }
261 Ok(())
262 }
263 DshIntegrationCommand::Plan {
264 json,
265 profile,
266 allow_full_access,
267 skin,
268 } => {
269 let (paths, report) = status_report(config, workspace, allow_full_access)?;
270 let identity = dsh::codewhale_route_identity(config, workspace)
271 .map_err(|e| anyhow::anyhow!("cannot resolve the current Codewhale route: {e}"))?;
272 let plan = dsh::plan(
273 &paths,
274 &report.detection,
275 &identity,
276 &profile,
277 allow_full_access,
278 skin,
279 true,
280 )?;
281 if json {
282 println!("{}", serde_json::to_string_pretty(&plan)?);
283 } else {
284 print_plan(&plan, "connect");
285 if !report.detection.installed() {
286 println!(
287 " warning: dsh is not on PATH; the plan is valid but launch will fail"
288 );
289 }
290 }
291 Ok(())
292 }
293 DshIntegrationCommand::Connect {
294 profile,
295 allow_full_access,
296 skin,
297 yes,
298 } => {
299 let (paths, report) = status_report(config, workspace, allow_full_access)?;
300 ensure_launchable_dsh(&report)?;
301 if report.record.as_ref().is_some_and(|r| !r.disabled) {
302 anyhow::bail!(
303 "DSH is already connected; use `{CLI_COMMAND} update` to rewrite the overlay"
304 );
305 }
306 let identity = dsh::codewhale_route_identity(config, workspace)
307 .map_err(|e| anyhow::anyhow!("cannot resolve the current Codewhale route: {e}"))?;
308 let plan = dsh::plan(
309 &paths,
310 &report.detection,
311 &identity,
312 &profile,
313 allow_full_access,
314 skin,
315 true,
316 )?;
317 print_plan(&plan, "connect");
318 confirm(yes, "Write these Codewhale-owned files and connect DSH?")?;
319 let record =
320 dsh::apply_plan(&paths, &report.detection, &plan, DshReceiptEvent::Connect)?;
321 println!(
322 "connected: {} (sha256 {})",
323 record.overlay_path.display(),
324 record.overlay_sha256
325 );
326 println!("receipt: {}", paths.receipt.display());
327 Ok(())
328 }
329 DshIntegrationCommand::Update {
330 profile,
331 allow_full_access,
332 skin,
333 ocean,
334 yes,
335 } => {
336 let (paths, report) = status_report(config, workspace, allow_full_access)?;
337 ensure_launchable_dsh(&report)?;
338 let record = report.record.as_ref().ok_or_else(|| {
339 anyhow::anyhow!("DSH is not connected; run `{CLI_COMMAND} connect`")
340 })?;
341 let profile = profile.unwrap_or_else(|| record.profile.clone());
342 let skin = skin.unwrap_or(record.skin_enabled);
343 let ocean = ocean.unwrap_or(record.ocean_enabled);
344 let identity = dsh::codewhale_route_identity(config, workspace)
345 .map_err(|e| anyhow::anyhow!("cannot resolve the current Codewhale route: {e}"))?;
346 let plan = dsh::plan(
347 &paths,
348 &report.detection,
349 &identity,
350 &profile,
351 allow_full_access,
352 skin,
353 ocean,
354 )?;
355 print_plan(&plan, "update");
356 confirm(yes, "Rewrite the Codewhale overlay for DSH?")?;
357 let record =
358 dsh::apply_plan(&paths, &report.detection, &plan, DshReceiptEvent::Update)?;
359 println!(
360 "updated: {} (sha256 {})",
361 record.overlay_path.display(),
362 record.overlay_sha256
363 );
364 Ok(())
365 }
366 DshIntegrationCommand::Launch {
367 profile,
368 dry_run,
369 args,
370 } => {
371 let (_paths, report) = status_report(config, workspace, false)?;
372 let spec = dsh::launch_spec(&report, profile.as_deref(), &args, workspace)?;
373 println!("{RELATIONSHIP_LABEL}: {}", spec.display());
374 if dry_run {
375 return Ok(());
376 }
377 let code = dsh::spawn_launch(&spec)?;
378 if code != 0 {
379 anyhow::bail!("dsh exited with status {code}");
380 }
381 Ok(())
382 }
383 DshIntegrationCommand::Disable => {
384 let paths = DshPaths::from_process()?;
385 let record = dsh::set_disabled(&paths, true)?;
386 println!(
387 "disabled: overlay kept at {}; launches refused",
388 record.overlay_path.display()
389 );
390 Ok(())
391 }
392 DshIntegrationCommand::Enable => {
393 let paths = DshPaths::from_process()?;
394 let record = dsh::set_disabled(&paths, false)?;
395 println!("enabled: {}", record.overlay_path.display());
396 Ok(())
397 }
398 DshIntegrationCommand::InstallBundle { app, yes } => {
399 let app = dsh::DshAppBundle::parse(&app)
400 .ok_or_else(|| anyhow::anyhow!("--app must be `web` or `headless`, got `{app}`"))?;
401 let (paths, report) = status_report(config, workspace, false)?;
402 ensure_launchable_dsh(&report)?;
403 let record = report.record.as_ref().ok_or_else(|| {
404 anyhow::anyhow!("DSH is not connected; run `{CLI_COMMAND} connect` first")
405 })?;
406 if let dsh::BundleAvailability::NotAvailable { reason } = &report.bundle_availability {
407 anyhow::bail!("DSH plugin path not available: {reason}");
408 }
409 if matches!(report.state, DshIntegrationState::StaleConfig { .. }) {
410 anyhow::bail!("overlay is stale; run `{CLI_COMMAND} update` before install-bundle");
411 }
412 let app_source = dsh::bundle::app_bundle_source(
413 report
414 .detection
415 .binary
416 .as_ref()
417 .ok_or_else(|| anyhow::anyhow!("dsh binary path is unknown"))?,
418 app,
419 )?;
420 let profile_dir = report
421 .detection
422 .dsh_home
423 .join("profiles")
424 .join(dsh::bundle::BUNDLE_PROFILE);
425 println!("{RELATIONSHIP_LABEL} — install-bundle plan (nothing written yet)");
426 println!(
427 " will write (Codewhale-owned): {}/{{package.json,cordis.patch.yml,README.md,NOTICE.md}}",
428 paths.bundle_dir.display()
429 );
430 println!(
431 " cordis.patch.yml = the current overlay rows (sha256 {})",
432 record.overlay_sha256
433 );
434 println!(
435 " will run: dsh plugin --profile {} add {}",
436 dsh::bundle::BUNDLE_PROFILE,
437 app_source.display()
438 );
439 println!(
440 " will run: dsh plugin --profile {} add {}",
441 dsh::bundle::BUNDLE_PROFILE,
442 paths.bundle_dir.display()
443 );
444 println!(
445 " dsh will create/modify only its dedicated profile {} (package.json, pnpm-lock.yaml, node_modules links); `web`/`headless` are untouched",
446 profile_dir.display()
447 );
448 println!(" plugin path: {}", report.bundle_availability.label());
449 println!(" no network: both adds are local `link:` paths");
450 confirm(
451 yes,
452 "Install the Codewhale bundle into DSH profile `codewhale`?",
453 )?;
454 let bundle = dsh::install_bundle(
455 &paths,
456 &report.detection,
457 &dsh::ProcessRunner,
458 &report.bundle_availability,
459 app,
460 )?;
461 println!(
462 "installed: {} {} into {} (patch sha256 {})",
463 bundle.package_name,
464 bundle.package_version,
465 bundle.profile_dir.display(),
466 bundle.patch_sha256
467 );
468 println!(
469 "launch without --patch: dsh --profile {} (or `{CLI_COMMAND} launch`)",
470 dsh::bundle::BUNDLE_PROFILE
471 );
472 Ok(())
473 }
474 DshIntegrationCommand::RemoveBundle { yes } => {
475 let (paths, report) = status_report(config, workspace, false)?;
476 let bundle = report
477 .record
478 .as_ref()
479 .and_then(|r| r.bundle.as_ref())
480 .ok_or_else(|| anyhow::anyhow!("no bundle is installed"))?;
481 println!(
482 "remove-bundle will run: dsh plugin --profile {} remove {}",
483 bundle.profile, bundle.package_name
484 );
485 println!(" then delete only {}", paths.bundle_dir.display());
486 println!(
487 " the DSH profile dir {} and its app bundle link stay in place (DSH-owned)",
488 bundle.profile_dir.display()
489 );
490 confirm(
491 yes,
492 "Remove the Codewhale bundle from DSH profile `codewhale`?",
493 )?;
494 let removed = dsh::remove_bundle(&paths, &report.detection, &dsh::ProcessRunner)?;
495 println!(
496 "removed bundle; deleted {} Codewhale-owned file(s)",
497 removed.len()
498 );
499 Ok(())
500 }
501 DshIntegrationCommand::Remove { yes } => {
502 let paths = DshPaths::from_process()?;
503 println!(
504 "remove will delete only Codewhale-owned files under {}:",
505 paths.root.display()
506 );
507 for path in [&paths.overlay, &paths.skin, &paths.skin_preview] {
508 if path.exists() {
509 println!(" {}", path.display());
510 }
511 }
512 println!(
513 " (receipt history is kept at {}; $DSH_HOME is never touched)",
514 paths.receipt.display()
515 );
516 confirm(yes, "Remove the DSH integration files?")?;
517 let removed = dsh::remove(&paths)?;
518 println!("removed {} file(s)", removed.len());
519 Ok(())
520 }
521 }
522 }
523
524 fn ensure_launchable_dsh(report: &DshStatusReport) -> Result<()> {
525 match &report.state {
526 DshIntegrationState::NotInstalled => {
527 anyhow::bail!(
528 "dsh is not on PATH; install the official DeepSeek Harness first (npm i -g @deepseek-ai/dsh)"
529 )
530 }
531 DshIntegrationState::Offline { reason } => anyhow::bail!("dsh is offline: {reason}"),
532 DshIntegrationState::Incompatible { reason, .. } => {
533 anyhow::bail!("installed dsh is incompatible with this integration: {reason}")
534 }
535 _ => Ok(()),
536 }
537 }
538
538 lines RUST