Plan: Canopy CLI

On this page

Status

Step Description Status

1

Scaffold canopy-cli crate with clap, reqwest, ApiClient, output, config modules

Done (2026-04-09)

2

Implement login/token/completion commands (auth infrastructure)

Done (2026-04-09)

3

Implement person and household commands (mirrors canopy-persons API)

Done (2026-04-09) — (person create/list/get/delete; household commands pending)

4

Implement rules commands (mirrors canopy-rules API)

Done (2026-04-09) — (list/get/evaluate)

5

Implement application commands (mirrors canopy-applications API)

Done (2026-04-09) — (create/list/get/withdraw)

6

Implement eligibility/determination commands (mirrors canopy-eligibility API)

Done (2026-04-09) — (determine/get/results)

7

Implement security/audit commands (mirrors canopy-security API)

Done (2026-04-09) — (events/event/alerts/nist-controls/summary/verify-chain)

8

Integration tests (CLI against devstack)

Done (2026-04-09) — 8 tests (person roundtrip, list, rules evaluate, application list, auth 401, JSON/table output)

Epic: &37
Branch: feature/canopy-cli
Labels: type::feature, priority::high, service::cli, program::cross-program, workflow::ready

Context

Per ADR-007, every API operation must be available as a CLI subcommand. The CLI is a first-class interface modeled on the OpenStack CLI pattern: a thin reqwest client with clap subcommands, profile-based config, and table/json output formatting.

The CLI ships in the Docker image and is used for scripting, automation, operational debugging, and integration test scenarios. It depends on no internal crates — it is a pure REST client that communicates exclusively through HTTP APIs.

The CLI grows incrementally: each service plan that adds API endpoints also adds the corresponding CLI commands. This plan covers the initial scaffold and the commands for services implemented in Month 1 (Foundation). Subsequent plans add commands as services are built.

Port from CRAIG

The CLI architecture is ported from d:/code/craig/services/craig-cli/:

  • src/client.rsApiClient with bearer auth, get/post/put/delete + query param support

  • src/output.rsFormat::Table | Format::Json, print_list/print_detail/print_kv/check_status

  • src/config.rsProfile struct, ~/.config/canopy/profiles.toml, auto-create on first run

  • src/auth.rs — Keycloak ROPC token acquisition, token file storage, auto-refresh

  • src/cmd/ — one module per service domain

Scope

In scope:

  • CLI scaffold: clap parser, ApiClient, output formatter, profile config, auth flow

  • Commands for Month 1 services: persons, households, rules, applications, eligibility, security

  • canopy login / canopy token show|refresh

  • canopy completion bash|zsh|fish|powershell

  • --format table|json global flag

  • --profile global flag with default profile auto-creation

  • Integration test helpers using the CLI library crate

Out of scope:

  • Program-specific commands (snap, tanf, medicaid, caps, wic) — added by their respective plans

  • Enrollment, renewal, notice, appeal, exchange, reporting commands — added when those services ship

  • TUI (terminal UI) — deferred per ADR-007

Dependencies

This plan depends on:

  • persons-household-model (must be complete): CLI person/household commands call canopy-persons API

  • rules-engine (must be complete): CLI rules commands call canopy-rules API

  • application-intake (must be complete): CLI application commands call canopy-applications API

  • security-audit-subscriber (should be complete): CLI security commands call canopy-security API

Code dependency: none — the CLI is a pure REST client. Runtime dependency: target services must be running for commands to work.

Design

Crate Structure

tools/canopy-cli/
├── Cargo.toml
├── src/
│   ├── main.rs              # clap parse + command dispatch
│   ├── lib.rs               # pub exports for integration tests
│   ├── client.rs            # ApiClient (reqwest + bearer auth)
│   ├── output.rs            # Format enum, print_list, print_detail, check_status
│   ├── config.rs            # Profile, profiles.toml, config_dir
│   ├── auth.rs              # Keycloak ROPC login, token storage, refresh
│   └── cmd/
│       ├── mod.rs
│       ├── login.rs          # canopy login
│       ├── token.rs          # canopy token show|refresh
│       ├── completion.rs     # canopy completion <shell>
│       ├── person.rs         # canopy person create|list|get|update|delete
│       ├── household.rs      # canopy household create|get|add-member|remove-member
│       ├── rules.rs          # canopy rules list|get|create|update|delete|import|evaluate
│       ├── application.rs    # canopy application create|list|get|update|withdraw
│       ├── eligibility.rs    # canopy eligibility determine|get|list
│       └── security.rs       # canopy security events|alerts|nist-controls
└── tests/
    └── cli_test.rs

Profile Configuration

# ~/.config/canopy/profiles.toml

[default]
keycloak_url = "http://localhost:8180"
keycloak_realm = "canopy"
keycloak_client_id = "canopy-api"
persons_url = "http://localhost:8002"
applications_url = "http://localhost:8003"
eligibility_url = "http://localhost:8004"
rules_url = "http://localhost:8001"
security_url = "http://localhost:8012"
web_url = "http://localhost:8080"

ApiClient

// SPDX-License-Identifier: AGPL-3.0-or-later

pub struct ApiClient {
    client: reqwest::Client,
    base_url: String,
    token: String,
}

impl ApiClient {
    pub fn new(base_url: &str, token: &str) -> Self { ... }
    pub async fn get(&self, path: &str) -> Result<(StatusCode, Value)> { ... }
    pub async fn get_with_query<Q: Serialize>(&self, path: &str, query: &Q) -> Result<(StatusCode, Value)> { ... }
    pub async fn post<B: Serialize>(&self, path: &str, body: &B) -> Result<(StatusCode, Value)> { ... }
    pub async fn put<B: Serialize>(&self, path: &str, body: &B) -> Result<(StatusCode, Value)> { ... }
    pub async fn delete(&self, path: &str) -> Result<(StatusCode, Value)> { ... }
}

Output Formatting

#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum Format {
    Table,
    Json,
}

pub fn check_status(status: StatusCode, body: &Value) -> Result<()> { ... }
pub fn print_list(format: Format, body: &Value, columns: &[&str]) -> Result<()> { ... }
pub fn print_detail(format: Format, body: &Value) -> Result<()> { ... }

Table output uses the tabled crate. JSON output is raw API response for piping to jq.

Steps

Step 1: Scaffold canopy-cli

Files:

  • tools/canopy-cli/Cargo.toml (new)

  • tools/canopy-cli/src/main.rs (new)

  • tools/canopy-cli/src/lib.rs (new)

  • tools/canopy-cli/src/client.rs (new)

  • tools/canopy-cli/src/output.rs (new)

  • tools/canopy-cli/src/config.rs (new)

  • tools/canopy-cli/src/cmd/mod.rs (new)

  • Cargo.toml (workspace members)

Port from d:/code/craig/services/craig-cli/:

  • client.rs — adapt ApiClient for Canopy service URLs

  • output.rs — copy Format, check_status, print_list, print_detail, print_kv, print_table verbatim

  • config.rs — adapt Profile struct for Canopy services (persons_url, rules_url, etc.), change config dir to ~/.config/canopy/

Add workspace dependencies: tabled, dirs, toml (for profile serialization).

Add tools/canopy-cli to workspace members in root Cargo.toml.

Verify: cargo check -p canopy-cli compiles with empty command dispatch.

Step 2: Auth commands (login, token, completion)

Files:

  • tools/canopy-cli/src/auth.rs (new)

  • tools/canopy-cli/src/cmd/login.rs (new)

  • tools/canopy-cli/src/cmd/token.rs (new)

  • tools/canopy-cli/src/cmd/completion.rs (new)

Port from CRAIG’s auth.rs and cmd/login.rs:

  • canopy login — prompt for username/password, acquire Keycloak token via ROPC grant, store in ~/.config/canopy/tokens/{profile}.json

  • canopy token show — display current token (masked by default, --raw for full token)

  • canopy token refresh — refresh the stored token using the refresh_token

  • canopy completion <shell> — generate shell completion script via clap_complete

Token auto-refresh: before each API call, check token expiry. If expired, attempt refresh. If refresh fails, prompt for re-login.

Step 3: Person and household commands

Files:

  • tools/canopy-cli/src/cmd/person.rs (new)

  • tools/canopy-cli/src/cmd/household.rs (new)

Commands map 1:1 to canopy-persons API endpoints:

Command API Call

canopy person create --first-name X --last-name Y --dob YYYY-MM-DD

POST /v1/persons

canopy person list [--search X] [--limit N] [--offset N]

GET /v1/persons

canopy person get <id>

GET /v1/persons/{id}

canopy person update <id> [--first-name X] [--last-name Y]

PUT /v1/persons/{id}

canopy person delete <id>

DELETE /v1/persons/{id}

canopy person add-income <person-id> --type wages --amount 2500 --frequency monthly

POST /v1/persons/{id}/income

canopy person add-asset <person-id> --type bank_account --value 1500

POST /v1/persons/{id}/assets

canopy person add-expense <person-id> --type shelter --amount 800 --frequency monthly

POST /v1/persons/{id}/expenses

canopy person add-address <person-id> --type residential --line1 "123 Main" --city Atlanta --state GA --zip 30301

POST /v1/persons/{id}/addresses

canopy household create --effective-date 2026-04-01

POST /v1/households

canopy household get <id>

GET /v1/households/{id}

canopy household add-member <household-id> --person-id <id> --relationship head_of_household

POST /v1/households/{id}/members

canopy household remove-member <household-id> <member-id>

DELETE /v1/households/{hid}/members/{mid}

Table output columns for person list: id, first_name, last_name, date_of_birth, active, created_at.

Step 4: Rules commands

Files: tools/canopy-cli/src/cmd/rules.rs (new)

Command API Call

canopy rules list [--limit N]

GET /v1/rulesets

canopy rules get <name>

GET /v1/rulesets/{name}

canopy rules create --name X --content @file.json

POST /v1/rulesets

canopy rules update <name> --content @file.json

PUT /v1/rulesets/{name}

canopy rules delete <name>

DELETE /v1/rulesets/{name}

canopy rules import --dir rulesets/georgia/

POST /v1/rulesets/import

canopy rules evaluate <name> --input '{"income": 1500}'

POST /v1/rulesets/{name}/evaluate

The --content @file.json pattern reads from file (like curl’s `@ prefix).

Step 5: Application commands

Files: tools/canopy-cli/src/cmd/application.rs (new)

Command API Call

canopy application create --household-id X --programs snap,tanf

POST /v1/applications

canopy application list [--status submitted] [--limit N]

GET /v1/applications

canopy application get <id>

GET /v1/applications/{id}

canopy application withdraw <id>

POST /v1/applications/{id}/withdraw

Step 6: Eligibility and determination commands

Files: tools/canopy-cli/src/cmd/eligibility.rs (new)

Command API Call

canopy eligibility determine <application-id> --programs snap

POST /v1/eligibility/determine

canopy eligibility get <determination-id>

GET /v1/eligibility/determinations/{id}

canopy eligibility list [--household-id X] [--program snap]

GET /v1/eligibility/determinations

Step 7: Security and audit commands

Files: tools/canopy-cli/src/cmd/security.rs (new)

Command API Call

canopy security events [--source canopy-persons] [--since 2026-04-01] [--limit N]

GET /v1/security/events

canopy security event <id>

GET /v1/security/events/{id}

canopy security alerts [--status open]

GET /v1/security/alerts

canopy security nist-controls

GET /v1/security/nist-controls

canopy security summary

GET /v1/security/summary

Step 8: Integration tests

Files: tools/canopy-cli/tests/cli_test.rs (new)

Using the CLI library crate (not shelling out to the binary):

  • Verify canopy person create + canopy person get roundtrip

  • Verify canopy person list --search returns correct results

  • Verify canopy household create + add-member + get returns household with members

  • Verify canopy rules evaluate returns expected output for test ruleset

  • Verify --format json outputs valid JSON

  • Verify --format table outputs human-readable table

  • Verify unauthenticated request returns 401

  • All tests self-skip when devstack is not available

Files Touched

File Change

tools/canopy-cli/Cargo.toml

New: CLI binary and library crate

tools/canopy-cli/src/main.rs

New: clap parse and command dispatch

tools/canopy-cli/src/lib.rs

New: public exports for integration tests

tools/canopy-cli/src/client.rs

New: ApiClient (reqwest + bearer auth)

tools/canopy-cli/src/output.rs

New: Format enum, print_list, print_detail, check_status

tools/canopy-cli/src/config.rs

New: Profile, profiles.toml, config_dir

tools/canopy-cli/src/auth.rs

New: Keycloak ROPC login, token storage

tools/canopy-cli/src/cmd/*.rs

New: one module per service domain (10 files)

tools/canopy-cli/tests/cli_test.rs

New: integration tests

Cargo.toml

Add canopy-cli to workspace members, add tabled/dirs/toml deps

Verification

  1. cargo build -p canopy-cli — binary compiles

  2. canopy --help shows all subcommands

  3. canopy completion bash generates valid bash completions

  4. canopy login acquires a Keycloak token against devstack

  5. canopy person create --first-name Test --last-name User --dob 2000-01-01 --format json returns 201 with person ID

  6. canopy person list --format table renders a table

  7. canopy rules evaluate snap-eligibility --input '{}' --format json returns evaluation result

  8. Integration tests pass with devstack running

Documentation Updates

  • .claude/CLAUDE.md — add canopy-cli to Feature Status table

  • .claude/docs/services.md — add CLI tool entry

  • CHANGELOG.adoc — entry under == Unreleased

  • docs/modules/ROOT/pages/developer-guide.adoc — add CLI usage section

  • docs/modules/ROOT/pages/implementation-guide.adoc — reference CLI parity requirement

Edit this page · default