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.rs—ApiClientwith bearer auth,get/post/put/delete+ query param support -
src/output.rs—Format::Table | Format::Json,print_list/print_detail/print_kv/check_status -
src/config.rs—Profilestruct,~/.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|jsonglobal flag -
--profileglobal 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— adaptApiClientfor Canopy service URLs -
output.rs— copyFormat,check_status,print_list,print_detail,print_kv,print_tableverbatim -
config.rs— adaptProfilestruct 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,--rawfor full token) -
canopy token refresh— refresh the stored token using the refresh_token -
canopy completion <shell>— generate shell completion script viaclap_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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
|
|
|
|
|
|
|
|
Step 6: Eligibility and determination commands
Files: tools/canopy-cli/src/cmd/eligibility.rs (new)
| Command | API Call |
|---|---|
|
|
|
|
|
|
Step 7: Security and audit commands
Files: tools/canopy-cli/src/cmd/security.rs (new)
| Command | API Call |
|---|---|
|
|
|
|
|
|
|
|
|
|
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 getroundtrip -
Verify
canopy person list --searchreturns correct results -
Verify
canopy household create+add-member+getreturns household with members -
Verify
canopy rules evaluatereturns expected output for test ruleset -
Verify
--format jsonoutputs valid JSON -
Verify
--format tableoutputs human-readable table -
Verify unauthenticated request returns 401
-
All tests self-skip when devstack is not available
Files Touched
| File | Change |
|---|---|
|
New: CLI binary and library crate |
|
New: clap parse and command dispatch |
|
New: public exports for integration tests |
|
New: ApiClient (reqwest + bearer auth) |
|
New: Format enum, print_list, print_detail, check_status |
|
New: Profile, profiles.toml, config_dir |
|
New: Keycloak ROPC login, token storage |
|
New: one module per service domain (10 files) |
|
New: integration tests |
|
Add canopy-cli to workspace members, add tabled/dirs/toml deps |
Verification
-
cargo build -p canopy-cli— binary compiles -
canopy --helpshows all subcommands -
canopy completion bashgenerates valid bash completions -
canopy loginacquires a Keycloak token against devstack -
canopy person create --first-name Test --last-name User --dob 2000-01-01 --format jsonreturns 201 with person ID -
canopy person list --format tablerenders a table -
canopy rules evaluate snap-eligibility --input '{}' --format jsonreturns evaluation result -
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