From 6513bb3faf9b92ced4c725b2e4aa3304cdf9c2a4 Mon Sep 17 00:00:00 2001 From: stephenleo Date: Mon, 27 Jul 2026 09:08:00 +0800 Subject: [PATCH] feat(config): publish JSON schema for editor autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `cship config-schema` subcommand (via schemars) that emits a JSON Schema for the `[cship]` config section, and publishes it as docs/public/config-schema.json so it's served at https://cship.dev/config-schema.json — usable via `"$schema" = 'https://cship.dev/config-schema.json'` in cship.toml / starship.toml. CI diffs the committed schema against a fresh generation to catch drift. Closes #196 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 3 + Cargo.lock | 108 ++- Cargo.toml | 1 + docs/configuration.md | 10 + docs/public/config-schema.json | 1194 ++++++++++++++++++++++++++++++++ src/config.rs | 51 +- src/main.rs | 5 + tests/cli.rs | 20 + 8 files changed, 1358 insertions(+), 34 deletions(-) create mode 100644 docs/public/config-schema.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1de31c..98c8725 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,6 @@ jobs: run: cargo test - name: Release build run: cargo build --release + - name: Check config schema is up to date + if: matrix.os == 'ubuntu-latest' + run: cargo run --release -- config-schema | diff - docs/public/config-schema.json diff --git a/Cargo.lock b/Cargo.lock index c7e2fec..1ee5432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,7 +196,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -268,6 +268,7 @@ dependencies = [ "nu-ansi-term", "predicates", "rstest", + "schemars", "serde", "serde_json", "tempfile", @@ -303,7 +304,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -315,6 +316,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "equivalent" version = "1.0.2" @@ -402,7 +409,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -855,7 +862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -900,6 +907,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex" version = "1.12.3" @@ -974,7 +1001,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.117", "unicode-ident", ] @@ -1041,6 +1068,31 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "semver" version = "1.0.27" @@ -1074,7 +1126,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1162,6 +1225,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1170,7 +1244,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1219,7 +1293,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1342,7 +1416,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1547,7 +1621,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1624,7 +1698,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1635,7 +1709,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1783,7 +1857,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1799,7 +1873,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1866,7 +1940,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1887,7 +1961,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1927,7 +2001,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b682c81..0b01481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } nu-ansi-term = "0.50" chrono = { version = "0.4", default-features = false, features = ["clock"] } unicode-width = "0.2" +schemars = "1" # Terminal-width detection for `$fill` right-alignment. Unix only: the statusline # child has no tty of its own, so we walk up to an ancestor's controlling tty and diff --git a/docs/configuration.md b/docs/configuration.md index 6b6c00c..7836fa3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,16 @@ CShip is configured via a TOML file. The config discovery order is: The recommended file is `~/.config/cship.toml`. +## Editor Schema + +Add a `$schema` key to get autocomplete and validation for the `[cship]` section in editors that support JSON Schema for TOML (e.g. VS Code's [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml)): + +```toml +"$schema" = 'https://cship.dev/config-schema.json' +``` + +Run `cship config-schema` to print the same schema locally. + ## Layout The `[cship]` section controls the overall layout: diff --git a/docs/public/config-schema.json b/docs/public/config-schema.json new file mode 100644 index 0000000..942a050 --- /dev/null +++ b/docs/public/config-schema.json @@ -0,0 +1,1194 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "cship config schema", + "description": "Schema for the `[cship]` section of starship.toml / cship.toml. See https://cship.dev/configuration for field docs.", + "type": "object", + "properties": { + "cship": { + "anyOf": [ + { + "$ref": "#/$defs/CshipConfig" + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "CshipConfig": { + "description": "Root configuration for CShip, loaded from the `[cship]` section of `starship.toml`.", + "type": "object", + "properties": { + "lines": { + "description": "`lines` array — each element is a format string for one statusline row.\nExample: `[\"$cship.model $git_branch\", \"$cship.cost\"]`", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "format": { + "description": "Starship-compatible top-level format string. Split on `$line_break` to produce\nmultiple rows. Takes priority over `lines` when both are set.", + "type": [ + "string", + "null" + ] + }, + "model": { + "description": "Configuration for the `[cship.model]` section.", + "anyOf": [ + { + "$ref": "#/$defs/ModelConfig" + }, + { + "type": "null" + } + ] + }, + "cost": { + "anyOf": [ + { + "$ref": "#/$defs/CostConfig" + }, + { + "type": "null" + } + ] + }, + "context_bar": { + "anyOf": [ + { + "$ref": "#/$defs/ContextBarConfig" + }, + { + "type": "null" + } + ] + }, + "context_window": { + "anyOf": [ + { + "$ref": "#/$defs/ContextWindowConfig" + }, + { + "type": "null" + } + ] + }, + "vim": { + "anyOf": [ + { + "$ref": "#/$defs/VimConfig" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "$ref": "#/$defs/AgentConfig" + }, + { + "type": "null" + } + ] + }, + "effort": { + "anyOf": [ + { + "$ref": "#/$defs/EffortConfig" + }, + { + "type": "null" + } + ] + }, + "session": { + "anyOf": [ + { + "$ref": "#/$defs/SessionConfig" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceConfig" + }, + { + "type": "null" + } + ] + }, + "usage_limits": { + "anyOf": [ + { + "$ref": "#/$defs/UsageLimitsConfig" + }, + { + "type": "null" + } + ] + }, + "peak_usage": { + "anyOf": [ + { + "$ref": "#/$defs/PeakUsageConfig" + }, + { + "type": "null" + } + ] + }, + "starship_prompt": { + "anyOf": [ + { + "$ref": "#/$defs/StarshipPromptConfig" + }, + { + "type": "null" + } + ] + }, + "account": { + "anyOf": [ + { + "$ref": "#/$defs/AccountConfig" + }, + { + "type": "null" + } + ] + }, + "fill": { + "description": "Configuration for the `$fill` layout token (`[cship.fill]`).", + "anyOf": [ + { + "$ref": "#/$defs/FillConfig" + }, + { + "type": "null" + } + ] + }, + "width": { + "description": "Fallback terminal width (columns) used by `$fill` when auto-detection\nfails (e.g. Windows, or the web/desktop app). Auto-detection from the\ncontrolling terminal takes priority when available; this is the next\nfallback before the built-in default of 80.", + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0, + "maximum": 65535 + }, + "width_offset": { + "description": "Columns Claude Code reserves around the statusline, subtracted from the\ndetected/configured terminal width to get the usable `$fill` width.\nDefaults to 3 (≈2 on the left, 1 on the right).", + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0, + "maximum": 65535 + } + } + }, + "ModelConfig": { + "description": "Per-module config fields shared by all native CShip modules.\nThese map to `[cship.model]` in `starship.toml`.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "description": "When `true`, prepends the module name as a label.", + "type": [ + "boolean", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "haiku_style": { + "type": [ + "string", + "null" + ] + }, + "sonnet_style": { + "type": [ + "string", + "null" + ] + }, + "opus_style": { + "type": [ + "string", + "null" + ] + }, + "family_style": { + "description": "Generic per-family styles, keyed by substring to match against `model.id`\n(fallback: `display_name`). Lets users style new model families (e.g. `fable`)\nwithout a cship code change.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "CostConfig": { + "description": "Configuration for `[cship.cost]` — convenience alias for total cost display.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "description": "Reserved — not yet rendered; included for config schema consistency with other modules.", + "type": [ + "string", + "null" + ] + }, + "warn_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "warn_style": { + "type": [ + "string", + "null" + ] + }, + "critical_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "critical_style": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "currency_symbol": { + "description": "Currency symbol to display instead of `$`. Defaults to `$`.\nExample: `\"£\"` or `\"€\"`.", + "type": [ + "string", + "null" + ] + }, + "conversion_rate": { + "description": "Multiplier applied to `total_cost_usd` before display. Defaults to `1.0` (no conversion).\nNote: `warn_threshold` and `critical_threshold` are compared against the converted value\n(`total_cost_usd * conversion_rate`) — configure them in your display currency.\nShould be positive; non-positive values are accepted but produce undefined threshold behavior.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "total_cost_usd": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "total_duration_ms": { + "description": "`total_duration` is an accepted alias — the rendered value is human-readable, not raw ms.\nDefine one or the other in TOML; if both are present, serde uses the last-parsed one.", + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "total_api_duration_ms": { + "description": "`total_api_duration` is an accepted alias — same rationale as `total_duration`.", + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "total_lines_added": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "total_lines_removed": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + } + } + }, + "SubfieldConfig": { + "description": "Unified configuration for individual sub-field modules\n(e.g. `[cship.cost.total_cost_usd]`, `[cship.context_window.used_percentage]`).", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "warn_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "warn_style": { + "type": [ + "string", + "null" + ] + }, + "critical_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "critical_style": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "invert_threshold": { + "description": "When `true`, fires threshold styles when value is BELOW the threshold.\nUse for decreasing-health indicators like `remaining_percentage` (low = bad).\n\n**Threshold resolution when `invert_threshold = true`:**\n- `warn_threshold`, `warn_style`, `critical_threshold`, and `critical_style` are resolved\n from **this sub-field config only** — parent [`ContextWindowConfig`] values are NOT\n inherited. Rationale: parent thresholds live in the non-inverted domain (high = bad),\n while this sub-field treats low as bad. Inheriting parent thresholds would invert the\n semantics incorrectly.\n- Base `style` **still falls back to the parent** [`ContextWindowConfig`]`.style` when not\n set on the sub-field. The style fallback is domain-independent and safe to inherit.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "ContextBarConfig": { + "description": "Configuration for `[cship.context_bar]` — visual progress bar with thresholds.\nImplemented in Story 2.2. Defined here so all Epic 2 config is available.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "warn_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "warn_style": { + "type": [ + "string", + "null" + ] + }, + "critical_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "critical_style": { + "type": [ + "string", + "null" + ] + }, + "width": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "empty_style": { + "description": "Style applied when rendering the bar at 0% due to absent context data.\nExample: `\"dim\"` to visually distinguish the empty state.", + "type": [ + "string", + "null" + ] + }, + "filled_char": { + "description": "Character used for filled (used) slots. Defaults to `\"█\"`.\nExample: `\"●\"` for filled circles.", + "type": [ + "string", + "null" + ] + }, + "empty_char": { + "description": "Character used for empty (unused) slots. Defaults to `\"░\"`.\nExample: `\"○\"` for hollow circles.", + "type": [ + "string", + "null" + ] + } + } + }, + "ContextWindowConfig": { + "description": "Configuration for `[cship.context_window]` sub-field modules.\nImplemented in Story 2.2. Defined here so all Epic 2 config is available.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "warn_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "warn_style": { + "type": [ + "string", + "null" + ] + }, + "critical_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "critical_style": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "used_percentage": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "remaining_percentage": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "total_input_tokens": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "total_output_tokens": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "current_usage_input_tokens": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "current_usage_output_tokens": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "current_usage_cache_creation_input_tokens": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "current_usage_cache_read_input_tokens": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + }, + "used_tokens": { + "anyOf": [ + { + "$ref": "#/$defs/SubfieldConfig" + }, + { + "type": "null" + } + ] + } + } + }, + "VimConfig": { + "description": "Configuration for `[cship.vim]` — vim mode display.\nImplemented in Story 2.3. Defined here so all Epic 2 config is available.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "normal_style": { + "type": [ + "string", + "null" + ] + }, + "insert_style": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + } + } + }, + "AgentConfig": { + "description": "Configuration for `[cship.agent]` — agent name display.\nImplemented in Story 2.3. Defined here so all Epic 2 config is available.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + } + } + }, + "EffortConfig": { + "description": "Configuration for `[cship.effort]` — reasoning effort level display.\n\nPer-level styles (`low_style`, `medium_style`, …) are matched against the\neffort level (`low`/`medium`/`high`/`xhigh`/`max`); each falls back to the\nbase `style` when unset, mirroring `[cship.model]`'s per-family styles.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "low_style": { + "type": [ + "string", + "null" + ] + }, + "medium_style": { + "type": [ + "string", + "null" + ] + }, + "high_style": { + "type": [ + "string", + "null" + ] + }, + "xhigh_style": { + "type": [ + "string", + "null" + ] + }, + "max_style": { + "type": [ + "string", + "null" + ] + } + } + }, + "SessionConfig": { + "description": "Configuration for session identity modules (cwd, session_id, transcript_path, etc.).\nImplemented in Story 2.4. Defined here so all Epic 2 config is available.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + } + } + }, + "WorkspaceConfig": { + "description": "Configuration for workspace modules (workspace.current_dir, workspace.project_dir).\nImplemented in Story 2.4. Defined here so all Epic 2 config is available.", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + } + } + }, + "UsageLimitsConfig": { + "description": "Configuration for `[cship.usage_limits]`.\nStory 5.1 defines the struct; Stories 5.2 and 5.3 implement the render logic.", + "type": "object", + "properties": { + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "style": { + "type": [ + "string", + "null" + ] + }, + "warn_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "warn_style": { + "type": [ + "string", + "null" + ] + }, + "critical_threshold": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "critical_style": { + "type": [ + "string", + "null" + ] + }, + "ttl": { + "description": "Cache refresh interval in seconds. Default: 60.\nIncrease to reduce API pressure with many concurrent sessions.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "format": { + "description": "Reserved — not yet rendered. Use `five_hour_format`, `seven_day_format`,\nand `separator` for per-section format control.", + "type": [ + "string", + "null" + ] + }, + "five_hour_format": { + "type": [ + "string", + "null" + ] + }, + "seven_day_format": { + "type": [ + "string", + "null" + ] + }, + "separator": { + "type": [ + "string", + "null" + ] + }, + "show_per_model": { + "description": "When `true`, `$cship.usage_limits` appends per-model breakdowns (opus, sonnet,\ncowork, oauth_apps) to the default `5h | 7d` output. The extra-usage section\nrenders unconditionally whenever the account has extra-usage data enabled.\nDefaults to `false` to preserve the pre-7.2 output shape `\"5h: X% | 7d: X%\"`.\nUsers who want the richer output set `show_per_model = true`, or reference the\ndedicated tokens (`$cship.usage_limits.opus`, `.sonnet`, etc.) directly — those\ntokens always render regardless of this flag.", + "type": [ + "boolean", + "null" + ] + }, + "extra_usage_format": { + "description": "Format string for extra usage display. Shown when extra_usage.is_enabled is true.\nPlaceholders: {active}, {pct}, {used}, {limit}, {remaining_credits}\n`{pct}` is the integer percentage of extra-usage budget consumed; `{used}`,\n`{limit}`, and `{remaining_credits}` render as dollar amounts with two\ndecimal places (the API reports cents; cship divides by 100 for display).\n`{remaining_credits}` is named distinctly from the percentage-based `{remaining}`\nused in other format strings to avoid silent misinterpretation.\nDefault: \"{active} extra: {pct}% (${used}/${limit})\"", + "type": [ + "string", + "null" + ] + }, + "opus_format": { + "description": "Format string for 7-day Opus breakdown. Shown when API returns non-null data.\nPlaceholders: {pct}, {reset}, {remaining}, {pace}\nDefault: \"opus {pct}%\"", + "type": [ + "string", + "null" + ] + }, + "sonnet_format": { + "description": "Format string for 7-day Sonnet breakdown.\nPlaceholders: {pct}, {reset}, {remaining}, {pace}\nDefault: \"sonnet {pct}%\"", + "type": [ + "string", + "null" + ] + }, + "cowork_format": { + "description": "Format string for 7-day Cowork breakdown.\nPlaceholders: {pct}, {reset}, {remaining}, {pace}\nDefault: \"cowork {pct}%\"", + "type": [ + "string", + "null" + ] + }, + "oauth_apps_format": { + "description": "Format string for 7-day OAuth apps breakdown.\nPlaceholders: {pct}, {reset}, {remaining}, {pace}\nDefault: \"oauth {pct}%\"", + "type": [ + "string", + "null" + ] + } + } + }, + "PeakUsageConfig": { + "description": "Configuration for `[cship.peak_usage]` — peak-time indicator.\nShows when Anthropic's peak-time rate limiting is likely active\nbased on current time relative to US Pacific business hours.", + "type": "object", + "properties": { + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "style": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "start_hour": { + "description": "Start of peak window in US Pacific time (0–23). Default: 7.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "end_hour": { + "description": "End of peak window in US Pacific time, exclusive (0–24). Default: 17.\nUse 24 to mean \"through end of day\" (e.g., `start_hour = 0, end_hour = 24` = all day).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + } + } + }, + "StarshipPromptConfig": { + "description": "Configuration for `[cship.starship_prompt]` — renders full starship prompt as a token.", + "type": "object", + "properties": { + "disabled": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "AccountConfig": { + "description": "Configuration for `[cship.account]` — displays the currently authenticated Anthropic account.\n\nSources account + organization metadata from the `/api/oauth/profile` endpoint so users\ncan see whether they're on their work or personal Claude account at a glance. Opt-in\nlabel mapping lets users hide raw org names / emails behind friendly labels.\n\n## Format placeholders\n- `{label}` — resolved user label (from `labels` map, keyed by organization name).\n Falls back to `{organization}` when no mapping matches.\n- `{organization}` — raw organization `name` from the API (e.g. `\"Fulcrum Genomics\"`).\n- `{display_name}` — account `display_name` (e.g. `\"Nils\"`).\n- `{email}` — account `email` (PII; opt in explicitly).\n- `{tier}` — organization `rate_limit_tier` (e.g. `\"default_claude_max_5x\"`).\n- `{type}` — organization `organization_type` (e.g. `\"claude_team\"`, `\"personal\"`).", + "type": "object", + "properties": { + "style": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": [ + "string", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "ttl": { + "description": "Cache TTL in seconds. Default: 86400 (24 hours). Profile data rarely changes.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "labels": { + "description": "Opt-in mapping from raw organization name → user-friendly label.\nExample: `{ \"Fulcrum Genomics\" = \"work\", \"Personal Workspace\" = \"personal\" }`\nWhen a rendered value uses `{label}` and this map is absent or doesn't contain\nthe organization, the raw organization name is used instead.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + "FillConfig": { + "description": "Configuration for the `$fill` layout token (`[cship.fill]`).\n\nMirrors Starship's `[fill]` module: `$fill` expands to fill the remaining\nhorizontal space with `symbol`. Multiple `$fill` tokens on one line split the\nspace evenly, so the content after the last `$fill` is right-aligned.", + "type": "object", + "properties": { + "symbol": { + "description": "Character used to fill the gap. Defaults to `\".\"` (matching Starship).", + "type": [ + "string", + "null" + ] + }, + "style": { + "description": "Style applied to the fill characters. Defaults to `\"bold black\"`.", + "type": [ + "string", + "null" + ] + }, + "disabled": { + "description": "When `true`, the `$fill` token itself renders as nothing. Any literal\nspaces written around it in the format string still remain.", + "type": [ + "boolean", + "null" + ] + } + } + } + } +} diff --git a/src/config.rs b/src/config.rs index 3a4cfdc..7fdc6ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,8 @@ +use schemars::JsonSchema; use serde::Deserialize; /// Root configuration for CShip, loaded from the `[cship]` section of `starship.toml`. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct CshipConfig { /// `lines` array — each element is a format string for one statusline row. /// Example: `["$cship.model $git_branch", "$cship.cost"]` @@ -41,7 +42,7 @@ pub struct CshipConfig { /// Mirrors Starship's `[fill]` module: `$fill` expands to fill the remaining /// horizontal space with `symbol`. Multiple `$fill` tokens on one line split the /// space evenly, so the content after the last `$fill` is right-aligned. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct FillConfig { /// Character used to fill the gap. Defaults to `"."` (matching Starship). pub symbol: Option, @@ -54,7 +55,7 @@ pub struct FillConfig { /// Per-module config fields shared by all native CShip modules. /// These map to `[cship.model]` in `starship.toml`. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct ModelConfig { pub style: Option, pub symbol: Option, @@ -72,7 +73,7 @@ pub struct ModelConfig { } /// Configuration for `[cship.cost]` — convenience alias for total cost display. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct CostConfig { pub style: Option, pub symbol: Option, @@ -107,7 +108,7 @@ pub struct CostConfig { /// Unified configuration for individual sub-field modules /// (e.g. `[cship.cost.total_cost_usd]`, `[cship.context_window.used_percentage]`). -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct SubfieldConfig { pub style: Option, pub symbol: Option, @@ -182,7 +183,7 @@ macro_rules! impl_has_threshold_style { /// Configuration for `[cship.context_bar]` — visual progress bar with thresholds. /// Implemented in Story 2.2. Defined here so all Epic 2 config is available. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct ContextBarConfig { pub style: Option, pub symbol: Option, @@ -207,7 +208,7 @@ pub struct ContextBarConfig { /// Configuration for `[cship.context_window]` sub-field modules. /// Implemented in Story 2.2. Defined here so all Epic 2 config is available. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct ContextWindowConfig { pub style: Option, pub symbol: Option, @@ -257,7 +258,7 @@ impl HasThresholdStyle for ContextWindowConfig { /// Configuration for `[cship.vim]` — vim mode display. /// Implemented in Story 2.3. Defined here so all Epic 2 config is available. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct VimConfig { pub style: Option, pub symbol: Option, @@ -270,7 +271,7 @@ pub struct VimConfig { /// Configuration for `[cship.agent]` — agent name display. /// Implemented in Story 2.3. Defined here so all Epic 2 config is available. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct AgentConfig { pub style: Option, pub symbol: Option, @@ -284,7 +285,7 @@ pub struct AgentConfig { /// Per-level styles (`low_style`, `medium_style`, …) are matched against the /// effort level (`low`/`medium`/`high`/`xhigh`/`max`); each falls back to the /// base `style` when unset, mirroring `[cship.model]`'s per-family styles. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct EffortConfig { pub style: Option, pub symbol: Option, @@ -300,7 +301,7 @@ pub struct EffortConfig { /// Configuration for session identity modules (cwd, session_id, transcript_path, etc.). /// Implemented in Story 2.4. Defined here so all Epic 2 config is available. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct SessionConfig { pub style: Option, pub symbol: Option, @@ -311,7 +312,7 @@ pub struct SessionConfig { /// Configuration for workspace modules (workspace.current_dir, workspace.project_dir). /// Implemented in Story 2.4. Defined here so all Epic 2 config is available. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct WorkspaceConfig { pub style: Option, pub symbol: Option, @@ -322,7 +323,7 @@ pub struct WorkspaceConfig { /// Configuration for `[cship.usage_limits]`. /// Story 5.1 defines the struct; Stories 5.2 and 5.3 implement the render logic. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct UsageLimitsConfig { pub disabled: Option, pub style: Option, @@ -377,7 +378,7 @@ pub struct UsageLimitsConfig { /// Configuration for `[cship.peak_usage]` — peak-time indicator. /// Shows when Anthropic's peak-time rate limiting is likely active /// based on current time relative to US Pacific business hours. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct PeakUsageConfig { pub disabled: Option, pub symbol: Option, @@ -391,7 +392,7 @@ pub struct PeakUsageConfig { } /// Configuration for `[cship.starship_prompt]` — renders full starship prompt as a token. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct StarshipPromptConfig { pub disabled: Option, } @@ -410,7 +411,7 @@ pub struct StarshipPromptConfig { /// - `{email}` — account `email` (PII; opt in explicitly). /// - `{tier}` — organization `rate_limit_tier` (e.g. `"default_claude_max_5x"`). /// - `{type}` — organization `organization_type` (e.g. `"claude_team"`, `"personal"`). -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, JsonSchema)] pub struct AccountConfig { pub style: Option, pub symbol: Option, @@ -547,11 +548,27 @@ pub fn load_with_source( /// Private wrapper so `toml::from_str` can extract `[cship]` sections /// from a full `starship.toml` that contains many other sections. /// Serde silently ignores all non-`cship` top-level keys. -#[derive(Debug, Deserialize, Default)] +/// +/// Also doubles as the root of the published JSON Schema (`schema_json()`) — the +/// `schemars` title/description below describe that public artifact, not this +/// internal wrapper. +#[derive(Debug, Deserialize, Default, JsonSchema)] +#[schemars( + title = "cship config schema", + description = "Schema for the `[cship]` section of starship.toml / cship.toml. See https://cship.dev/configuration for field docs." +)] struct StarshipToml { cship: Option, } +/// Generate the JSON Schema for the `[cship]` config section, for editor +/// autocomplete/validation via `"$schema" = 'https://cship.dev/config-schema.json'`. +/// Published to `docs/public/config-schema.json` — see `cship config-schema`. +pub fn schema_json() -> String { + let schema = schemars::schema_for!(StarshipToml); + serde_json::to_string_pretty(&schema).expect("schema serializes to JSON") +} + /// Load `CshipConfig` from a `starship.toml`-format file at `path`. /// Returns an error if the file cannot be read OR if the TOML is malformed. /// Returns default `CshipConfig` if `[cship]` section is absent (not an error). diff --git a/src/main.rs b/src/main.rs index 8f4e55e..0676c66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,8 @@ enum Commands { Explain, /// Remove cship binary and settings.json entry. Uninstall, + /// Print the JSON schema for the `[cship]` config section (editor autocomplete). + ConfigSchema, } fn main() { @@ -50,6 +52,9 @@ fn main() { Some(Commands::Uninstall) => { cship::uninstall::run(); } + Some(Commands::ConfigSchema) => { + println!("{}", cship::config::schema_json()); + } None => { let ctx = match cship::context::from_stdin() { Ok(ctx) => ctx, diff --git a/tests/cli.rs b/tests/cli.rs index c0f8bba..7676204 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1154,3 +1154,23 @@ fn test_fill_disabled_collapses_to_nothing() { "disabled fill must not render: {stdout:?}" ); } + +#[test] +fn test_config_schema_prints_valid_json_schema() { + let output = cargo_bin_cmd!("cship") + .args(["config-schema"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let schema: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("config-schema output must be valid JSON: {e}\n{stdout}")); + assert!( + schema["properties"]["cship"].is_object(), + "expected top-level cship property in schema: {stdout}" + ); + assert!( + schema["$defs"]["ModelConfig"]["properties"]["family_style"].is_object(), + "expected ModelConfig.family_style in schema $defs: {stdout}" + ); +}