diff --git a/.allium/code-generator.allium b/.allium/code-generator.allium new file mode 100644 index 0000000..96a6d53 --- /dev/null +++ b/.allium/code-generator.allium @@ -0,0 +1,221 @@ +-- allium: 3 +-- code-generator.allium + +-- Scope: OAS-driven code generation pipeline in cmd/generate/ +-- Includes: spec table, command registry (CommandMapping), generation +-- pipeline, coverage validation (checkExhaustive) +-- Grounded in: cmd/generate/main.go, cmd/generate/commands.go +-- Excludes: +-- - Output files: internal/api/*.gen.go, internal/cmd/commands.gen.go +-- - Hand-written extension points: bodyHook and configureFunc +-- implementations (specified in hand-authored-commands.allium) +-- - OAS specification source files (api/specs/) + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum ArtifactKind { type_definitions | client_methods | operation_descriptions | cli_commands } + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +-- One row of the developer-maintained spec table (main.go:36-39). The OAS +-- JSON documents under api/specs/ are external inputs; this table is owned +-- by the generator. +entity OASSpec { + spec_file: String -- OAS source under api/specs/ ("trading-api.json", "market-data-api.json") + prefix: String -- output file prefix ("trading" or "marketdata") + client_name: String -- name of the generated client struct ("Trading", "MarketData") + client_var: String -- package-level client var wired into commands ("tradingClient", "dataClient") + base_url_field: String -- client.Client field with the API base URL ("BaseURL", "DataURL") +} + +entity CommandMapping { + -- Developer-maintained registry entry classifying one OAS operation: + -- cmdRegistry (mapped) or cmdSkip (excluded) in cmd/generate/commands.go. + operation_id: String + disposition: mapped | excluded + parent_command: String? -- key into the cmdParents group table + command_use: String? -- Cobra `Use` string; empty + self=false is invalid + self_command: Boolean -- true: the parent group itself is runnable (attachCmd) + examples: String? -- required when mapped; rendered as CLI help examples + exclusion_reason: String? -- required when excluded; explains why + flag_aliases: Set -- renames body fields that collide with path/query params (bodyAliases) + skip_fields: Set -- body fields handled by a hand-written hook (bodySkipFields) + default_overrides: Set -- per-flag default value overrides (defaults map) + normalized_params: Set -- path params stripped of "/" (normalize list, e.g. BTC/USD → BTCUSD) + body_hook: String? -- hand-written func(cmd, body) run after body construction + configure_func: String? -- hand-written func(cmd) appended to configure closures + + invariant MappedMustHaveExamples { + disposition = mapped implies examples != null + } + + invariant ExcludedMustHaveReason { + disposition = excluded implies exclusion_reason != null + } +} + +entity GeneratedArtifact { + -- A file produced and written by the generator during a run + spec: OASSpec? -- null for combined (cross-spec) artifacts + kind: ArtifactKind + output_path: String + is_gofmt_applied: Boolean +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- Invoked as `go run ./cmd/generate` (Makefile `generate` target). +surface CodeGeneratorCLI { + provides: + RunCodeGenerator() +} + +-- Developers maintain the registry by hand in cmd/generate/commands.go: +-- every new OAS operation must be added to cmdRegistry (mapped) or cmdSkip +-- (excluded) — checkExhaustive's error message says exactly that +-- (commands.go:876). +surface CommandRegistryMaintenance { + provides: + RegisterOperation(operation_id, disposition, examples?, exclusion_reason?) +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule RegisterOperation { + when: RegisterOperation(operation_id, disposition, examples?, exclusion_reason?) + ensures: CommandMapping.created( + operation_id: operation_id, + disposition: disposition, + examples: examples, + exclusion_reason: exclusion_reason + ) +} + +-- Phase 1+2a: per-spec types and clients, plus the combined descriptions +-- file, are written UNCONDITIONALLY — before the command registry is +-- validated (main.go:67-83). +rule GenerateApiArtifacts { + when: RunCodeGenerator() + + ensures: + for spec in OASSpecs: + GeneratedArtifact.created( + spec: spec, + kind: type_definitions, + output_path: type_output_path(spec.prefix), + is_gofmt_applied: true + ) + ensures: + for spec in OASSpecs: + GeneratedArtifact.created( + spec: spec, + kind: client_methods, + output_path: client_output_path(spec.prefix, spec.client_name), + is_gofmt_applied: true + ) + ensures: GeneratedArtifact.created( + spec: null, + kind: operation_descriptions, + output_path: "internal/api/descriptions.gen.go", + is_gofmt_applied: true + ) + + @guidance + -- Type generation: one internal/api/_types.gen.go per spec + -- (main.go:71). Schema name collisions WITHIN a spec are resolved by + -- appending a "V3" suffix; the capitalised variant keeps the clean + -- name and the lowercase variant is renamed (main.go:169-184). + -- + -- Client generation: one _client.gen.go per spec. Each + -- operation becomes a typed method with path injection + -- (url.PathEscape), query-parameter building, and JSON marshaling. + -- Generic unmarshal helpers handle typed and array responses; + -- operations with no response schema return raw JSON + -- (main.go:541-612). Request types with required string fields gain + -- a Validate() method (main.go:489-513, 634-648). + -- + -- Descriptions file: one combined descriptions.gen.go across all + -- specs. Contains one Op variable per operation (Name, Summary, + -- Long, Example, ReturnsArray, Flags), lazily-built response + -- schemas, an AllOps slice, and an OpByName lookup used by the CLI + -- at runtime for flag registration, --schema output and CSV headers + -- (main.go:1192-1324). + -- + -- Formatting: gofmt is applied to every artifact; if formatting + -- fails, the file is still written UNFORMATTED and a warning goes to + -- stderr — generation does not abort (main.go:1354-1363). +} + +-- Phase 2b: the combined Cobra command tree is gated on registry coverage. +-- checkExhaustive runs after the API artifacts are written and aborts the +-- process before commands.gen.go is touched (main.go:90-92, +-- commands.go:868-915). +rule GenerateCommandTree { + when: RunCodeGenerator() + + -- no registry entry references an operation missing from the specs + let stale_mappings = CommandMappings where not operation_exists_in_specs(operation_id) + + -- every operation_id found in the OAS specs has a registry entry + requires: all_endpoints_registered() + requires: stale_mappings.count = 0 + + ensures: GeneratedArtifact.created( + spec: null, + kind: cli_commands, + output_path: "internal/cmd/commands.gen.go", + is_gofmt_applied: true + ) + + @guidance + -- checkExhaustive collects ALL violations, then aborts with the full + -- list (commands.go:912-914). The three violation classes: + -- 1. an operation_id present in an OAS spec but in neither + -- cmdRegistry nor cmdSkip (commands.go:871-878); + -- 2. a mapped entry with empty examples (commands.go:881-884) — + -- this machine-enforces MappedMustHaveExamples; + -- 3. a body field whose kebab-case flag name collides with a + -- path/query parameter and has no flag_aliases entry + -- (commands.go:885-910). + -- A registry entry referencing an operation that no longer exists in + -- any spec is also fatal, at emit time (commands.go:948-951). + -- ExcludedMustHaveReason holds by construction (cmdSkip maps + -- operation → reason string) but is not machine-checked for + -- non-emptiness. + -- + -- Commands file contents: parent command group variables + -- (cmdParents), a fetchCmd/attachCmd wiring per mapped operation, + -- POST body constructors, PATCH/PUT body constructors with change + -- tracking (rejecting when no flag was set, commands.go:1323-1326), + -- and a single init() that wires the Cobra tree. Body fields in + -- skip_fields are excluded from generated construction and handled + -- by body_hook; default_overrides bake flag defaults; nullable OAS + -- scalars become pointer fields set only when the flag was + -- explicitly changed (commands.go:1240-1258). +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +invariant UniqueCommandMappings { + for a in CommandMappings: + for b in CommandMappings: + a != b implies a.operation_id != b.operation_id +} + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "Registry validation runs only before commands.gen.go is written; *_types.gen.go, *_client.gen.go and descriptions.gen.go are regenerated even when checkExhaustive subsequently aborts (main.go:67-92), leaving the tree partially regenerated. Intended, or should validation run before any file is written?" + +open question "Schema-name deduplication is applied per spec (main.go:55); goName collisions ACROSS the trading and market-data specs are not disambiguated and would silently overwrite entries in the shared schemaByGoName lookup (main.go:60-65). Is cross-spec collision considered impossible, or should dedup run over the combined schema list?" diff --git a/.allium/credential-resolution.allium b/.allium/credential-resolution.allium new file mode 100644 index 0000000..89e88b2 --- /dev/null +++ b/.allium/credential-resolution.allium @@ -0,0 +1,252 @@ +-- allium: 3 +-- credential-resolution.allium +-- +-- How `config.Load` (internal/config/config.go) resolves the credential +-- bundle, trading environment and base URLs for a CLI invocation. +-- +-- Two independent resolutions happen per invocation: +-- 1. Credentials resolve as an ATOMIC BUNDLE — the first complete source +-- wins and fields are never mixed across sources (config.go:21-23, +-- 72-79, 107-129). +-- 2. Paper-vs-live resolves separately from credentials +-- (config.go:80-88, 131-150). + +use "./hand-authored-commands.allium" as commands + +------------------------------------------------------------ +-- External Entities +------------------------------------------------------------ + +-- Contents of $ALPACA_CONFIG_DIR|~/.config/alpaca/config.yaml +-- (config.go:33-37, 190-201). Governed by the profile-management commands +-- in hand-authored-commands.allium. +external entity GlobalConfig { + default_profile: String? + output: String? + color: String? +} + +-- Process environment. An unset variable and an empty-string variable are +-- indistinguishable: the code tests os.Getenv(...) != "", so "" means absent +-- (config.go:110-111, 135). live_trade_override carries the RAW string value +-- of ALPACA_LIVE_TRADE — it is not a boolean; see EnvLiveTradeIsStrict. +external entity EnvironmentConfig { + api_key: String? + secret_key: String? + live_trade_override: String? + profile: String? + config_dir: String? +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum TradingEnvironment { paper | live } + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + -- config.go:91 (EnvPaper is the final fallback profile name) + system_default_profile: String = "paper" + paper_trading_url: String = "https://paper-api.alpaca.markets" + live_trading_url: String = "https://api.alpaca.markets" + market_data_url: String = "https://data.alpaca.markets" +} + +------------------------------------------------------------ +-- Given +------------------------------------------------------------ + +given { + env: EnvironmentConfig + global_config: GlobalConfig +} + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity CredentialResolution { + profile_name: String + environment: TradingEnvironment + kind: Unauthenticated | EnvironmentAPIKey | ProfileOAuth | ProfileAPIKey + + is_authenticated: kind != Unauthenticated + is_oauth: kind = ProfileOAuth +} + +variant Unauthenticated : CredentialResolution {} + +variant EnvironmentAPIKey : CredentialResolution { + api_key: String + secret_key: String +} + +variant ProfileOAuth : CredentialResolution { + access_token: String + scopes: String? +} + +variant ProfileAPIKey : CredentialResolution { + api_key: String + secret_key: String +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- Every non-profile command triggers resolution once, from the root +-- command's PersistentPreRunE (internal/cmd/root.go:129-196). +surface CommandStartup { + provides: + CredentialsRequested(requested_profile?) + + @guarantee ProfileCommandsSkipResolution + -- Subcommands of `alpaca profile` do not run credential resolution at + -- startup (root.go:141-143); login/logout/switch manage profiles + -- without requiring resolved credentials. + + @guarantee AuthRequiredCommandsValidate + -- Commands other than version, help, completion, update and doctor + -- require an authenticated resolution; an Unauthenticated result + -- aborts with "authentication required" and the hint to run + -- `alpaca profile login` (root.go:172-175, 231-241, config.go:169-174). +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +-- Profile name priority: explicit --profile flag > ALPACA_PROFILE > +-- global config default_profile > system default "paper" (config.go:91). +-- Empty string counts as absent at every level. +rule SelectProfileName { + when: CredentialsRequested(requested_profile?) + let profile_name = + if requested_profile != null: requested_profile + else if env.profile != null: env.profile + else if global_config.default_profile != null: global_config.default_profile + else: config.system_default_profile + ensures: ProfileNameSelected(profile_name) + + @guidance + -- The profile file is read from + -- $ALPACA_CONFIG_DIR|~/.config/alpaca/profiles/.yaml + -- (config.go:64-70, 208-219). A missing OR unparseable profile file + -- behaves identically to an empty profile: resolution falls through + -- to the next source. Parse failures only print a warning on stderr + -- (config.go:214-218); they never abort the command. +} + +-- Highest priority source: environment API key pair. Both fields are +-- required; a partial pair falls through to profile credentials rather than +-- partially authenticating (config.go:113-117). +rule ResolveFromEnvironmentAPIKeys { + when: ProfileNameSelected(profile_name) + requires: env.api_key != null and env.secret_key != null + ensures: EnvironmentAPIKey.created( + profile_name: profile_name, + environment: if env_is_live(env.live_trade_override): live else: paper, + api_key: env.api_key, + secret_key: env.secret_key + ) + + @guidance + -- When credentials come from the environment, the profile's + -- live_trade field is never consulted (config.go:143-144 requires a + -- profile-sourced credential bundle). +} + +-- Second priority: profile OAuth token. Used when no complete environment +-- API key pair is present (config.go:118-121). +rule ResolveFromProfileOAuth { + when: ProfileNameSelected(profile_name) + let profile = commands/Profile{name: profile_name} + requires: (env.api_key = null or env.secret_key = null) + requires: exists profile + requires: profile.access_token != null + ensures: ProfileOAuth.created( + profile_name: profile_name, + environment: + if env.live_trade_override != null: (if env_is_live(env.live_trade_override): live else: paper) + else if profile.live_trade = true: live + else: paper, + access_token: profile.access_token, + scopes: profile.scopes + ) +} + +-- Third priority: profile API key pair. Used when environment keys are +-- absent and the profile has no OAuth token (config.go:122-125). +rule ResolveFromProfileAPIKeys { + when: ProfileNameSelected(profile_name) + let profile = commands/Profile{name: profile_name} + requires: (env.api_key = null or env.secret_key = null) + requires: exists profile + requires: profile.access_token = null + requires: profile.api_key != null and profile.secret_key != null + ensures: ProfileAPIKey.created( + profile_name: profile_name, + environment: + if env.live_trade_override != null: (if env_is_live(env.live_trade_override): live else: paper) + else if profile.live_trade = true: live + else: paper, + api_key: profile.api_key, + secret_key: profile.secret_key + ) +} + +-- Fallback: no complete credential bundle found in any source +-- (config.go:126-127). NOTE: the trading environment is still resolved from +-- ALPACA_LIVE_TRADE — base-URL resolution is independent of whether +-- credentials were found (config.go:134-150). An unauthenticated resolution +-- with ALPACA_LIVE_TRADE=true points at the live URL; requests then fail +-- with an auth error rather than being silently redirected to paper. +rule ResolveWithNoCredentials { + when: ProfileNameSelected(profile_name) + let profile = commands/Profile{name: profile_name} + requires: (env.api_key = null or env.secret_key = null) + requires: + not exists profile + or (profile.access_token = null and (profile.api_key = null or profile.secret_key = null)) + ensures: Unauthenticated.created( + profile_name: profile_name, + environment: if env_is_live(env.live_trade_override): live else: paper + ) +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +-- env_is_live(v) is deliberately strict: only trimmed, case-insensitive +-- "true" selects live. Any other set value — "1", "yes", "false", a typo — +-- forces paper (config.go:152-159). When the variable is set at all, the +-- profile's live_trade field is ignored entirely (config.go:135-142). + +-- The unsafe path always requires explicit opt-in: live is selected only by +-- ALPACA_LIVE_TRADE=true, or by profile.live_trade=true when the credential +-- bundle came from that profile and ALPACA_LIVE_TRADE is unset. +invariant LiveRequiresExplicitOptIn { + for r in CredentialResolutions: + r.environment = live implies + (env_is_live(env.live_trade_override) + or (r.kind != Unauthenticated and r.kind != EnvironmentAPIKey)) +} + +-- Environment-sourced credentials never inherit the profile's live_trade. +invariant EnvCredentialsIgnoreProfileLiveTrade { + for r in CredentialResolutions: + (r.kind = EnvironmentAPIKey and env.live_trade_override = null) + implies r.environment = paper +} + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "Unauthenticated + ALPACA_LIVE_TRADE=true resolves the live base URL (config.go:134-150). Auth-required commands abort before any request, but is pointing an unauthenticated invocation at the live URL intended, or should the paper default apply when no credentials resolve?" diff --git a/.allium/hand-authored-commands.allium b/.allium/hand-authored-commands.allium new file mode 100644 index 0000000..46fb684 --- /dev/null +++ b/.allium/hand-authored-commands.allium @@ -0,0 +1,435 @@ +-- allium: 3 +-- hand-authored-commands.allium + +-- Scope: Hand-authored CLI command logic for the Alpaca CLI +-- Includes: order-submit body hook (bracket legs, time-in-force defaulting, dry run), +-- watchlist by-name asset removal, self-update discovery and upgrade, +-- doctor diagnostics, credential profile management +-- Grounded in: internal/cmd/order.go, internal/cmd/watchlist.go, internal/cmd/update.go, +-- internal/cmd/update_check.go, internal/cmd/doctor.go, internal/cmd/auth.go +-- Excludes: generated Cobra command tree (internal/cmd/commands.gen.go), +-- raw API passthrough (api.go), factory helpers (factory.go), +-- help-all rendering (help_all.go) + +------------------------------------------------------------ +-- External Entities +------------------------------------------------------------ + +-- Broker-side resources; the Alpaca Trading API governs their lifecycle. + +external entity Asset { + symbol: String +} + +external entity BrokerWatchlist { + id: String + name: String + assets: Set +} + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +-- The --take-profit flag accepts either a bare price (shorthand for +-- {"limit_price": }) or a raw JSON object (order.go:59-72). +value TakeProfitLeg { + limit_price: Decimal +} + +-- The --stop-loss flag accepts either a bare price (shorthand for +-- {"stop_price": }) or a raw JSON object (order.go:59-72). +value StopLossLeg { + stop_price: Decimal + limit_price: Decimal? +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +-- Literals match the code exactly: "homebrew" and "goinstall" +-- (internal/cmd/update_check.go:11-13). +enum InstallMethod { homebrew | goinstall } + +enum CredentialSource { env_api_key | oauth | profile_api_key } + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + -- internal/cmd/auth.go:22 (defaultProfileName = config.EnvPaper) + default_profile_name: String = "paper" + -- internal/cmd/update.go:40, internal/cmd/doctor.go:89 + update_check_timeout: Duration = 10.seconds + -- internal/cmd/auth.go:352 (credential validation HTTP client) + validate_timeout: Duration = 10.seconds +} + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +-- A stored connection profile: one YAML file under +-- $ALPACA_CONFIG_DIR|~/.config/alpaca/profiles/.yaml +-- (internal/config/config.go:39-49, 208-243). Field-level shape matches +-- config.Profile. live_trade is a tri-state: absent means paper (safe +-- default), false means explicitly paper, true means live. +entity Profile { + name: String + api_key: String? + secret_key: String? + access_token: String? + scopes: String? + live_trade: Boolean? +} + +entity Order { + symbol: String + side: buy | sell + order_class: simple | bracket + time_in_force: day | gtc | ioc | fok | opg | cls + take_profit: TakeProfitLeg? + stop_loss: StopLossLeg? + + invariant BracketHasLegs { + order_class = bracket implies (take_profit != null or stop_loss != null) + } +} + +-- The structured document printed by `alpaca update --check` +-- (internal/cmd/update.go:50-61). +entity UpdateStatus { + current_version: String + latest_version: String + update_available: Boolean + install_method: InstallMethod + update_command: String +} + +entity DiagnosticReport { + cli_version: String + credential_source: CredentialSource + config_accessible: Boolean + trading_api_reachable: Boolean + data_api_reachable: Boolean + -- null when the GitHub release check itself failed; doctor reports + -- "could not check for updates" without failing (doctor.go:88-98) + update_available: Boolean? +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface AlpacaCLIHandAuthored { + provides: + PlaceOrder(symbol, side, qty?, time_in_force?, take_profit?, stop_loss?, dry_run?) + RemoveWatchlistAssetByName(watchlist_name, symbol) + CheckForUpdate() + UpgradeCli(install_method, confirmed) + RunDiagnostics() + LoginWithOAuth(profile_name?, scopes?) + LoginWithApiKey(profile_name?, api_key, secret_key, live?) + LogoutProfile(profile_name?) + SwitchActiveProfile(profile_name) + + @guarantee NoLiveOAuthLogin + -- `alpaca profile login --live` without --api-key is rejected with an + -- error before any browser flow starts; OAuth login is paper-only + -- (auth.go:47-50). + + @guidance + -- Command mapping: PlaceOrder = `alpaca order submit` (generated + -- command + postOrderHook), RemoveWatchlistAssetByName = + -- `alpaca watchlist remove-by-name`, CheckForUpdate/UpgradeCli = + -- `alpaca update`, RunDiagnostics = `alpaca doctor`, the rest = + -- `alpaca profile login|logout|switch`. +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +-- Order Construction (postOrderHook, internal/cmd/order.go) + +-- Time-in-force defaulting applies only when the user did not pass +-- --time-in-force. Crypto detection is purely syntactic: a "/" in the symbol +-- (e.g. BTC/USD) means crypto. No asset lookup is performed (order.go:30-35). + +rule ConstructBracketOrder { + when: PlaceOrder(symbol, side, time_in_force?, take_profit?, stop_loss?, dry_run?) + + requires: take_profit != null or stop_loss != null + requires: dry_run != true + + let tif = + if time_in_force != null: time_in_force + else if is_crypto_pair_symbol(symbol): gtc + else: day + + ensures: Order.created( + symbol: symbol, + side: side, + order_class: bracket, + time_in_force: tif, + take_profit: take_profit, + stop_loss: stop_loss + ) + + @guidance + -- is_crypto_pair_symbol(s) is strings.Contains(s, "/") (order.go:31). + -- Presence of either leg flag forces order_class to "bracket" + -- (order.go:37-41); the user-supplied order class is overwritten. + -- Leg flags parse as shorthand price or raw JSON (order.go:59-72); + -- invalid JSON aborts the command before submission. +} + +rule ConstructSimpleOrder { + when: PlaceOrder(symbol, side, time_in_force?, take_profit?, stop_loss?, dry_run?) + + requires: take_profit = null and stop_loss = null + requires: dry_run != true + + let tif = + if time_in_force != null: time_in_force + else if is_crypto_pair_symbol(symbol): gtc + else: day + + ensures: Order.created( + symbol: symbol, + side: side, + order_class: simple, + time_in_force: tif + ) +} + +rule DryRunOrder { + when: PlaceOrder(symbol, side, time_in_force?, take_profit?, stop_loss?, dry_run?) + + requires: dry_run = true + + ensures: OrderPreviewDisplayed(symbol: symbol) + + @guidance + -- --dry-run returns the fully constructed request body (including + -- defaulted time-in-force and bracket legs) from the body hook, which + -- short-circuits submission; the body is rendered through the standard + -- output pipeline, so --jq and --csv apply to it (order.go:24-26, + -- commands.gen.go hook wiring, factory.go renderData). No order is + -- submitted. +} + +-- Watchlist Management (internal/cmd/watchlist.go) + +rule RemoveWatchlistAssetByName { + when: RemoveWatchlistAssetByName(watchlist_name, symbol) + + let watchlist = BrokerWatchlist{name: watchlist_name} + + requires: exists watchlist + + ensures: watchlist.assets.remove(Asset{symbol}) + + @guidance + -- Both --name and --symbol are required (watchlist.go:16-18). + -- Implemented as two API calls: resolve the watchlist by name, then + -- remove the symbol by watchlist ID (watchlist.go:20-31). A missing + -- watchlist fails with 'watchlist "" not found' before any + -- mutation. The updated watchlist is rendered on success. +} + +-- Update Discovery and Self-Upgrade (internal/cmd/update.go, update_check.go) + +rule CheckForUpdate { + when: CheckForUpdate() + + ensures: UpdateStatus.created( + current_version: current_version(), + latest_version: latest_published_version(), + update_available: version_newer(latest_published_version(), current_version()), + install_method: detect_install_method(), + update_command: upgrade_command(detect_install_method()) + ) + + @guidance + -- latest_published_version() queries the GitHub releases API with a + -- config.update_check_timeout deadline (update.go:136-159). + -- version_newer compares numeric major.minor.patch, strips a leading + -- "v" and anything after "-" (pre-release), and is strictly greater + -- (update_check.go:55-78). + -- detect_install_method(): resolved executable path containing + -- "/Cellar/" or "/homebrew/" means homebrew; every other outcome is + -- goinstall (update_check.go:15-44 — the GOBIN/GOPATH branches also + -- return goinstall). + -- `alpaca update --check` prints this document as indented JSON to + -- stdout with "v" prefixes stripped, and never prompts (update.go:50-61). + -- The bare `alpaca` help output shows a best-effort update notice with + -- a 2-second timeout, silent on error or when current (update.go:120-130). +} + +rule UpgradeCli { + when: UpgradeCli(install_method, confirmed) + + requires: confirmed + + ensures: CliUpgraded(install_method: install_method) + + @guidance + -- confirmed means --yes was passed, or the user answered "y"/"yes" to + -- the interactive prompt (update.go:72-88). In a non-interactive + -- terminal without --yes, the upgrade command is printed but never + -- run, and the command exits successfully (update.go:72-76). + -- The upgrade shells out via `sh -c` to "brew upgrade alpacahq/tap/cli" + -- (homebrew) or "go install github.com/alpacahq/cli/cmd/alpaca@latest" + -- (goinstall), streaming output to the terminal (update.go:98-108, + -- update_check.go:46-53). +} + +-- Doctor Diagnostics (internal/cmd/doctor.go) + +rule RunDiagnostics { + when: RunDiagnostics() + + ensures: DiagnosticReport.created( + cli_version: current_version(), + credential_source: active_credential_source(), + config_accessible: config_accessible(), + trading_api_reachable: trading_api_reachable(), + data_api_reachable: data_api_reachable(), + update_available: version_newer(latest_published_version(), current_version()) + ) + + @guidance + -- Checks run in order: config directory exists, profiles exist, config + -- loads, credentials resolve, trading API GET /v2/account, data API + -- GET /v2/stocks/AAPL/trades/latest (10s client timeout), update check. + -- Exits non-zero ("some checks failed") if any check fails + -- (doctor.go:115-122). + -- A missing config directory or zero saved profiles is NOT a failure + -- when both ALPACA_API_KEY and ALPACA_SECRET_KEY are set + -- (doctor.go:24, 32-51). + -- No resolvable credentials aborts the remaining checks and fails + -- (doctor.go:60-63). + -- A failed update check is reported but does not fail doctor + -- (doctor.go:88-98). + -- Environment variable shadowing of profile credentials is reported as + -- a warning, not as a check failure (doctor.go:68). +} + +-- Credential Profile Management (internal/cmd/auth.go) + +rule LoginWithOAuth { + when: LoginWithOAuth(profile_name?, scopes?) + + let name = if profile_name != null: profile_name else: config.default_profile_name + + ensures: + Profile.created( + name: name, + access_token: granted_access_token(), + scopes: granted_scopes() + ) + ActiveProfileChanged(profile_name: name) + + @guidance + -- OAuth login is paper-only; --live is rejected (auth.go:47-50). + -- Scopes: --scope is comma-separated and converted to space-separated; + -- with no --scope on an interactive terminal, a multi-select prompt + -- offers account:write, trading, data (all pre-selected); otherwise the + -- default is all scopes (auth.go:63-74, 117-121). + -- The granted token is validated with GET /v2/account (Bearer auth) + -- against the paper trading API before the profile is saved, unless + -- --no-validate is passed (auth.go:81-87, 346-365). HTTP 401/403 means + -- invalid credentials; other 4xx/5xx is "unexpected response". + -- The saved profile stores access_token and the granted scope string; + -- live_trade is never written, so OAuth profiles resolve to paper + -- (auth.go:89-92). + -- Login also sets global default_profile to this profile, making it + -- active (auth.go:97-101). +} + +rule LoginWithApiKey { + when: LoginWithApiKey(profile_name?, api_key, secret_key, live?) + + let name = if profile_name != null: profile_name else: config.default_profile_name + + ensures: + Profile.created( + name: name, + api_key: api_key, + secret_key: secret_key, + live_trade: if live = true: true else: null + ) + ActiveProfileChanged(profile_name: name) + + @guidance + -- Supports paper (default) and live (--live); live_trade is written + -- only for live profiles, paper profiles omit the field entirely + -- (auth.go:156-160, 201-207). + -- Missing --key/--secret are prompted interactively; the secret is + -- read without echo on a TTY (auth.go:168-186). Both are required; + -- empty values abort (auth.go:188-190). + -- Passing --secret on the command line prints a shell-history warning + -- (auth.go:163-166). + -- Credentials are validated with GET /v2/account using + -- APCA-API-KEY-ID / APCA-API-SECRET-KEY headers against the selected + -- environment before the profile is saved, unless --no-validate + -- (auth.go:192-199, 346-365). + -- Login also sets global default_profile to this profile + -- (auth.go:212-216). +} + +rule LogoutProfile { + when: LogoutProfile(profile_name?) + + let name = if profile_name != null: profile_name else: config.default_profile_name + let profile = Profile{name: name} + + requires: exists profile + + ensures: not exists profile + + @guidance + -- Deleting a profile that does not exist fails with + -- 'profile "" not found' (auth.go:254-264). +} + +rule SwitchActiveProfile { + when: SwitchActiveProfile(profile_name) + + let profile = Profile{name: profile_name} + + requires: exists profile + + ensures: ActiveProfileChanged(profile_name: profile_name) + + @guidance + -- Switching persists default_profile in the global config file + -- (auth.go:318-322). An unknown name fails, listing available profiles + -- and hinting at `alpaca profile login --name ` (auth.go:310-316). +} + +-- Environment Credential Shadowing + +rule WarnOnEnvCredentialShadowing { + when: profile: Profile.created + + requires: env_credentials_present() + requires: profile.access_token != null + or (profile.api_key != null and profile.secret_key != null) + + ensures: EnvShadowingWarning(profile: profile) + + @guidance + -- Fires only when BOTH ALPACA_API_KEY and ALPACA_SECRET_KEY are set + -- AND the profile holds a complete credential bundle (an access token + -- or a full key pair) — env vars shadowing an empty profile is not + -- warned about (auth.go:225-234). + -- The profile is persisted regardless; the warning informs the user + -- that env vars take precedence at runtime. + -- The same warning is also printed by `alpaca doctor` (doctor.go:68) + -- and around the bare `alpaca` help output (root.go:114-125), not only + -- at profile save time. +} diff --git a/.allium/http-client.allium b/.allium/http-client.allium new file mode 100644 index 0000000..ccbf778 --- /dev/null +++ b/.allium/http-client.allium @@ -0,0 +1,284 @@ +-- allium: 3 +-- http-client.allium +-- +-- Behaviour of the shared HTTP client (internal/client/client.go): auth +-- header selection, retry policy, error shaping, and credential scrubbing +-- of diagnostic output. + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +-- Shape of client.APIError (client.go:44-53). code is the numeric Alpaca +-- API error code parsed from the JSON error body; status is the HTTP +-- status (0 for transport failures). +value RequestError { + status: Integer + code: Integer? + message: String + method: String? + path: String? + request_id: String? + hint: String? +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum HttpMethod { get | post | put | patch | delete } + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + -- client.go:27 (maxRetries = 3): total send attempts, including the first + max_attempts: Integer = 3 + -- client.go:183: base delay is 500ms, doubling per attempt + backoff_base: Duration = 500.milliseconds + -- client.go:96, 103; overridable per invocation with --timeout + -- (root.go:190-192) + default_timeout: Duration = 30.seconds +} + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity HttpClient { + api_key: String? + secret: String? + access_token: String? + base_url: String + data_url: String + timeout: Duration +} + +entity OutboundRequest { + client: HttpClient + method: HttpMethod + path: String + attempt: Integer + status: in_flight | awaiting_retry | succeeded | failed + error: RequestError when status = failed + + transitions status { + in_flight -> awaiting_retry + in_flight -> succeeded + in_flight -> failed + awaiting_retry -> in_flight + terminal: succeeded, failed + } +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- Generated typed client methods (internal/api/*_client.gen.go) and the raw +-- `alpaca api` passthrough all funnel through Client.Do / doWithRetry +-- (client.go:112-148). +surface TypedClientMethods { + provides: + SendRequest(client, method, path, body?) +} + +-- Stimuli from the Alpaca API server and the network. +surface UpstreamResponses { + provides: + ReceiveOkResponse(request) + ReceiveRateLimitResponse(request, retry_after?) + ReceiveServerErrorResponse(request) + ReceiveNonRetryableErrorResponse(request, http_status) + TransportFailure(request, cause) + + @guidance + -- ReceiveOkResponse: any 2xx/3xx (status < 400, client.go:260). + -- ReceiveRateLimitResponse: HTTP 429. + -- ReceiveServerErrorResponse: exactly 500, 502, 503 or 504 + -- (client.go:175-177); 501 and every other 5xx are NOT retryable. + -- ReceiveNonRetryableErrorResponse: any other status >= 400. + -- TransportFailure: DNS/TCP/TLS/timeout errors where no HTTP + -- response was received. +} + +-- The process clock: the retry backoff timer firing. +surface RetryScheduler { + provides: + RetryDelayElapsed(request) +} + +-- Verbose/debug/trace flags (and their ALPACA_VERBOSE / ALPACA_DEBUG / +-- ALPACA_TRACE env equivalents, root.go:152-160) route request/response +-- details to stderr. +surface DiagnosticOutput { + provides: + EmitDiagnosticOutput(content) +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule AuthWithAccessToken { + when: SendRequest(client, method, path, body?) + requires: client.access_token != null + ensures: OutboundRequest.created( + client: client, + method: method, + path: path, + attempt: 1, + status: in_flight + ) + + @guidance + -- Access-token auth sends "Authorization: Bearer " and takes + -- priority over API key/secret when both are configured + -- (client.go:207-208). Every request also carries + -- "User-Agent: alpaca-cli/" (client.go:216) and + -- "Content-Type: application/json" when a body is present + -- (client.go:213-215). +} + +rule AuthWithApiKeyAndSecret { + when: SendRequest(client, method, path, body?) + requires: client.access_token = null + ensures: OutboundRequest.created( + client: client, + method: method, + path: path, + attempt: 1, + status: in_flight + ) + + @guidance + -- Key-pair auth sends the "APCA-API-KEY-ID" and + -- "APCA-API-SECRET-KEY" headers (client.go:209-211). If neither + -- credential is set the headers are sent empty; the server rejects + -- with 401 — the client itself never blocks an unauthenticated send. +} + +rule SucceedOnOkResponse { + when: ReceiveOkResponse(request) + requires: request.status = in_flight + ensures: request.status = succeeded + + @guidance + -- An empty 2xx body yields an empty result rather than a JSON parse + -- error (client.go:284-287); the command layer renders it as {} + -- (factory.go:94-103). +} + +rule RetryOnRateLimitResponse { + when: ReceiveRateLimitResponse(request, retry_after?) + requires: request.status = in_flight + requires: request.attempt < config.max_attempts + ensures: request.status = awaiting_retry + + @guidance + -- Wait for the delay in the Retry-After response header, parsed as + -- integer seconds only (client.go:274-280); an absent or unparseable + -- header falls back to the exponential backoff used for server + -- errors (client.go:179-186). + -- Unless --quiet, a "Rate limited, retrying in ..." notice goes to + -- stderr; with --verbose every retry is announced (client.go:164-169). +} + +rule RetryOnServerErrorResponse { + when: ReceiveServerErrorResponse(request) + requires: request.status = in_flight + requires: request.attempt < config.max_attempts + ensures: request.status = awaiting_retry + + @guidance + -- Backoff before resend: backoff_base * 2^(attempt-1) plus uniform + -- random jitter in [0, delay/2] (client.go:179-186). With the + -- defaults: ~500ms, ~1s, ~2s (each +jitter). +} + +rule ResumeAfterRetryDelay { + when: RetryDelayElapsed(request) + requires: request.status = awaiting_retry + ensures: + request.attempt = request.attempt + 1 + request.status = in_flight +} + +rule FailOnRateLimitMaxAttempts { + when: ReceiveRateLimitResponse(request, _) + requires: request.status = in_flight + requires: request.attempt >= config.max_attempts + ensures: request.status = failed +} + +rule FailOnServerErrorMaxAttempts { + when: ReceiveServerErrorResponse(request) + requires: request.status = in_flight + requires: request.attempt >= config.max_attempts + ensures: request.status = failed +} + +-- 4xx and non-retryable 5xx responses fail immediately, on any attempt. +rule FailOnNonRetryableErrorResponse { + when: ReceiveNonRetryableErrorResponse(request, http_status) + requires: request.status = in_flight + ensures: + request.status = failed + request.error = parse_api_error(http_status) + + @guidance + -- The error body is parsed as JSON into {code, message} + -- (client.go:261-262); a non-JSON or message-less body falls back to + -- the raw body text, then to the HTTP status text (client.go:262-267). + -- request_id comes from the X-Request-Id response header + -- (client.go:261). + -- Default hints are attached by status: 401 invalid credentials, + -- 403 forbidden, 422 validation, 429 rate limited (client.go:75-90). + -- A 401 whose body is not JSON gets a "possible proxy or wrong URL" + -- hint instead (client.go:268-273). +} + +-- Network-level failures are never retried: only *APIError values with a +-- retryable status re-enter the loop, and transport errors carry status 0 +-- (client.go:158-161, 175-177, 232-238). +rule FailOnTransportError { + when: TransportFailure(request, cause) + requires: request.status = in_flight + ensures: + request.status = failed + request.error = transport_error(cause) + + @guidance + -- The message is "could not reach : " with credentials + -- scrubbed from both URL and cause, plus a hint to check the + -- connection and run `alpaca doctor` (client.go:232-238, 312-321). +} + +rule ScrubCredentialsFromDiagnosticOutput { + when: EmitDiagnosticOutput(content) + ensures: DiagnosticEmitted(content: scrub_credentials(content)) + + @guidance + -- Every occurrence of the API key, secret and access token is + -- replaced with "[REDACTED]" in --verbose request lines, --debug + -- URLs, headers and response bodies, --trace output, and transport + -- error messages (client.go:219-256, 291-321). +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +invariant AttemptsNeverExceedMax { + for r in OutboundRequests: + r.attempt <= config.max_attempts +} + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "The --debug request BODY is printed without credential scrubbing (client.go:221-223), while the request URL, headers and response body are scrubbed. Credentials normally travel in headers, not bodies — is the unscrubbed body intentional or an oversight?" + +open question "After the final failed retryable attempt, doWithRetry sleeps the full backoff delay once more before returning the error (client.go:150-172) — wasted latency of up to ~3s. Intended?" diff --git a/.allium/oauth-flow.allium b/.allium/oauth-flow.allium new file mode 100644 index 0000000..bad2c69 --- /dev/null +++ b/.allium/oauth-flow.allium @@ -0,0 +1,183 @@ +-- allium: 3 +-- oauth-flow.allium +-- Authorization-code OAuth2 flow for CLI user authentication +-- (internal/oauth/oauth.go, internal/oauth/config.go). +-- Currently restricted to paper trading; live trading requires API keys +-- until PKCE is added (internal/cmd/auth.go:47-50). + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +-- Shape of the token endpoint response (oauth.go:23-27). +value AccessToken { + access_token: String + token_type: String + scope: String +} + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity AuthorizationRequest { + scope: String + environment: String? + state: String + redirect_uri: String + initiated_at: Timestamp + status: pending | completed | denied | timed_out + + transitions status { + pending -> completed + pending -> denied + pending -> timed_out + terminal: completed, denied, timed_out + } + + token: AccessToken when status = completed +} + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + -- oauth.go:73 (2-minute wait for the browser callback) + authorization_timeout: Duration = 2.minutes + -- oauth.go:133 (HTTP client timeout for the token exchange) + token_exchange_timeout: Duration = 15.seconds + authorize_url: String = "https://app.alpaca.markets/oauth/authorize" + token_url: String = "https://api.alpaca.markets/oauth/token" +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- `alpaca profile login` (OAuth path) drives the flow +-- (internal/cmd/auth.go:47-109). +surface ProfileLoginCommand { + provides: + UserInitiatesAuthorization(scope, environment?) +} + +-- The localhost callback endpoint the browser is redirected to +-- (oauth.go:83-115). +surface BrowserCallback { + provides: + AuthorizationCodeReceived(request, code, state) + AuthorizationCallbackError(request, reason) + + @guarantee CallbackPortsFixed + -- The callback server binds 127.0.0.1 on the first free port of the + -- pre-registered set 41920-41924 (oauth/config.go:12, + -- oauth.go:184-193); the redirect URI is + -- http://localhost:/callback (oauth.go:44). + + @guarantee NoPortNoFlow + -- If none of the callback ports can be bound, login fails before any + -- browser interaction, with a hint to free a port or close other + -- `alpaca profile login` instances (oauth.go:184-193). +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule UserInitiatesAuthorization { + when: UserInitiatesAuthorization(scope, environment?) + ensures: AuthorizationRequest.created( + scope: scope, + environment: environment, + state: random_state(), + redirect_uri: callback_redirect_uri(), + initiated_at: now, + status: pending + ) + + @guidance + -- random_state() is 16 bytes from a cryptographic RNG, hex-encoded + -- (oauth.go:176-182); it is embedded as the `state` query parameter + -- of the authorization URL so the callback can be CSRF-validated. + -- The authorization URL carries response_type=code, client_id, + -- redirect_uri, state, plus scope and env when non-empty + -- (oauth.go:160-174). + -- The browser is opened via the platform opener (open/xdg-open/ + -- rundll32); if that fails, the URL is printed for manual use and + -- the flow keeps waiting (oauth.go:60-64, 195-206). +} + +rule AuthorizationGranted { + when: AuthorizationCodeReceived(request, code, state) + requires: request.status = pending + requires: state = request.state + requires: code != "" + ensures: + request.status = completed + request.token = exchange_code(code) + + @guidance + -- exchange_code POSTs grant_type=authorization_code, the code, the + -- CLI's registered client credentials and the exact redirect URI to + -- the token endpoint as a form body (oauth.go:117-131). + -- A non-200 token response, an unparseable body, or an empty + -- access_token in the response each fail the login with a + -- "token exchange failed" error (oauth.go:144-156) — the request + -- does NOT reach completed in those cases. +} + +-- A callback state that does not exactly match the value generated at +-- initiation is rejected as a potential CSRF attack: the browser gets +-- HTTP 400 and the CLI fails with "state mismatch" (oauth.go:99-103). +rule AuthorizationStateMismatch { + when: AuthorizationCodeReceived(request, code, state) + requires: request.status = pending + requires: state != request.state + ensures: request.status = denied +} + +-- A callback with a matching state but no authorization code fails with +-- "no authorization code received" (oauth.go:105-109). +rule AuthorizationMissingCode { + when: AuthorizationCodeReceived(request, code, state) + requires: request.status = pending + requires: state = request.state + requires: code = "" + ensures: request.status = denied +} + +rule AuthorizationDenied { + when: AuthorizationCallbackError(request, reason) + requires: request.status = pending + ensures: request.status = denied + + @guidance + -- Fires when the callback carries an `error` query parameter; the + -- error_description (or the error code when absent) is shown to the + -- user and rendered on the browser error page (oauth.go:85-94). + -- The browser page is static HTML; the description is HTML-escaped + -- (oauth.go:219-231). +} + +rule AuthorizationTimedOut { + when: request: AuthorizationRequest.initiated_at + config.authorization_timeout <= now + requires: request.status = pending + ensures: request.status = timed_out + + @guidance + -- The user sees "timed out waiting for authorization" with a hint to + -- complete the browser flow (oauth.go:73-75). + -- The race between a callback arriving and the timeout firing is + -- real and resolved first-event-wins by the select loop + -- (oauth.go:67-75); all four status transitions out of pending are + -- terminal, so the losing event has no effect. +} + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "PKCE — when will PKCE or Device Authorization Grant (RFC 8628) be added to enable live trading authorization?" + +open question "The callback channel is first-writer-wins with capacity 1 (oauth.go:46); after a rejected callback (state mismatch / missing code) the flow fails immediately rather than continuing to wait for a legitimate callback. Is fail-fast the intended CSRF response, or should the server keep listening until the timeout?" diff --git a/.allium/output-contract.allium b/.allium/output-contract.allium new file mode 100644 index 0000000..4729405 --- /dev/null +++ b/.allium/output-contract.allium @@ -0,0 +1,189 @@ +-- allium: 3 +-- output-contract.allium +-- +-- Two-tier output model: API commands emit structured data (JSON or CSV) +-- to stdout with JSON errors on stderr; operational commands emit plain +-- text. Covers jq-filter transformation, CSV formatting, and process exit +-- codes. +-- Grounded in: internal/cmd/root.go (renderData, printJSONError, Execute), +-- internal/output/output.go, internal/output/jq.go, internal/cmd/factory.go. + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum OutputFormat { json | csv } + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +-- Shape of the JSON error document written to stderr (root.go:65-84). +value CommandError { + error: String + code: Integer + status: Integer + hint: String + method: String? + path: String? + request_id: String? +} + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + default_format: OutputFormat = json + exit_success: Integer = 0 + -- client.go:23 (ExitAPIError) + exit_error: Integer = 1 + -- client.go:24 (ExitAuthError, HTTP 401 only) + exit_auth_error: Integer = 2 +} + +------------------------------------------------------------ +-- Contracts +------------------------------------------------------------ + +contract RenderingPipeline { + render: (data: Any, format: OutputFormat, jq_filter: String?) -> String + + @invariant FormatSelectionPrecedence + -- The output format resolves --csv flag > ALPACA_OUTPUT env var > + -- global config `output` field > json (root.go:163-167, + -- config.go:97). There is no --json flag; JSON is reached by not + -- selecting CSV at any level. + + @invariant JqBeforeFormat + -- When a jq filter is present, it is applied to the raw data + -- before the output format serialiser runs. The serialiser + -- receives the transformed data, not the original + -- (root.go:254-266). + + @invariant JqResultShape + -- A jq program producing exactly one value yields that value + -- unwrapped; zero or multiple values are collected into an array + -- (jq.go:40-44). Parse, compile and evaluation errors abort the + -- command with an "--jq:" prefixed error. + + @invariant NilCollectionAsEmptyArray + -- A nil or absent collection renders as an empty JSON array ([]), + -- never as null (output.go:28-35). Downstream scripts need no + -- null-checks on list results. + + @invariant EmptyResponseAsObject + -- Endpoints that return no content (204 etc.) render {} on stdout, + -- so stdout is always valid JSON in JSON mode (factory.go:94-103). + + @invariant CsvColumnsFromFirstRow + -- For a non-empty data set, CSV columns are the keys of the FIRST + -- row, sorted alphabetically (output.go:56-58). Keys missing from + -- later rows render as empty cells; keys appearing only in later + -- rows are dropped. Declared response schemas do NOT reorder + -- populated CSV output. + + @invariant CsvHeadersFromSchemaWhenEmpty + -- Schema-derived headers are used only when the data set is empty: + -- if no jq filter is active and the command declares a response + -- schema, an empty result still emits the header row + -- (output.go:45-55, root.go:262-263, 268-285). An empty result + -- with no schema headers emits nothing at all. + + @invariant CsvScalarFormatting + -- CSV cells: integral numbers render without decimals, other + -- numbers with two decimal places, booleans as true/false, null as + -- an empty cell (output.go:110-133). + + @invariant DefaultFormatIsJson + -- When no output format is explicitly selected, the pipeline + -- defaults to JSON, indented with two spaces (output.go:36-38). +} + +contract ErrorChannel { + emit_error: (error: CommandError) -> String + + @invariant ErrorOnStderr + -- Error documents are serialised as indented JSON and written to + -- stderr, independent of the configured output format or any jq + -- filter (root.go:65-84). + + @invariant ErrorStructure + -- Every error document carries error, code, status and hint keys — + -- code and status are 0 and hint is "" when the failure was not an + -- API response (root.go:66-71). method, path and request_id are + -- included only when the upstream API response supplies them + -- (root.go:72-80). + + @invariant AllErrorsAreStructured + -- Every failure path funnels through Execute: API errors keep their + -- fields; any other error (flag parsing, validation, config + -- problems) is wrapped into an error document with just error/hint + -- text (root.go:50-63). No failure mode prints a bare non-JSON + -- message to stderr as its primary output. +} + +contract ExitStatus { + exit_code: (error: CommandError?) -> Integer + + @invariant ZeroOnSuccess + -- Successful commands exit 0. + + @invariant TwoOnAuthFailure + -- An API response of HTTP 401 exits with code 2 (client.go:68-73). + + @invariant OneOnAnyOtherFailure + -- Every other failure — API errors, usage errors, config errors — + -- exits with code 1 (root.go:50-62). +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface ApiCommandOutput { + contracts: + fulfils RenderingPipeline + fulfils ErrorChannel + fulfils ExitStatus + + @guarantee StructuredStdout + -- Successful API commands write structured data (JSON or CSV) to + -- stdout. Human-readable prose is not emitted to stdout by API + -- commands. + + @guarantee MachineReadableErrors + -- Errors from API commands appear on stderr as a JSON document. + -- Callers can separate structured results (stdout) from failure + -- signals (stderr) without parsing output text. + + @guarantee SchemaFlagShortCircuits + -- --schema prints a TypeScript-like response schema for the command + -- and exits without calling the API (root.go:136-140, 293-326). + + @guidance + -- Retry notices, verbose/debug/trace diagnostics and shadowing + -- warnings all go to stderr, never stdout. --quiet (or ALPACA_QUIET) + -- suppresses those notices and disables color (root.go:145-150, + -- client.go:164-169). +} + +surface OperationalCommandOutput { + contracts: + fulfils ExitStatus + + @guarantee PlainTextStdout + -- Operational commands (version, update, doctor) emit + -- human-readable plain text to stdout. They do not use the + -- structured rendering pipeline and are not subject to + -- --jq or --csv flags. + + @guarantee UpdateCheckIsJson + -- Exception: `alpaca update --check` prints a machine-readable + -- indented JSON status document to stdout (update.go:50-61); it is + -- still outside the rendering pipeline, so --jq/--csv do not apply. + + @guarantee NoAuthRequired + -- version, help, completion, update and doctor run without resolved + -- credentials (root.go:231-241). +} diff --git a/.github/workflows/allium-check.yml b/.github/workflows/allium-check.yml new file mode 100644 index 0000000..d4e16e7 --- /dev/null +++ b/.github/workflows/allium-check.yml @@ -0,0 +1,124 @@ +name: Allium Check + +# Runs `allium check` on every `.allium` file in the repo. +# +# Local install: +# cargo install --locked --version 3.5.0 allium-cli +# +# Toolchain note: allium-cli is a Rust binary and is not bootstrapped through +# the Makefile's `bin/-` convention (where one exists). The +# version is single-sourced via the ALLIUM_VERSION env var below. + +"on": + push: + branches: [main] + paths: + - "**/*.allium" + - ".github/workflows/allium-check.yml" + pull_request: + paths: + - "**/*.allium" + - ".github/workflows/allium-check.yml" + +permissions: + contents: read + +env: + ALLIUM_VERSION: "3.5.0" + # Opt in to Node 24 runtime for actions; Node 20 will be removed 2026-09-16. + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Cache allium-cli binary + id: cache-allium + uses: actions/cache@v5 + with: + path: ~/.cargo/bin/allium + key: allium-cli-${{ env.ALLIUM_VERSION }}-${{ runner.os }}-${{ runner.arch }} + + - name: Install Rust toolchain + if: steps.cache-allium.outputs.cache-hit != 'true' + # Pinned SHA for `stable` branch — dtolnay/rust-toolchain uses + # branches (not tags) for channel pinning, so we pin the commit + # instead. Bump when refreshing the toolchain. + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Install allium-cli + if: steps.cache-allium.outputs.cache-hit != 'true' + run: cargo install --locked --version ${{ env.ALLIUM_VERSION }} allium-cli + + - name: Run allium check + # Gate on DIAGNOSTIC SEVERITY, not on the process exit code and not on + # a grepped summary line. + # + # * `allium check --help` documents exit 1 as "One or more errors OR + # WARNINGS were reported", so the exit code cannot express + # "errors only". Specs carry unavoidable warnings + # (externalEntity.missingSourceHint, use.unresolvedPath), so gating + # on it would keep this job red for content-free reasons. + # * The previous gate grepped for an "N error(s)" summary. allium + # 3.5.0 and 3.5.3 emit JSON and never print that string, so the job + # failed unconditionally, independent of spec content. + # * allium emits ONE JSON DOCUMENT PER SPEC FILE, concatenated, so the + # stream must be slurped (`jq -s`), not parsed as a single object. + # + # Older binaries printed a human summary instead of JSON and runner + # caches are not uniform, so the legacy format is still accepted. If + # NEITHER parses, the job fails loudly: a gate that cannot read its own + # input must never report green. + shell: bash + run: | + set -uo pipefail + FILES=$(find . -type f -name "*.allium" -not -path "./.git/*" | sort) + if [ -z "$FILES" ]; then + echo "No .allium files found; nothing to check." + exit 0 + fi + echo "Checking:"; echo "$FILES" | sed 's/^/ /' + allium --version || true + + set +e + allium check $FILES >allium.out 2>allium.err + RC=$? + set -e + + if [ "$RC" -eq 2 ]; then + echo "::error::allium check could not resolve inputs (exit 2)" + cat allium.err || true + exit 1 + fi + + ERRORS="" + WARNINGS="" + if jq -e -s 'length > 0' allium.out >/dev/null 2>&1; then + ERRORS=$(jq -s '[.[].diagnostics[]? | select(.severity=="error")] | length' allium.out) + WARNINGS=$(jq -s '[.[].diagnostics[]? | select(.severity=="warning")] | length' allium.out) + jq -s -r '.[] | .spec_file as $f | .diagnostics[]? + | select(.severity=="error") + | "::error file=\($f),line=\(.location.line // 0)::\(.code): \(.message)"' \ + allium.out || true + else + ERRORS=$(grep -oE '[0-9]+ error\(s\)' allium.out | tail -1 | awk '{print $1}') + WARNINGS=$(grep -oE '[0-9]+ warning\(s\)' allium.out | tail -1 | awk '{print $1}') + [ -n "$ERRORS" ] && echo "note: parsed legacy text summary (binary predates JSON output)" + fi + + if [ -z "$ERRORS" ]; then + echo "::error::allium check output matched neither JSON nor the legacy summary (exit $RC)" + head -20 allium.out || true + cat allium.err || true + exit 1 + fi + + echo "errors: $ERRORS, warnings: ${WARNINGS:-0} (warnings do not gate)" + if [ "$ERRORS" -gt 0 ]; then + echo "::error::allium check reported $ERRORS error(s)" + exit 1 + fi + echo "OK - 0 errors." diff --git a/.github/workflows/allium-drift.yml b/.github/workflows/allium-drift.yml new file mode 100644 index 0000000..cca69b4 --- /dev/null +++ b/.github/workflows/allium-drift.yml @@ -0,0 +1,38 @@ +name: Allium Drift Check + +# Thin caller — the drift check logic lives in alpaca-harness. +# +# Runs automatically on every PR update. Posts a drift report as a PR comment. +# Currently in advisory mode (required_passing: false) — drift findings will not +# block PRs. Flip to true once specs are trusted. +# +# Required secret on this repo (set one — the reusable workflow auto-detects): +# ANTHROPIC_API_KEY — claude provider (preferred when both set) +# CURSOR_API_KEY — cursor provider +# +# Ref pin policy: alpacahq/alpaca-harness is a first-party internal repo and +# this caller deliberately tracks its `master` so improvements (e.g. parser +# fixes, model bumps) reach all callers without per-repo PRs. The repo is +# org-internal, so all branch writes are gated by the same review process +# as this repo. If we later need pinned releases, switch to a tag and bump +# via dependabot. + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + allium-drift: + uses: alpacahq/alpaca-harness/.github/workflows/ai-allium-drift.yml@master + with: + pr_num: ${{ github.event.pull_request.number }} + head_ref: ${{ github.event.pull_request.head.ref }} + target_repo: alpacahq/cli + required_passing: false + secrets: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}