procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
//! Tools that drive `@caatinga/cli`.
//!
//! Caatinga sits between "contract is written" and "contract is deployed with typed bindings". Its
//! value comes from invariants — versioned artifacts, a topologically ordered deployment graph, no
//! contract ids copied by hand, no key material on a command line — and every one of them is lost
//! by a caller that reaches past it. So these tools pass contract *names* and let Caatinga resolve
//! paths and ids from `caatinga.config.ts` and `caatinga.artifacts.json`, rather than passing the
//! wasm and the id themselves.
//!
//! They also refuse to run outside a Caatinga project: without a config there is nothing for the
//! CLI to resolve a name against, and reporting that is more useful than a stack trace from npx.

use std::path::{Path, PathBuf};
use std::time::Duration;

use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::process::Command;

use super::paths::resolve_in_workspace;
use super::Tool;

// Pinned rather than floating: `npx @caatinga/cli` resolves to whatever is newest at the moment of
// the call, so the same Procyon would change behaviour with no change to Procyon.
//
// BEFORE MOVING THIS PIN, re-verify the flags. Every argv this module builds was read off the
// CLI's own help output for this exact version:
//
//     npx @caatinga/cli@<new> deploy   --help
//     npx @caatinga/cli@<new> invoke   --help
//     npx @caatinga/cli@<new> read     --help
//     npx @caatinga/cli@<new> generate --help
//     npx @caatinga/cli@<new> doctor   --help
//
// This is not ceremony. Three of these tools were originally written against flags nobody
// checked — `deploy --wasm`, `invoke --id --fn --arg`, and a `bindings` subcommand that has never
// existed — so none of them could work, and the failure looked like a Caatinga problem rather
// than ours. A renamed flag breaks a deploy silently; only the help output is authoritative.
const CAATINGA_VERSION: &str = "@caatinga/cli@3.9.2";

// A build of several contracts is legitimately slow, but not unbounded: nothing else limits a
// tool call, so a stalled child would hold the turn open with no message.
const COMMAND_TIMEOUT: Duration = Duration::from_secs(600);

// A PATH lookup instead of spawning `npx --version`: the spawn cost 0.3-2s of cold start on
// every build/deploy/invoke, and it ran on the runtime thread that drives the TUI.
pub fn check_npx_available() -> Result<(), String> {
    which::which("npx")
        .map(|_| ())
        .map_err(|_| "npx was not found on PATH. Is Node.js installed?".to_string())
}

/// Confirms the workspace is a Caatinga project, returning the config it found.
///
/// Every command here takes a contract name that only means something relative to a config, so
/// running without one cannot succeed. Checked here so the failure names the actual problem.
pub async fn require_caatinga_project() -> Result<String, String> {
    for candidate in ["caatinga.config.ts", "caatinga.config.js"] {
        let path = resolve_in_workspace(candidate)?;
        if tokio::fs::try_exists(&path).await.unwrap_or(false) {
            return Ok(candidate.to_string());
        }
    }

    Err(missing_config_message().await)
}

/// The three ways "no config here" can happen, said apart.
///
/// One message for all three was the actual usability failure: in a template whose app is nested
/// one level down, `caatinga_build` answered "this is not a Caatinga project" about a checkout with
/// a `caatinga.config.ts` in plain sight one directory away, and the reasonable reading of that is
/// a broken tool rather than a workspace boundary. The boundary is deliberate — every path a tool
/// touches resolves against the directory Procyon was launched from — so the fix is to name it,
/// not to widen it.
async fn missing_config_message() -> String {
    let base = "This is not a Caatinga project: no caatinga.config.ts in the workspace. \
                The caatinga_* tools act on contracts declared in that config, so there is \
                nothing here for them to do.";

    let Ok(root) = std::env::current_dir() else {
        return format!("{} Run `npx @caatinga/cli init` to set one up.", base);
    };

    if let Some(child) = config_in_child(&root).await {
        return format!(
            "{} There is one at {}, in a subdirectory. Procyon's workspace is the directory it was \
             launched from and it does not follow paths out of it, so relaunch Procyon there — or \
             run the caatinga_* tools against a workspace that has the config at its root.",
            base,
            child.display()
        );
    }

    if let Some(parent) = config_in_ancestor(&root).await {
        return format!(
            "{} There is one at {}, above the workspace. Procyon will not reach outside the \
             directory it was launched from; relaunch it there.",
            base,
            parent.display()
        );
    }

    format!(
        "{} Run `npx @caatinga/cli init` to set one up, or use the stellar CLI directly for a \
         one-off contract.",
        base
    )
}

const CONFIG_NAMES: [&str; 2] = ["caatinga.config.ts", "caatinga.config.js"];

async fn has_config(dir: &Path) -> bool {
    for name in CONFIG_NAMES {
        if tokio::fs::try_exists(dir.join(name)).await.unwrap_or(false) {
            return true;
        }
    }
    false
}

/// One level down only. This is a pointer in an error message, not a search: walking the tree
/// would stat a whole monorepo to improve the wording of a refusal.
async fn config_in_child(root: &Path) -> Option<PathBuf> {
    let mut entries = tokio::fs::read_dir(root).await.ok()?;
    let mut found = Vec::new();
    while let Ok(Some(entry)) = entries.next_entry().await {
        let dir = entry.path();
        // `node_modules` holds thousands of packages and some of them are Caatinga's own.
        if !dir.is_dir() || dir.file_name().is_some_and(|n| n == "node_modules") {
            continue;
        }
        if has_config(&dir).await {
            found.push(dir);
        }
    }
    // Sorted: the hint must not depend on `read_dir` order.
    found.sort();
    found.into_iter().next()
}

async fn config_in_ancestor(root: &Path) -> Option<PathBuf> {
    let mut current = root.parent()?.to_path_buf();
    loop {
        if has_config(&current).await {
            return Some(current);
        }
        if !current.pop() {
            return None;
        }
    }
}

/// Whether the workspace's npm dependencies are installed.
///
/// `None` when the question does not apply — no `package.json`, so nothing to install. The check
/// is deliberately shallow: `node_modules` present at all is what separates "never installed" from
/// "installed and possibly stale", and only the first is the deadlock worth naming.
pub async fn dependencies_installed() -> Option<bool> {
    let root = resolve_in_workspace(".").ok()?;
    if !tokio::fs::try_exists(root.join("package.json"))
        .await
        .unwrap_or(false)
    {
        return None;
    }
    Some(
        tokio::fs::try_exists(root.join("node_modules"))
            .await
            .unwrap_or(false),
    )
}

/// The deadlock, stated once so every tool that hits it says the same thing.
///
/// A Caatinga template ships committed placeholder bindings and an empty artifacts file. Nothing
/// in the project works until `npm install` has run, and the CLI that would fix it is one of the
/// dependencies that is not installed. Doctor correctly reported "blocked"; what it did not say is
/// that Procyon can do something about it.
pub const DEPENDENCIES_BLOCKED: &str =
    "This project's npm dependencies are not installed (no node_modules next to package.json), so \
     the project's own Caatinga CLI, its build scripts and its type-check cannot run — and the \
     committed bindings are still placeholders that throw PLACEHOLDER_BINDING at runtime. Run the \
     `install_dependencies` tool (it needs approval, or --allow-changes on a headless run), then \
     caatinga_build, caatinga_deploy and generate_bindings in that order.";

/// Generated binding files that are still the template's committed stubs.
///
/// Caatinga writes `export const __caatingaPlaceholder = true` into the bindings it scaffolds, and
/// every method in them throws. Left in place they fail at runtime in the browser, which is a long
/// way from the tool call that should have replaced them — so it is worth saying here.
pub async fn placeholder_bindings() -> Vec<String> {
    let Ok(root) = resolve_in_workspace("src/contracts/generated") else {
        return Vec::new();
    };
    let mut found = Vec::new();
    let Ok(mut entries) = tokio::fs::read_dir(&root).await else {
        return Vec::new();
    };
    while let Ok(Some(entry)) = entries.next_entry().await {
        let index = entry.path().join("src").join("index.ts");
        if let Ok(body) = tokio::fs::read_to_string(&index).await {
            if body.contains("__caatingaPlaceholder") {
                found.push(entry.file_name().to_string_lossy().into_owned());
            }
        }
    }
    found.sort();
    found
}

/// Rejects anything that is not an identity alias.
///
/// Caatinga documents `--source` as "Stellar CLI identity alias that can sign (for example
/// alice)", and the reason is credential hygiene: a value passed here reaches the process list,
/// and on failure it reaches this tool's error text, the model's context and the session log on
/// disk. A seed or secret key must never travel that path — the alias keeps key material inside
/// the Stellar CLI's own keystore.
pub fn validate_source(source: &str) -> Result<(), String> {
    let source = source.trim();

    if source.is_empty() {
        return Err("'source' is empty; give a Stellar CLI identity alias, e.g. alice".to_string());
    }

    // A seed phrase is the other way key material arrives.
    if source.split_whitespace().count() > 1 {
        return Err(
            "'source' looks like a seed phrase. Pass a Stellar CLI identity alias instead \
             (e.g. alice); Procyon never handles key material."
                .to_string(),
        );
    }

    // Strkeys: S… is a secret seed, G… a public address. Neither is an alias, and the first is a
    // credential. Length and case are what separate these from an alias that merely starts with S.
    let looks_like_strkey = source.len() >= 56
        && source
            .chars()
            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());

    if looks_like_strkey {
        return Err(match source.as_bytes().first() {
            Some(b'S') => "'source' is a secret key. Pass a Stellar CLI identity alias instead \
                           (e.g. alice) — a secret on a command line reaches the process list, \
                           the error text and the session log."
                .to_string(),
            _ => "'source' is a raw address. Caatinga signs through a Stellar CLI identity \
                  alias (e.g. alice), which is also what keeps the key out of Procyon."
                .to_string(),
        });
    }

    Ok(())
}

/// Checks a `<contract>.<method>` target, which both `invoke` and `read` take.
///
/// A contract id here is the mistake the name-based shape exists to prevent, and it otherwise
/// fails deep inside the CLI with a message about an unknown contract.
pub fn validate_target(target: &str) -> Result<(), String> {
    let Some((contract, method)) = target.split_once('.') else {
        return Err(format!(
            "'{}' is not a valid target. Use <contract>.<method>, where the contract is a name \
             from caatinga.config.ts — e.g. token.transfer. Caatinga resolves the contract id \
             from its artifacts.",
            target
        ));
    };

    if contract.is_empty() || method.is_empty() {
        return Err(format!(
            "'{}' is missing the contract or the method. Use <contract>.<method>, e.g. \
             token.transfer.",
            target
        ));
    }

    if super::is_contract_id(contract) {
        return Err(format!(
            "'{}' is a contract id, not a contract name. Use the name from \
             caatinga.config.ts — Caatinga looks the id up in its artifacts, which is what keeps \
             the two from drifting after a redeploy.",
            contract
        ));
    }

    Ok(())
}

/// Runs a Caatinga subcommand in the workspace and returns its output.
///
/// `-y` matters: without it npx asks before installing a package it does not have cached, and the
/// child's stdin is closed, so the prompt cannot be answered and the call dies complaining about
/// a cancelled install rather than about Caatinga.
pub async fn run_caatinga(args: &[String]) -> Result<String, String> {
    check_npx_available()?;

    let mut argv = vec!["-y".to_string(), CAATINGA_VERSION.to_string()];
    argv.extend_from_slice(args);

    let subcommand = args.first().cloned().unwrap_or_default();

    let child = Command::new("npx").args(&argv).output();
    let output = match tokio::time::timeout(COMMAND_TIMEOUT, child).await {
        Ok(Ok(output)) => output,
        Ok(Err(e)) => return Err(format!("Failed to execute caatinga {}: {}", subcommand, e)),
        Err(_) => {
            return Err(format!(
                "caatinga {} did not finish within {}s and was abandoned. It may still be \
                 running; check `npx {} status` before retrying.",
                subcommand,
                COMMAND_TIMEOUT.as_secs(),
                CAATINGA_VERSION
            ))
        }
    };

    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();

    if output.status.success() {
        Ok(stdout)
    } else {
        Err(format!(
            "caatinga {} failed (exit code: {})\n\nstdout:\n{}\n\nstderr:\n{}",
            subcommand,
            output.status.code().unwrap_or(-1),
            stdout,
            stderr
        ))
    }
}

/// The contract ids Caatinga recorded for one network.
///
/// Read from the artifacts rather than scraped out of the deploy log: the artifacts are the
/// versioned source of truth per network, and an id lifted from stdout is stale the moment
/// anything is redeployed. Absent or unexpected shapes yield nothing rather than a guess.
async fn deployed_contracts(network: &str) -> Vec<(String, String)> {
    let Ok(path) = resolve_in_workspace("caatinga.artifacts.json") else {
        return Vec::new();
    };
    let Ok(raw) = tokio::fs::read_to_string(&path).await else {
        return Vec::new();
    };
    let Ok(artifacts) = serde_json::from_str::<Value>(&raw) else {
        crate::diag::warn("caatinga.artifacts.json is not valid JSON; contract ids not reported");
        return Vec::new();
    };

    let contracts = artifacts
        .get("networks")
        .and_then(|n| n.get(network))
        .and_then(|n| n.get("contracts"))
        .and_then(|c| c.as_object());

    let Some(contracts) = contracts else {
        return Vec::new();
    };

    contracts
        .iter()
        .filter_map(|(name, entry)| {
            let id = entry.get("contractId").and_then(|v| v.as_str())?;
            Some((name.clone(), id.to_string()))
        })
        .collect()
}

pub struct CaatingaBuildTool;

#[async_trait]
impl Tool for CaatingaBuildTool {
    fn name(&self) -> &str {
        "caatinga_build"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::Build
    }

    fn description(&self) -> &str {
        "Build the Soroban contracts declared in caatinga.config.ts. Builds every contract unless \
         one is named. Only works in a Caatinga project."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "contract": {
                    "type": "string",
                    "description": "Name of a contract from caatinga.config.ts. Omit to build all."
                }
            },
            "required": []
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        require_caatinga_project().await?;

        let mut args = vec!["build".to_string()];
        if let Some(contract) = input.get("contract").and_then(|v| v.as_str()) {
            args.push(contract.to_string());
        }

        let stdout = run_caatinga(&args).await?;
        Ok(format!("Build successful.\n\n{}", stdout))
    }
}

pub struct CaatingaDeployTool;

#[async_trait]
impl Tool for CaatingaDeployTool {
    fn name(&self) -> &str {
        "caatinga_deploy"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::Signing
    }

    fn description(&self) -> &str {
        "Deploy Soroban contracts declared in caatinga.config.ts. Deploys every contract in \
         dependency order unless one is named, and afterwards Caatinga regenerates bindings, runs \
         wiring hooks and syncs frontend env by itself. Use dry_run to estimate cost without \
         submitting. Only works in a Caatinga project."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "contract": {
                    "type": "string",
                    "description": "Name of a contract from caatinga.config.ts. Omit to deploy all, in dependency order."
                },
                "network": {
                    "type": "string",
                    "description": "Network name as configured in caatinga.config.ts (e.g. testnet). Required: this signs and submits, so it must not rely on a default. Mainnet is refused unless the operator enabled it."
                },
                "source": {
                    "type": "string",
                    "description": "Stellar CLI identity alias that signs, e.g. 'alice'. Never a secret key, seed phrase or raw address."
                },
                "dry_run": {
                    "type": "boolean",
                    "description": "Estimate the deploy cost without submitting anything"
                },
                "if_changed": {
                    "type": "boolean",
                    "description": "Skip contracts whose local WASM already matches the artifacts"
                }
            },
            "required": ["network"]
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        require_caatinga_project().await?;

        // A deploy ships the wasm recorded by the last build. Source edited since then would go on
        // chain as the *previous* version, with the deploy reporting success — so the check is here
        // rather than in the prompt, and it applies to `dry_run` too: an estimate against artifacts
        // nobody rebuilt is an estimate of the wrong thing.
        crate::verify::session().guard_deploy()?;

        let mut args = vec!["deploy".to_string()];

        if let Some(contract) = input.get("contract").and_then(|v| v.as_str()) {
            args.push(contract.to_string());
        }

        // A deploy signs and submits, so the network is gated and must be explicit — see
        // `tools::mainnet`. `--dry-run` still goes through it: it estimates against the network,
        // and treating an estimate as ungated would make the gate a formality to route around.
        let network =
            super::mainnet::resolve_signing_network(input.get("network").and_then(|v| v.as_str()))?;
        args.push("--network".to_string());
        args.push(network.clone());

        if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
            validate_source(source)?;
            args.push("--source".to_string());
            args.push(source.to_string());
        }

        if input.get("dry_run").and_then(|v| v.as_bool()) == Some(true) {
            args.push("--dry-run".to_string());
        }
        if input.get("if_changed").and_then(|v| v.as_bool()) == Some(true) {
            args.push("--if-changed".to_string());
        }

        let stdout = run_caatinga(&args).await?;
        let mut result = "Deploy successful.\n".to_string();

        // Reported from the artifacts, which is also where the caller should read them next time
        // rather than carrying an id around.
        let deployed = deployed_contracts(&network).await;
        if !deployed.is_empty() {
            result.push_str(&format!(
                "\nRecorded in caatinga.artifacts.json for {}:\n",
                network
            ));
            for (name, id) in deployed {
                result.push_str(&format!("  {} = {}\n", name, id));
            }
        }

        result.push_str(&format!("\n{}", stdout));
        Ok(result)
    }
}

pub struct CaatingaDoctorTool;

#[async_trait]
impl Tool for CaatingaDoctorTool {
    fn name(&self) -> &str {
        "caatinga_doctor"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::ReadOnly
    }

    fn description(&self) -> &str {
        "Check the local Caatinga setup: CLI, Stellar CLI, Rust, config, network reachability and \
         signing identity. Run this first when a build, deploy or invoke fails for a reason that \
         is not in the contract — most such failures are environment drift, and this names them \
         instead of guessing. Only works in a Caatinga project."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "network": {
                    "type": "string",
                    "description": "Network name from caatinga.config.ts to validate"
                },
                "source": {
                    "type": "string",
                    "description": "Stellar CLI identity alias to validate, e.g. 'alice'. Never a secret key, seed phrase or raw address."
                },
                "all_networks": {
                    "type": "boolean",
                    "description": "Report deploy and bindings coverage for every configured network"
                },
                "strict": {
                    "type": "boolean",
                    "description": "Also fail when the frontend env file drifts from the artifacts, or bindings are stale"
                }
            },
            "required": []
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        require_caatinga_project().await?;

        let mut args = vec!["doctor".to_string()];

        if let Some(network) = input.get("network").and_then(|v| v.as_str()) {
            args.push("--network".to_string());
            args.push(network.to_string());
        }

        if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
            validate_source(source)?;
            args.push("--source".to_string());
            args.push(source.to_string());
        }

        if input.get("all_networks").and_then(|v| v.as_bool()) == Some(true) {
            args.push("--all-networks".to_string());
        }
        if input.get("strict").and_then(|v| v.as_bool()) == Some(true) {
            args.push("--strict".to_string());
        }

        // A failing check is the useful answer here, not an error: the report says what is wrong,
        // and losing it to a non-zero exit code would defeat the point of running doctor.
        let mut out = match run_caatinga(&args).await {
            Ok(stdout) => format!("Setup looks healthy.\n\n{}", stdout),
            Err(report) => format!(
                "Doctor reported problems. This is the diagnosis, not a tool failure:\n\n{}",
                report
            ),
        };

        // Appended rather than folded into the CLI's own report: Caatinga says "Project
        // dependencies not installed — Fix: npm install", which is a correct instruction addressed
        // to a human at a shell. The model reading this has a tool for it, and before this nothing
        // told it so — the session ended with the diagnosis repeated back to the user.
        if dependencies_installed().await == Some(false) {
            out.push_str("\n\n");
            out.push_str(DEPENDENCIES_BLOCKED);
        } else {
            let stale = placeholder_bindings().await;
            if !stale.is_empty() {
                out.push_str(&format!(
                    "\n\nStill on placeholder bindings: {}. Every method in these throws \
                     PLACEHOLDER_BINDING at runtime — the frontend will fail in the browser, not \
                     here. Run generate_bindings once the contracts are deployed.",
                    stale.join(", ")
                ));
            }
        }

        Ok(out)
    }
}

/// Installs the workspace's npm dependencies.
///
/// A tool of its own rather than a flag on `caatinga_doctor`, for the reason the risk engine
/// exists: doctor is `ReadOnly` and must stay that way, while this writes `node_modules`, may
/// rewrite the lockfile, and runs whatever lifecycle scripts the dependency tree declares — from
/// the network. That is `Write`, so it asks.
pub struct InstallDependenciesTool;

#[async_trait]
impl Tool for InstallDependenciesTool {
    fn name(&self) -> &str {
        "install_dependencies"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::Write
    }

    fn description(&self) -> &str {
        "Install the workspace's npm dependencies (npm install, or pnpm install where the project \
         uses pnpm). Run this when caatinga_doctor reports that dependencies are not installed: \
         until they are, the project's own Caatinga CLI, its build scripts and its type-check \
         cannot run."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {},
            "required": []
        })
    }

    async fn execute(&self, _input: Value) -> Result<String, String> {
        let root = resolve_in_workspace(".")?;
        if !tokio::fs::try_exists(root.join("package.json"))
            .await
            .unwrap_or(false)
        {
            return Err(
                "There is no package.json in the workspace, so there are no npm dependencies to \
                 install. A Rust-only contract workspace needs cargo, not npm."
                    .to_string(),
            );
        }

        // The lockfile decides, not a preference: running `npm install` in a pnpm workspace
        // produces a second, divergent tree and a `package-lock.json` the project does not track.
        let pnpm = tokio::fs::try_exists(root.join("pnpm-lock.yaml"))
            .await
            .unwrap_or(false)
            || tokio::fs::try_exists(root.join("pnpm-workspace.yaml"))
                .await
                .unwrap_or(false);
        let manager = if pnpm { "pnpm" } else { "npm" };

        which::which(manager).map_err(|_| {
            format!(
                "This project is a {0} workspace, but {0} was not found on PATH.",
                manager
            )
        })?;

        let run = Command::new(manager)
            .arg("install")
            .current_dir(&root)
            .output();
        let output = match tokio::time::timeout(COMMAND_TIMEOUT, run).await {
            Ok(Ok(output)) => output,
            Ok(Err(e)) => return Err(format!("Failed to execute {} install: {}", manager, e)),
            Err(_) => {
                return Err(format!(
                    "{} install did not finish within {}s and was abandoned.",
                    manager,
                    COMMAND_TIMEOUT.as_secs()
                ))
            }
        };

        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();

        if !output.status.success() {
            return Err(format!(
                "{} install failed (exit code: {})\n\nstdout:\n{}\n\nstderr:\n{}",
                manager,
                output.status.code().unwrap_or(-1),
                stdout,
                stderr
            ));
        }

        Ok(format!(
            "Dependencies installed with {}. The project's CLI and scripts can run now; the \
             bindings are still whatever is committed until generate_bindings replaces them.\n\n{}",
            manager, stdout
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Shaped like a strkey so the validator sees what it is meant to reject, but obviously
    // synthetic: a published crate should not carry a string that a secret scanner — or a
    // reader — could mistake for a real credential.
    const SECRET: &str = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
    const ADDRESS: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
    // A contract id is a public identifier, so this one carries no such hazard.
    const ID: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";

    #[test]
    fn an_identity_alias_is_accepted() {
        for alias in ["alice", "bob", "deployer-ci", "s", "Steve"] {
            assert!(validate_source(alias).is_ok(), "rejected {}", alias);
        }
    }

    // The one that matters: a secret on a command line reaches the process list, this tool's
    // error text, the model's context and the session log on disk.
    #[test]
    fn a_secret_key_is_refused_and_the_message_says_why() {
        let err = validate_source(SECRET).expect_err("a secret key must be refused");
        assert!(err.contains("secret key"), "{}", err);
        assert!(err.contains("alias"), "{}", err);
        assert!(
            !err.contains(SECRET),
            "the error must not echo the secret: {}",
            err
        );
    }

    #[test]
    fn a_raw_address_is_refused() {
        let err = validate_source(ADDRESS).expect_err("an address is not an alias");
        assert!(err.contains("alias"), "{}", err);
    }

    #[test]
    fn a_seed_phrase_is_refused() {
        let err = validate_source("abandon abandon abandon about")
            .expect_err("a seed phrase must be refused");
        assert!(err.contains("seed phrase"), "{}", err);
    }

    #[test]
    fn an_empty_source_is_refused() {
        assert!(validate_source("   ").is_err());
    }

    // `install_dependencies` runs whatever lifecycle scripts the dependency tree declares, fetched
    // from the network. Classified below Write it would run without ever asking — and the default
    // is Write precisely so a misclassification errs towards asking, not away from it.
    #[test]
    fn installing_dependencies_asks_before_it_runs() {
        assert_eq!(
            InstallDependenciesTool.capability(),
            crate::risk::Capability::Write
        );
    }

    // Procyon's own workspace is Rust-only. The refusal must name that rather than shelling out to
    // npm and returning its complaint about a directory with no manifest.
    #[tokio::test]
    async fn installing_dependencies_refuses_a_workspace_with_no_package_json() {
        let err = InstallDependenciesTool
            .execute(json!({}))
            .await
            .expect_err("a Rust-only workspace has nothing to install");
        assert!(err.contains("no package.json"), "{}", err);
        assert!(err.contains("cargo"), "{}", err);
    }

    // And the question does not apply here at all — `Some(false)` would send every caller down the
    // "blocked" path in a workspace that has no npm dependencies to begin with.
    #[tokio::test]
    async fn a_workspace_with_no_package_json_is_not_reported_as_blocked() {
        assert_eq!(dependencies_installed().await, None);
    }

    // The nested-template case, which is what made the refusal look like a bug: a config sitting
    // one directory down must be named, not summarised as "this is not a Caatinga project".
    #[tokio::test]
    async fn a_config_in_a_subdirectory_is_named_in_the_refusal() {
        let temp = tempfile::tempdir().unwrap();
        let nested = temp.path().join("my-app");
        tokio::fs::create_dir(&nested).await.unwrap();
        tokio::fs::write(nested.join("caatinga.config.ts"), "export default {}")
            .await
            .unwrap();

        let msg = super::config_in_child(temp.path()).await;
        assert_eq!(msg.as_deref(), Some(nested.as_path()));
    }

    // `node_modules` carries Caatinga's own package, so a scan that did not skip it would point
    // the user at a dependency's fixture instead of at their project.
    #[tokio::test]
    async fn node_modules_is_not_offered_as_the_project() {
        let temp = tempfile::tempdir().unwrap();
        let inside = temp.path().join("node_modules").join("@caatinga");
        tokio::fs::create_dir_all(&inside).await.unwrap();
        tokio::fs::write(
            temp.path().join("node_modules").join("caatinga.config.ts"),
            "export default {}",
        )
        .await
        .unwrap();

        assert!(super::config_in_child(temp.path()).await.is_none());
    }

    #[tokio::test]
    async fn a_config_above_the_workspace_is_named_too() {
        let temp = tempfile::tempdir().unwrap();
        let inner = temp.path().join("inner");
        tokio::fs::create_dir(&inner).await.unwrap();
        tokio::fs::write(temp.path().join("caatinga.config.ts"), "export default {}")
            .await
            .unwrap();

        // Canonicalized: on macOS a tempdir sits behind /private, and the walk yields the real path.
        let found = super::config_in_ancestor(&inner).await.unwrap();
        assert_eq!(
            found.canonicalize().unwrap(),
            temp.path().canonicalize().unwrap()
        );
    }

    // The tools take contract names, which only mean something against a config; without one the
    // failure should name that rather than surface an npx error.
    #[tokio::test]
    async fn the_tools_refuse_a_project_without_a_caatinga_config() {
        // The test runs in Procyon's own workspace, which is not a Caatinga project.
        let err = CaatingaDeployTool
            .execute(json!({"network": "testnet"}))
            .await
            .expect_err("deploy must refuse a non-Caatinga project");
        assert!(err.contains("not a Caatinga project"), "{}", err);

        let err = CaatingaBuildTool
            .execute(json!({}))
            .await
            .expect_err("build must refuse a non-Caatinga project");
        assert!(err.contains("not a Caatinga project"), "{}", err);
    }

    // Validation has to happen before the value can reach argv.
    #[tokio::test]
    async fn deploy_refuses_a_secret_before_spawning_anything() {
        let err = CaatingaDeployTool
            .execute(json!({"source": SECRET}))
            .await
            .expect_err("deploy must refuse a secret key");
        assert!(
            err.contains("secret key") || err.contains("not a Caatinga project"),
            "got {}",
            err
        );
    }

    #[tokio::test]
    async fn no_contract_ids_are_reported_when_there_are_no_artifacts() {
        assert!(deployed_contracts("testnet").await.is_empty());
    }

    #[test]
    fn a_contract_and_method_is_a_valid_target() {
        for target in ["token.transfer", "my-contract.balance_of", "a.b"] {
            assert!(validate_target(target).is_ok(), "rejected {}", target);
        }
    }

    // Passing an id where a name belongs is the drift the name-based shape exists to prevent, so
    // it is caught here rather than deep inside the CLI.
    #[test]
    fn a_contract_id_as_the_target_is_refused_by_name() {
        let target = format!("{}.transfer", ID);
        let err = validate_target(&target).expect_err("an id is not a contract name");

        assert!(err.contains("contract id"), "{}", err);
        assert!(err.contains("artifacts"), "{}", err);
    }

    #[test]
    fn a_target_without_a_method_is_refused() {
        for target in ["token", "token.", ".transfer", ""] {
            assert!(
                validate_target(target).is_err(),
                "accepted an incomplete target: {:?}",
                target
            );
        }
    }

    // Doctor's whole purpose is the report, so a failing check has to come back as the diagnosis
    // rather than be swallowed as a tool error.
    #[tokio::test]
    async fn doctor_refuses_a_project_without_a_caatinga_config() {
        let err = CaatingaDoctorTool
            .execute(json!({}))
            .await
            .expect_err("doctor must refuse a non-Caatinga project");
        assert!(err.contains("not a Caatinga project"), "{}", err);
    }

    // Deploy submits, so it must not inherit a network from anywhere the gate cannot see.
    #[test]
    fn deploy_requires_its_network() {
        let schema = CaatingaDeployTool.input_schema();
        let required = schema["required"].as_array().unwrap();
        assert!(
            required.contains(&json!("network")),
            "a deploy must state its network: {}",
            schema
        );
    }

    #[tokio::test]
    async fn doctor_refuses_a_secret_source() {
        let err = CaatingaDoctorTool
            .execute(json!({"source": SECRET}))
            .await
            .expect_err("doctor must refuse a secret key");
        assert!(
            err.contains("secret key") || err.contains("not a Caatinga project"),
            "got {}",
            err
        );
    }
}