partiri-cli 0.2.0

Partiri CLI — Deploy and manage services on Partiri Cloud
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
use std::path::Path;

use clap::CommandFactory;
use serde_json::{json, Value};

use crate::client::ApiClient;
use crate::config::{PartiriConfig, CONFIG_FILE};
use crate::error::{CliError, Result};
use crate::output::{ctx, print_result};

pub const LLM_GUIDE: &str = include_str!("../../LLM.md");

// ─── guide ──────────────────────────────────────────────────────────────────

pub fn run_guide() -> Result<()> {
    if ctx().json {
        print_result(&json!({ "markdown": LLM_GUIDE }));
    } else {
        print!("{}", LLM_GUIDE);
    }
    Ok(())
}

// ─── schema ─────────────────────────────────────────────────────────────────

pub fn run_schema() -> Result<()> {
    let schema = config_schema();
    if ctx().json {
        print_result(&schema);
    } else {
        println!("{}", serde_json::to_string_pretty(&schema).unwrap());
    }
    Ok(())
}

fn config_schema() -> Value {
    json!({
        "title": ".partiri.jsonc",
        "description": "Per-service config file consumed by the partiri CLI.",
        "type": "object",
        "required": ["fk_workspace", "fk_project", "service"],
        "properties": {
            "id":         { "type": ["string", "null"], "description": "Service UUID. Set by 'partiri service create'; do not edit by hand." },
            "deploy_tag": { "type": ["string", "null"], "description": "Most recent deploy tag. Set by 'partiri service deploy'." },
            "fk_workspace": { "type": "string", "description": "Workspace UUID." },
            "fk_project":   { "type": "string", "description": "Project UUID. Must belong to fk_workspace." },
            "service": {
                "type": "object",
                "required": ["name", "deploy_type", "runtime", "root_path", "fk_region", "fk_pod"],
                "properties": {
                    "name":        { "type": "string", "maxLength": 16, "description": "Service name. Unique within a project." },
                    "deploy_type": { "type": "string", "enum": ["webservice", "static", "private-service"] },
                    "runtime":     { "type": "string", "enum": ["node", "rust", "python", "go", "ruby", "elixir", "php", "jvm", "dotnet", "cpp", "static", "registry"] },
                    "root_path":   { "type": "string", "default": "." },
                    "repository_url":            { "type": ["string", "null"], "description": "Mutually exclusive with registry_url." },
                    "repository_branch":         { "type": ["string", "null"], "description": "Required when repository_url is set." },
                    "registry_url":              { "type": ["string", "null"], "description": "Mutually exclusive with repository_url." },
                    "registry_repository_url":   { "type": ["string", "null"], "description": "Required when registry_url is set." },
                    "fk_service_secret":         { "type": ["string", "null"], "description": "Required for private repository or registry sources." },
                    "build_path":                { "type": ["string", "null"] },
                    "build_command":             { "type": ["string", "null"], "description": "Required for repo source on non-static runtimes." },
                    "pre_deploy_command":        { "type": ["string", "null"] },
                    "run_command":               { "type": ["string", "null"], "description": "Required for deploy_type=webservice or private-service." },
                    "fk_region":                 { "type": "string" },
                    "fk_pod":                    { "type": "string", "description": "Must come from the same workspace as fk_region." },
                    "health_check_path":         { "type": ["string", "null"], "description": "Path or absolute URL. Only absolute URLs are probed by 'validate --remote'." },
                    "maintenance_mode":          { "type": "boolean", "default": false },
                    "active":                    { "type": "boolean", "default": true },
                    "env": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "required": ["key", "value"],
                            "properties": {
                                "key":   { "type": "string" },
                                "value": { "type": "string" }
                            }
                        }
                    }
                }
            }
        },
        "rules": [
            "service.repository_url XOR service.registry_url (one must be set, not both).",
            "When deploy_type ∈ {webservice, private-service}, service.run_command is required.",
            "When repository_url is set on a non-static runtime, service.build_command is required.",
            "service.name must be ≤16 characters.",
            "fk_region and fk_pod must belong to the same workspace.",
        ]
    })
}

// ─── template ───────────────────────────────────────────────────────────────

pub struct TemplateArgs {
    pub deploy_type: Option<String>,
    pub runtime: Option<String>,
    pub source: Option<String>,
}

pub fn run_template(args: TemplateArgs) -> Result<()> {
    let dt = args.deploy_type.as_deref().unwrap_or("webservice");
    let runtime = args.runtime.as_deref().unwrap_or("node");
    let source = args.source.as_deref().unwrap_or("repo");

    let template = build_template(dt, runtime, source);

    if ctx().json {
        print_result(&json!({
            "deploy_type": dt,
            "runtime": runtime,
            "source": source,
            "template": template,
        }));
    } else {
        println!("{}", template);
    }
    Ok(())
}

fn build_template(deploy_type: &str, runtime: &str, source: &str) -> String {
    let (build_command, run_command) = default_commands(runtime);

    // Registry-sourced deploys don't run a build (image is already built) and the
    // run command is supplied by the image entrypoint, so suppress both.
    let is_registry = source == "registry";

    let build_line = if is_registry {
        "    // \"build_command\": null,       // not used for registry-sourced deploys (image is pre-built)".to_string()
    } else {
        format!("    \"build_command\": \"{}\",", build_command)
    };
    let run_line = if deploy_type == "static" {
        "    // \"run_command\": null,         // not used for static deploys".to_string()
    } else if is_registry {
        "    // \"run_command\": null,         // not used for registry-sourced deploys (image entrypoint runs)".to_string()
    } else {
        format!("    \"run_command\": \"{}\",", run_command)
    };
    let source_block = if is_registry {
        r#"    "registry_url": "registry.example.com",
    "registry_repository_url": "your-org/your-image:tag",
    // "repository_url": null,
    // "repository_branch": null,
    // For private images, set fk_service_secret to a registry-secret UUID:
    // "fk_service_secret": "uuid",  // run 'partiri service token --secret <UUID>'"#
    } else {
        r#"    "repository_url": "https://github.com/your-org/your-repo.git",
    "repository_branch": "main",
    // "registry_url": null,
    // "registry_repository_url": null,
    // For private repos, set fk_service_secret to a repository-secret UUID:
    // "fk_service_secret": "uuid",  // run 'partiri service token --secret <UUID>'"#
    };
    format!(
        r#"{{
  // The service ID is assigned by 'partiri service create'; leave null until then.
  "id": null,
  "deploy_tag": null,
  "fk_workspace": "<workspace UUID — run 'partiri -j llm context' to discover>",
  "fk_project":   "<project UUID — same source>",

  "service": {{
    "name": "my-service",                  // ≤16 chars
    "deploy_type": "{deploy_type}",        // webservice | static | private-service
    "runtime": "{runtime}",
    "root_path": ".",

{source_block}

{build_line}
    // "build_path": "dist",
    // "pre_deploy_command": "",
{run_line}

    "fk_region": "<region UUID>",
    "fk_pod":    "<pod UUID>",

    // "health_check_path": "/health",
    "maintenance_mode": false,
    "active": true,
    "env": []
  }}
}}
"#
    )
}

fn default_commands(runtime: &str) -> (&'static str, &'static str) {
    match runtime {
        "node" => ("npm run build", "node ./dist/server/entry.mjs"),
        "rust" => ("cargo build --release", "./target/release/app"),
        "python" => ("pip install -r requirements.txt", "python main.py"),
        "go" => ("go build -o app .", "./app"),
        "ruby" => ("bundle install", "ruby app.rb"),
        "elixir" => ("mix deps.get && mix compile", "mix phx.server"),
        "php" => ("composer install", "php -S 0.0.0.0:$PORT"),
        "jvm" => ("./gradlew build", "java -jar build/libs/app.jar"),
        "dotnet" => (
            "dotnet publish -c Release",
            "dotnet bin/Release/net8.0/app.dll",
        ),
        "cpp" => ("make", "./app"),
        "static" => ("npm run build", ""),
        "registry" => ("", ""),
        _ => ("", ""),
    }
}

// ─── examples ───────────────────────────────────────────────────────────────

pub fn run_examples() -> Result<()> {
    let examples = json!([
        {
            "name": "node-webservice-public-repo",
            "description": "Node.js webservice from a public GitHub repo.",
            "jsonc": build_template("webservice", "node", "repo"),
            "commands": [
                "partiri auth --key <KEY>",
                "partiri init --template",
                "# edit .partiri.jsonc with the values above and real UUIDs from `partiri -j llm context`",
                "partiri -j validate --remote",
                "partiri -j -y service create",
                "partiri -j -y service deploy",
            ]
        },
        {
            "name": "static-site",
            "description": "Pre-built static site from a public repo.",
            "jsonc": build_template("static", "static", "repo"),
            "commands": [
                "partiri init --template",
                "# set deploy_type=static, runtime=static, no run_command, build_path=dist",
                "partiri -j -y service create",
                "partiri -j -y service deploy",
            ]
        },
        {
            "name": "private-registry-image",
            "description": "Pre-built Docker image from a private registry; requires fk_service_secret.",
            "jsonc": build_template("webservice", "registry", "registry"),
            "commands": [
                "partiri -j llm context | jq '.data.workspaces[0].registry_secrets'",
                "partiri -j -y service token --secret <SECRET_UUID>",
                "partiri -j validate --remote",
                "partiri -j -y service create",
                "partiri -j -y service deploy",
            ]
        },
        {
            "name": "rust-private-service",
            "description": "Internal Rust service (not exposed publicly).",
            "jsonc": build_template("private-service", "rust", "repo"),
            "commands": [
                "partiri init --template",
                "partiri -j -y service create",
                "partiri -j -y service deploy",
            ]
        }
    ]);
    if ctx().json {
        print_result(&examples);
    } else {
        println!("{}", serde_json::to_string_pretty(&examples).unwrap());
    }
    Ok(())
}

// ─── capabilities ───────────────────────────────────────────────────────────

pub fn run_capabilities() -> Result<()> {
    let cmd = crate::cli::Cli::command();
    let tree = walk_command(&cmd);
    if ctx().json {
        print_result(&tree);
    } else {
        println!("{}", serde_json::to_string_pretty(&tree).unwrap());
    }
    Ok(())
}

fn walk_command(cmd: &clap::Command) -> Value {
    let args: Vec<Value> = cmd
        .get_arguments()
        .filter(|a| !a.is_positional() || a.get_id().as_str() != "help")
        .map(|a| {
            let id = a.get_id().as_str();
            let mut o = serde_json::Map::new();
            o.insert("name".into(), Value::String(id.into()));
            if let Some(s) = a.get_short() {
                o.insert("short".into(), Value::String(s.to_string()));
            }
            if let Some(l) = a.get_long() {
                o.insert("long".into(), Value::String(l.into()));
            }
            o.insert("required".into(), Value::Bool(a.is_required_set()));
            let takes_value = a.get_action().takes_values();
            o.insert("takes_value".into(), Value::Bool(takes_value));
            if let Some(h) = a.get_help() {
                o.insert("help".into(), Value::String(h.to_string()));
            }
            // Skip possible_values for boolean flags: clap reports ["true","false"] for
            // SetTrue/SetFalse actions, which misleads agents into trying `--flag=true`.
            if takes_value {
                if let Some(values) = a.get_possible_values().get(0..).filter(|v| !v.is_empty()) {
                    let v: Vec<Value> = values
                        .iter()
                        .map(|pv| Value::String(pv.get_name().into()))
                        .collect();
                    o.insert("possible_values".into(), Value::Array(v));
                }
            }
            Value::Object(o)
        })
        .collect();

    let subs: Vec<Value> = cmd.get_subcommands().map(walk_command).collect();

    let mut o = serde_json::Map::new();
    o.insert("name".into(), Value::String(cmd.get_name().into()));
    if let Some(about) = cmd.get_about() {
        o.insert("about".into(), Value::String(about.to_string()));
    }
    o.insert("args".into(), Value::Array(args));
    if !subs.is_empty() {
        o.insert("subcommands".into(), Value::Array(subs));
    }
    Value::Object(o)
}

// ─── errors ─────────────────────────────────────────────────────────────────

pub fn run_errors() -> Result<()> {
    let catalog = json!([
        { "code": "400", "meaning": "Bad request — config values out of range or wrong type",
          "hint": "Check that your configuration values are valid.",
          "likely_causes": ["Configuration values are out of range or wrong type"],
          "suggested_commands": ["partiri validate"] },
        { "code": "401", "meaning": "Unauthorized",
          "hint": "Run 'partiri auth' to update your API key.",
          "likely_causes": ["API key expired or revoked", "Wrong PARTIRI_API_URL"],
          "suggested_commands": ["partiri auth --key <K>", "partiri llm doctor"] },
        { "code": "402", "meaning": "Insufficient workspace balance",
          "hint": "Top up at https://partiri.cloud/settings/billing",
          "likely_causes": ["Workspace balance is empty"],
          "suggested_commands": ["partiri llm whoami"] },
        { "code": "403", "meaning": "Permission denied or workspace limit reached",
          "hint": "Your account may lack permission, or a workspace limit has been reached.",
          "likely_causes": ["Account lacks permission", "Workspace limit reached"],
          "suggested_commands": ["partiri llm whoami"] },
        { "code": "404", "meaning": "Resource not found",
          "hint": "The resource was not found. It may have been deleted.",
          "likely_causes": ["Resource was deleted", "Wrong UUID for workspace/project/region/pod"],
          "suggested_commands": ["partiri llm context"] },
        { "code": "409", "meaning": "Conflict",
          "hint": "A conflicting operation is in progress. Wait for it to finish, then retry.",
          "likely_causes": ["Conflicting operation in progress"],
          "suggested_commands": ["partiri service jobs"] },
        { "code": "422", "meaning": "Invalid request data / schema mismatch",
          "hint": "The request data is invalid. Check your configuration values.",
          "likely_causes": ["Invalid request data", "Schema mismatch with the API"],
          "suggested_commands": ["partiri validate --remote"] },
        { "code": "429", "meaning": "Rate limit exceeded",
          "hint": "Wait a moment and try again.",
          "likely_causes": ["Rate limit exceeded"], "suggested_commands": [] },
        { "code": "5xx", "meaning": "Server-side error",
          "hint": "Try again later, or contact support.",
          "likely_causes": ["Transient backend issue"], "suggested_commands": [] },
        { "code": "auth", "meaning": "No API key configured locally",
          "hint": "Configure a key via 'partiri auth --key <K>'.",
          "likely_causes": ["No API key configured"],
          "suggested_commands": ["partiri auth --key <K>"] },
        { "code": "validation", "meaning": "Local config validation failed",
          "hint": "Fix the failing fields, then re-run.",
          "likely_causes": [], "suggested_commands": ["partiri llm next"] },
        { "code": "network", "meaning": "API host unreachable",
          "hint": "Check connectivity and PARTIRI_API_URL.",
          "likely_causes": ["API host unreachable", "Wrong PARTIRI_API_URL"],
          "suggested_commands": ["partiri llm doctor"] },
        { "code": "config", "meaning": "Bad .partiri.jsonc content",
          "hint": "Re-check the file against the schema.", "likely_causes": [],
          "suggested_commands": ["partiri validate"] },
        { "code": "cancelled", "meaning": "User aborted (Ctrl-C / inquire cancel) — exit code 2",
          "hint": null, "likely_causes": [], "suggested_commands": [] },
        { "code": "missing_dependency", "meaning": "A required predecessor wasn't done",
          "hint": null, "likely_causes": [], "suggested_commands": ["partiri llm next"] }
    ]);
    if ctx().json {
        print_result(&catalog);
    } else {
        println!("{}", serde_json::to_string_pretty(&catalog).unwrap());
    }
    Ok(())
}

// ─── explain ────────────────────────────────────────────────────────────────

pub fn run_explain(command: &str) -> Result<()> {
    let cmd = crate::cli::Cli::command();
    let target = match find_subcommand(&cmd, command) {
        Some(c) => c,
        None => {
            return Err(Box::new(
                CliError::new("validation", format!("Unknown command '{}'.", command))
                    .with_hint("Run 'partiri llm capabilities' to list every command."),
            ));
        }
    };

    let pitfalls = pitfalls_for(command);
    let info = json!({
        "command": command,
        "description": target.get_about().map(|s| s.to_string()),
        "args": walk_command(target).get("args").cloned(),
        "subcommands": walk_command(target).get("subcommands").cloned(),
        "pitfalls": pitfalls,
    });

    if ctx().json {
        print_result(&info);
    } else {
        println!("{}", serde_json::to_string_pretty(&info).unwrap());
    }
    Ok(())
}

fn find_subcommand<'a>(root: &'a clap::Command, path: &str) -> Option<&'a clap::Command> {
    let mut cur = root;
    for segment in path.split_whitespace() {
        cur = cur.find_subcommand(segment)?;
    }
    Some(cur)
}

fn pitfalls_for(command: &str) -> Vec<&'static str> {
    match command {
        "init" => vec![
            "Refuses to overwrite an existing .partiri.jsonc — delete it first.",
            "Without --template the command requires a TTY (it runs the wizard).",
        ],
        "auth" => vec![
            "API key must be ≥64 characters; control characters are rejected.",
            "From a TTY, an existing key is preserved unless --force is passed.",
        ],
        "validate" => vec![
            "Without --remote, only static/local checks run.",
            "--remote needs an API key.",
        ],
        "service create" => vec![
            "Requires every fk_* field set in .partiri.jsonc; run validate --remote first.",
            "service.name must be ≤16 chars and unique within the project.",
        ],
        "service deploy" => vec![
            "Destructive operation — pass -y to skip the confirmation in scripts.",
            "Best-effort refresh of deploy_tag after the job is created — may still be empty if the deploy hasn't completed. Run 'partiri llm next' or 'partiri service pull' to refresh later.",
        ],
        "service kill" => vec![
            "Destructive operation — pass -y to skip the confirmation in scripts.",
        ],
        "service pause" => vec![
            "Destructive operation — pass -y to skip the confirmation in scripts.",
            "Pausing stops billable compute but preserves config; resume with 'service unpause'.",
        ],
        "service unpause" => vec![
            "Destructive operation — pass -y to skip the confirmation in scripts.",
            "Resumes a paused service. Idempotent if the service is already running.",
        ],
        "service link" => vec![
            "--workspace requires --project (projects don't carry across workspaces).",
            "--region requires --pod.",
        ],
        "service token" => vec![
            "Lists registry secrets when registry_url is set, otherwise repository secrets.",
        ],
        "llm context" => vec![
            "One call returns workspaces + their projects/regions/pods/secrets/services.",
            "Pass --workspace <UUID> to scope to one workspace and reduce work.",
        ],
        _ => vec![],
    }
}

// ─── whoami ─────────────────────────────────────────────────────────────────

pub fn run_whoami(client: &ApiClient) -> Result<()> {
    let key_path = crate::modules::auth::credentials_path()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "<unknown>".into());
    let key_configured = crate::modules::auth::read_key().is_some();
    let api_url =
        std::env::var("PARTIRI_API_URL").unwrap_or_else(|_| "https://api.partiri.cloud".into());

    let workspaces = client.list_workspaces()?;
    let user_email = workspaces.first().and_then(|w| w.email.clone());

    let payload = json!({
        "key_path": key_path,
        "key_configured": key_configured,
        "api_url": api_url,
        "user_email": user_email,
        "workspace_count": workspaces.len(),
    });

    if ctx().json {
        print_result(&payload);
    } else {
        println!("{}", serde_json::to_string_pretty(&payload).unwrap());
    }
    Ok(())
}

// ─── doctor ─────────────────────────────────────────────────────────────────

pub fn run_doctor() -> Result<()> {
    let mut checks: Vec<Value> = Vec::new();

    // Key file
    let key_path = crate::modules::auth::credentials_path();
    match &key_path {
        Some(p) if p.exists() => checks.push(json!({
            "name": "credentials_file",
            "status": "ok",
            "message": format!("found at {}", p.display()),
            "fix": null,
        })),
        Some(p) => checks.push(json!({
            "name": "credentials_file",
            "status": "fail",
            "message": format!("missing at {}", p.display()),
            "fix": "partiri auth --key <KEY>",
        })),
        None => checks.push(json!({
            "name": "credentials_file",
            "status": "fail",
            "message": "could not determine config directory",
            "fix": "set $HOME or $XDG_CONFIG_HOME",
        })),
    }

    let key_loaded = crate::modules::auth::read_key().is_some();
    checks.push(json!({
        "name": "key_readable",
        "status": if key_loaded { "ok" } else { "fail" },
        "message": if key_loaded { "key file readable and non-empty" } else { "key not readable or empty" },
        "fix": if key_loaded { Value::Null } else { Value::String("partiri auth --key <KEY>".into()) },
    }));

    // API reachability
    let mut api_ok = false;
    if key_loaded {
        match ApiClient::new() {
            Ok(client) => match client.list_workspaces() {
                Ok(ws) => {
                    api_ok = true;
                    checks.push(json!({
                        "name": "api_reachable",
                        "status": "ok",
                        "message": format!("authenticated; {} workspace(s) visible", ws.len()),
                        "fix": null,
                    }));
                }
                Err(e) => checks.push(json!({
                    "name": "api_reachable",
                    "status": "fail",
                    "message": format!("API call failed: {}", e),
                    "fix": "check PARTIRI_API_URL and that the key is valid",
                })),
            },
            Err(e) => checks.push(json!({
                "name": "api_reachable",
                "status": "fail",
                "message": format!("could not construct API client: {}", e),
                "fix": "partiri auth --key <KEY>",
            })),
        }
    } else {
        checks.push(json!({
            "name": "api_reachable",
            "status": "warn",
            "message": "skipped — no API key",
            "fix": null,
        }));
    }

    // .partiri.jsonc presence
    if Path::new(CONFIG_FILE).exists() {
        match PartiriConfig::load() {
            Ok(_) => checks.push(json!({
                "name": "partiri_jsonc",
                "status": "ok",
                "message": "found and parses",
                "fix": null,
            })),
            Err(e) => checks.push(json!({
                "name": "partiri_jsonc",
                "status": "fail",
                "message": format!("parse error: {}", e),
                "fix": "partiri llm template",
            })),
        }
    } else {
        checks.push(json!({
            "name": "partiri_jsonc",
            "status": "warn",
            "message": format!("no {} in current directory (not required for top-level commands)", CONFIG_FILE),
            "fix": "partiri init --template",
        }));
    }

    let _ = api_ok;
    let payload = json!({ "checks": checks });

    let has_fail = checks.iter().any(|c| c["status"] == "fail");

    if ctx().json {
        print_result(&payload);
    } else {
        for c in &checks {
            let icon = match c["status"].as_str() {
                Some("ok") => "",
                Some("warn") => "!",
                Some("fail") => "",
                _ => "?",
            };
            println!(
                "  {} {:<22} {}",
                icon,
                c["name"].as_str().unwrap_or(""),
                c["message"].as_str().unwrap_or("")
            );
            if let Some(fix) = c["fix"].as_str() {
                println!("    fix: {}", fix);
            }
        }
    }

    if has_fail {
        return Err(Box::new(
            CliError::new("validation", "Doctor reported failing checks.")
                .with_hint("See the per-check 'fix' field for the recommended action."),
        ));
    }
    Ok(())
}

// ─── context ────────────────────────────────────────────────────────────────

pub fn run_context(client: &ApiClient, workspace: Option<String>) -> Result<()> {
    let workspaces = client.list_workspaces()?;
    let user_email = workspaces.first().and_then(|w| w.email.clone());

    let scoped: Vec<_> = match &workspace {
        Some(id) => workspaces.into_iter().filter(|w| &w.id == id).collect(),
        None => workspaces,
    };

    let mut ws_payload: Vec<Value> = Vec::new();
    for w in &scoped {
        // Each per-workspace fan-out is sequential here for simplicity; the API caches per-workspace
        // for 5 minutes, so subsequent calls are cheap.
        let projects = client.list_projects(&w.id).unwrap_or_default();
        let regions = client.list_regions(&w.id).unwrap_or_default();
        let pods = client.list_pods(&w.id).unwrap_or_default();
        let registry_secrets = client.list_registry_secrets(&w.id).unwrap_or_default();
        let repository_secrets = client.list_repository_secrets(&w.id).unwrap_or_default();

        let mut services_per_project: Vec<Value> = Vec::new();
        for p in &projects {
            if let Ok(svcs) = client.list_services(&p.id) {
                for s in svcs {
                    services_per_project.push(json!({
                        "id": s.id,
                        "name": s.name,
                        "fk_project": p.id,
                        "fk_region": s.fk_region,
                        "fk_pod": s.fk_pod,
                        "deploy_type": s.deploy_type,
                        "runtime": s.runtime,
                    }));
                }
            }
        }

        ws_payload.push(json!({
            "id": w.id,
            "name": w.name,
            "email": w.email,
            "projects": projects.iter().map(|p| json!({
                "id": p.id,
                "name": p.name,
                "environment": p.environment,
            })).collect::<Vec<_>>(),
            "regions": regions.iter().map(|r| json!({
                "id": r.id,
                "name": r.name,
                "label": r.label,
                "country_code": r.country_code,
            })).collect::<Vec<_>>(),
            "pods": pods.iter().map(|p| json!({
                "id": p.id,
                "name": p.name,
                "label": p.label,
                "cpu": p.cpu,
                "ram": p.ram,
            })).collect::<Vec<_>>(),
            "registry_secrets": registry_secrets.iter().map(|s| json!({
                "id": s.id,
                "name": s.name,
                "provider": s.provider,
            })).collect::<Vec<_>>(),
            "repository_secrets": repository_secrets.iter().map(|s| json!({
                "id": s.id,
                "name": s.name,
                "provider": s.provider,
            })).collect::<Vec<_>>(),
            "services": services_per_project,
        }));
    }

    let payload = json!({
        "user": { "email": user_email },
        "workspaces": ws_payload,
    });

    if ctx().json {
        print_result(&payload);
    } else {
        println!("{}", serde_json::to_string_pretty(&payload).unwrap());
    }
    Ok(())
}

// ─── next ───────────────────────────────────────────────────────────────────

pub fn run_next() -> Result<()> {
    let key_configured = crate::modules::auth::read_key().is_some();
    let jsonc_exists = Path::new(CONFIG_FILE).exists();

    let (state, next_command, rationale): (String, String, String) = if !key_configured {
        (
            "needs_auth".into(),
            "partiri auth --key <KEY>".into(),
            "No API key configured. Configure one before running anything else.".into(),
        )
    } else if !jsonc_exists {
        (
            "needs_init".into(),
            "partiri init --template".into(),
            "No .partiri.jsonc in this directory. Write a template, then fill it in.".into(),
        )
    } else {
        match PartiriConfig::load() {
            Ok(cfg) => deduce_state(&cfg),
            Err(e) => {
                let payload = json!({
                    "state": "config_broken",
                    "next_command": "partiri validate",
                    "rationale": format!("Existing .partiri.jsonc fails to parse: {}. Fix the file.", e),
                });
                if ctx().json {
                    print_result(&payload);
                } else {
                    println!("{}", serde_json::to_string_pretty(&payload).unwrap());
                }
                return Ok(());
            }
        }
    };

    let payload = json!({
        "state": state,
        "next_command": next_command,
        "rationale": rationale,
    });

    if ctx().json {
        print_result(&payload);
    } else {
        println!("{}", serde_json::to_string_pretty(&payload).unwrap());
    }
    Ok(())
}

fn deduce_state(cfg: &PartiriConfig) -> (String, String, String) {
    if cfg.fk_workspace.is_empty()
        || cfg.fk_project.is_empty()
        || cfg.service.fk_region.is_empty()
        || cfg.service.fk_pod.is_empty()
    {
        return (
            "needs_uuids".into(),
            "partiri -j llm context".into(),
            "One or more fk_* fields are empty. Fetch the UUIDs and edit the file.".into(),
        );
    }
    if cfg.id.is_none() {
        return (
            "needs_create".into(),
            "partiri -j validate --remote && partiri -j -y service create".into(),
            "Config looks ready. Validate against the API, then register the service.".into(),
        );
    }
    if cfg.deploy_tag.is_some() {
        return (
            "deployed".into(),
            "partiri -j service jobs".into(),
            "Service is deployed. Inspect jobs/logs/metrics from here.".into(),
        );
    }

    // id is set, deploy_tag is missing — check the deploy job history to disambiguate
    // "never deployed" from "deploy in progress" / "deploy failed" / "deploy succeeded but
    // tag not yet propagated locally".
    if let (Some(id), Ok(client)) = (cfg.id.as_deref(), ApiClient::new()) {
        if let Ok(jobs) = client.list_service_jobs(id) {
            let mut deploys: Vec<_> = jobs
                .into_iter()
                .filter(|j| j.job_type == "deploy")
                .collect();
            deploys.sort_by(|a, b| b.created_at.cmp(&a.created_at));
            if let Some(latest) = deploys.first() {
                match latest.status.as_str() {
                    "succeeded" => {
                        return (
                            "deployed".into(),
                            "partiri service pull".into(),
                            "A deploy job succeeded but deploy_tag is not yet set in .partiri.jsonc — refresh from the API.".into(),
                        );
                    }
                    "in_progress" | "open" => {
                        return (
                            "deploying".into(),
                            "partiri -j service jobs".into(),
                            "A deploy job is in progress. Watch the job status.".into(),
                        );
                    }
                    "failed" | "timed_out" => {
                        return (
                            "deploy_failed".into(),
                            "partiri -j service jobs".into(),
                            format!(
                                "The most recent deploy job ended with status '{}'. Inspect jobs/logs.",
                                latest.status
                            ),
                        );
                    }
                    _ => {}
                }
            }
        }
    }

    (
        "needs_deploy".into(),
        "partiri -j -y service deploy".into(),
        "Service is registered but never deployed. Trigger a deploy.".into(),
    )
}