procyon 0.1.1

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
//! 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::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(
        "This is not a Caatinga project: no caatinga.config.ts in the workspace. \
         The caatinga_* tools deploy contracts declared in that config, so there is nothing \
         for them to act on. Run `npx @caatinga/cli init` to set one up, or use the stellar \
         CLI directly for a one-off contract."
            .to_string(),
    )
}

/// 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.
        match run_caatinga(&args).await {
            Ok(stdout) => Ok(format!("Setup looks healthy.\n\n{}", stdout)),
            Err(report) => Ok(format!(
                "Doctor reported problems. This is the diagnosis, not a tool failure:\n\n{}",
                report
            )),
        }
    }
}

#[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());
    }

    // 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
        );
    }
}