diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 3b5f5d7..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: 2.1 -jobs: - build: - docker: - - image: ruby:3.4.9 - steps: - - checkout - - run: - name: Run the default task - command: | - gem install bundler -v 4.0.14 - bundle install - bundle exec rake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d84411 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ["3.2", "3.3", "3.4"] + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run tests + run: bundle exec rspec + + rubocop: + name: RuboCop + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - name: Run RuboCop + run: bundle exec rubocop --format github + + brakeman: + name: Brakeman + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + # This is a plain Ruby gem (not a Rails app), so Brakeman needs --force. + # -z makes it exit non-zero if any security warning is found. + - name: Run Brakeman + run: bundle exec brakeman --force --no-progress --quiet -z + + bundler-audit: + name: Bundler Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - name: Audit dependencies for known CVEs + run: bundle exec bundler-audit check --update diff --git a/.gitignore b/.gitignore index b04a8c8..e9eed9e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ # rspec failure tracking .rspec_status + +# built gems +/*.gem + +# local bundler config / lockfile for an application-less gem +/Gemfile.lock diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..a2d0132 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,70 @@ +AllCops: + TargetRubyVersion: 3.2 + NewCops: enable + SuggestExtensions: false + Exclude: + - "bin/**/*" + - "vendor/**/*" + - "tmp/**/*" + - "pkg/**/*" + +# We document with YARD; a class comment isn't always warranted. +Style/Documentation: + Enabled: false + +# The CLI leans on descriptive multi-branch case/format methods; keep the +# metrics as guardrails rather than hard style rules for this codebase. +Metrics/AbcSize: + Max: 40 +Metrics/MethodLength: + Max: 30 +Metrics/ClassLength: + Max: 250 +Metrics/ModuleLength: + Max: 250 + Exclude: + - "spec/**/*" +Metrics/BlockLength: + # Thor groups its instance helpers in a `no_commands do ... end` block. + AllowedMethods: + - "no_commands" + Exclude: + - "spec/**/*" + - "*.gemspec" +Metrics/CyclomaticComplexity: + Max: 13 +Metrics/PerceivedComplexity: + Max: 13 +Metrics/ParameterLists: + Max: 6 + # Keyword args are self-documenting at the call site and don't suffer the + # positional-argument ordering problem this cop guards against. + CountKeywordArgs: false + +# Our error classes take keyword args (status:, body:), so `raise Error.new(...)` +# is intentional and clearer than the exploded form. +Style/RaiseArgs: + Enabled: false + +# Endless methods and long single-purpose lines read fine here. +Layout/LineLength: + Max: 120 + AllowedPatterns: + - '\A\s*#' # long comment/URLs + +Style/EndlessMethod: + EnforcedStyle: allow_single_line + +# Heredocs and message strings frequently mix quote styles intentionally. +Style/StringLiterals: + EnforcedStyle: double_quotes +Style/WordArray: + Enabled: true + +# Guard clauses that raise with a message are clearer un-negated at times. +Style/GuardClause: + Enabled: false + +# Specs use `expect { }.to raise_error { |e| ... }` blocks. +Lint/AmbiguousBlockAssociation: + Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9aed7..4605084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ ## [Unreleased] -## [0.1.0] - 2026-07-06 +- MCP `list` now speaks the API v3 filter DSL: `attribute[op]=value` applies an + operator (`eq`, `not_eq`, `gt`, `gteq`, `lt`, `lteq`, `in`, `matches`, + `does_not_match`) and repeating an attribute combines conditions with AND + (e.g. a numeric or date range). Values are coerced to numbers/booleans; ISO + 8601 timestamps stay strings. +- Fixed the `booking_discount_codes` / `booking_discount_code_usages` resources + to use their flat top-level v3 paths (were pointing at a nested + `booking/discount_codes` path). +- Internal: the library now autoloads via Zeitwerk instead of hand-maintained + `require_relative` chains. +- Tests: added VCR-cassette acceptance tests exercising the REST and MCP flows + end-to-end (list, Link-header pagination, 404 handling, operator/range filter). +- Security: `auth client-credentials` / `refresh` / `exchange` no longer dump the + full token payload (including the refresh token) when their output is piped or + captured. The full payload is now opt-in via an explicit `-o json` / + `SMILY_OUTPUT=json`; the default stays access-token-only. +- Fixed a spurious "results may be truncated" warning from `--all` / `--limit` + when a collection finished on exactly the `--max-pages` page; the warning now + fires only when a further page is actually being left behind. +- Config: a structurally valid config file carrying a value `safe_load` refuses + (e.g. an unquoted date) now fails with a clean configuration error instead of + an uncaught YAML backtrace. +- Argv parsing: a forgotten value for a global flag (`smily --token rentals + list`) now fails with a clear "Missing value for --token" error instead of + swallowing the subcommand into the flag (which produced a baffling "command + not found" or a request fired with a bogus value). Use `--flag=value` when a + value legitimately collides with a command name. -- Initial release +## [0.1.0] + +Initial (unreleased) version: a command-line client for the BookingSync (Smily) +API v3. + +- Per-resource commands (`list`, `get`, `create`, `update`, `delete`) for the + documented API v3 endpoints, generated from a resource registry. +- Raw `smily api ` escape hatch for any endpoint/verb, with + optional `--paginate`. +- OAuth helpers: `auth client-credentials`, `auth refresh`, `auth exchange`, + `auth authorize-url`, `auth status`, `auth token`. +- Config profiles (`smily config …`) stored at `~/.config/smily/config.yml` + (0600), with flag > env > profile resolution. +- Account scoping via `--account-id` / `SMILY_ACCOUNT_ID` for + Client-Credentials-flow tokens. +- Output formats: table (default on a TTY), json, yaml, csv, ndjson; sparse + fieldsets, filtering, sideloading and pagination (`--all`, `--limit`, + `--page`, `--per-page`). +- MCP mode (`smily mcp`): talk to a BookingSync MCP server (Model Context + Protocol) over the Streamable-HTTP JSON-RPC 2.0 transport. Includes `info`, + `tools`, and a low-level `call`, plus `list`/`get`/`schema`/`resources` + convenience wrappers over the generic dispatcher tools, and `mcp login` for + its dedicated setup. Separate credential (`mcp_token`) and endpoint + (`mcp_url`, default `/mcp`); `--account-id` scopes tool calls. +- `whoami`, `resources`, `completion` (bash/zsh/fish) and `version` commands. +- Built on the official `bookingsync-api` client. +- Security: `--verbose` redacts `Authorization`/`client_secret`/tokens; config + written `0600` in a `0700` directory; warns when credentials would be sent + over non-HTTPS. +- Reliability: `--max-pages` backstop on auto-pagination (default 1000); + `--limit` sizes the page request; `--retry N` retries 429s honoring + `Retry-After` / `X-RateLimit-Reset`. +- Ergonomics: single-value global flags may precede the subcommand; + `create --path` for nested creates. +- CI: `bundler-audit` dependency CVE scan. diff --git a/Gemfile b/Gemfile index caecbf9..859f527 100644 --- a/Gemfile +++ b/Gemfile @@ -9,3 +9,9 @@ gem "irb" gem "rake", "~> 13.0" gem "rspec", "~> 3.0" +gem "vcr", "~> 6.0" +gem "webmock", "~> 3.0" + +gem "brakeman", "~> 8.0", require: false +gem "bundler-audit", "~> 0.9", require: false +gem "rubocop", "~> 1.80", require: false diff --git a/README.md b/README.md index bd77dd1..28110ef 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,387 @@ -# SmilyCli +# smily — BookingSync (Smily) API v3 CLI -TODO: Delete this and the text below, and describe your gem +`smily` is an ergonomic, comprehensive command-line client for the +[BookingSync (Smily) API v3](https://developers.bookingsync.com/reference). -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/smily_cli`. To experiment with that code, run `bin/console` for an interactive prompt. +It aims to be *the* tool you reach for when you want to talk to API v3 from a +terminal or a script: + +- **Per-resource commands** — `smily rentals list`, `smily bookings get 42`, + `smily clients create …` — for every documented endpoint. +- **A raw escape hatch** — `smily api get ` reaches *anything* the API + exposes, with pagination and wrapping out of your way. +- **OAuth built in** — client-credentials, refresh, and authorization-code + helpers; no need to hand-roll token requests. +- **Config profiles** — keep tokens for multiple accounts/environments and + switch with `--profile`. +- **Output that fits your workflow** — a readable table on a TTY, clean JSON + when piped, plus YAML, CSV and NDJSON. +- **Pagination, filtering, sparse fieldsets, sideloading** — first-class flags. + +It is built on top of the official +[`bookingsync-api`](https://github.com/BookingSync/bookingsync-api) gem, so the +HTTP, JSON:API and error-handling behavior matches the reference client. ## Installation -TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. +```bash +gem install smily_cli +``` + +Or with Bundler: + +```ruby +gem "smily_cli" +``` + +This installs the `smily` executable. + +## Setup (first run) -Install the gem and add to the application's Gemfile by executing: +Once the gem is installed, get from zero to your first authenticated call in +four steps: ```bash -bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +# 1. Confirm the executable is on your PATH +smily version + +# 2. Get an API token. For scripts/automation the Client Credentials flow is +# simplest: you need your application's Client ID and Secret from the +# BookingSync developer portal (https://www.bookingsync.com/oauth/applications). +# --save writes the token to the default profile at +# ~/.config/smily/config.yml (created 0600, since it holds secrets). +smily auth client-credentials --client-id "$CLIENT_ID" --client-secret "$CLIENT_SECRET" --save + +# 3. Verify the token actually works (calls /me) +smily auth status --check + +# 4. Make your first real call +smily rentals list --limit 5 ``` -If bundler is not being used to manage dependencies, install the gem by executing: +A Client-Credentials token can read across every account that authorized your +app; pin one account with `--account-id ` per call, or store it once: ```bash -gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +smily config set account_id ``` -## Usage +Don't want to store anything? Every command also reads `--token` / `SMILY_TOKEN` +directly, so `SMILY_TOKEN=… smily rentals list` works with no config file at +all. See [Authentication](#authentication) for the Authorization-Code (per-user) +flow and [Configuration & profiles](#configuration--profiles) for multiple +environments. -TODO: Write usage instructions here +## Quick start -## Development +```bash +# 1. Get a token (Client Credentials flow) and save it to the default profile +smily auth client-credentials --client-id "$CLIENT_ID" --client-secret "$CLIENT_SECRET" --save + +# 2. Explore +smily resources # what can I talk to? +smily rentals list --limit 5 # a readable table +smily bookings list --filter status=booked from=2026-01-01 + +# 3. Fetch one, as JSON +smily rentals get 42 -o json + +# 4. Anything the resource commands don't cover +smily api get rentals/42/bookings --paginate -o ndjson +``` + +## Authentication + +API v3 uses OAuth 2.0 Bearer tokens. A token can be supplied (in order of +precedence) by: + +1. `--token` flag +2. `SMILY_TOKEN` (or `BOOKINGSYNC_TOKEN`) environment variable +3. the active config profile + +### Getting a token + +```bash +# Client Credentials — app-level, public scope, reads across all authorized +# accounts. Great for scripts. (Tokens last ~2h.) +smily auth client-credentials --client-id ID --client-secret SECRET --save + +# Authorization Code — per-account access. +smily auth authorize-url --client-id ID --redirect-uri https://app.example.com/cb --scope "rentals_read bookings_read" +# (open the URL, approve, copy the ?code=… back) +smily auth exchange --client-id ID --client-secret SECRET --code CODE --redirect-uri https://app.example.com/cb --save + +# Refresh an expiring token +smily auth refresh --client-id ID --client-secret SECRET --refresh-token RT --save + +# Check what's configured (optionally validate against /me) +smily auth status --check +``` -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +`--save` stores the token (and any refresh token / client credentials) in the +active profile so subsequent commands just work. + +### Scoping a Client-Credentials token to one account + +A Client-Credentials token spans every account that authorized your app. Pin a +single account with `--account-id` (sent as the `account_id` query parameter), +via `SMILY_ACCOUNT_ID`, or by storing it in a profile: + +```bash +smily rentals list --account-id 12345 +smily config set account_id 12345 --profile acme +``` + +For Authorization-Code tokens the account is implicit, so this is a no-op. + +## Configuration & profiles + +Configuration lives in `~/.config/smily/config.yml` (override with +`SMILY_CONFIG`; honors `XDG_CONFIG_HOME`). The file is written with `0600` +permissions because it holds secrets. + +```bash +smily config init # interactive: store a token + base URL +smily config set token TOKEN --profile prod +smily config set account_id 123 --profile prod +smily config use prod # make "prod" the default profile +smily config list # profiles + settings (secrets masked) +smily config path # where's the file? +``` + +Use a specific profile per command with `--profile NAME`, or set +`SMILY_PROFILE`. + +## Working with resources + +Every registered resource supports the same verbs: + +```bash +smily list [options] +smily get ID [options] +smily create --data # not on read-only resources +smily update ID --data +smily delete ID [--yes] +``` + +Run `smily resources` for the full catalog, or `smily help`. + +### Listing, filtering, pagination + +```bash +smily bookings list --filter status=booked --filter from=2026-01-01 +smily rentals list --fields id name --limit 10 +smily rentals list --all -o ndjson # every page, one JSON object per line +smily rentals list --page 2 --per-page 50 # a specific page +smily rentals list --include photos # sideload associations +``` + +- `--filter k=v` / `--query k=v` — API query parameters (repeat or space-separate). + These are sent verbatim, so a resource's documented filters work directly + (`--filter status=booked from=2026-01-01`), and operator query params are + forwarded as-is where the endpoint supports them + (`--filter 'final_price[gteq]=600'`). For the *validated* operator/range DSL, + see [MCP mode](#advanced-filtering-operators--ranges). +- `--fields a b c` — sparse fieldset (also limits table/CSV columns). +- `--include a b` — sideload associations (returned under `linked`). +- `--limit N` — stop after N records (follows pages as needed; also sizes the + page request to `min(N, 100)`). +- `--all` — fetch every page. +- `--page` / `--per-page` — fetch one specific page. +- `--max-pages N` — hard backstop on auto-pagination (default 1000); the CLI + warns if it stops early. +- `--retry N` — retry `429 Too Many Requests` up to N times, waiting for the + rate-limit window (`Retry-After` / `X-RateLimit-Reset`). Off by default. + +### Creating & updating + +Provide attributes as a JSON object; the `{ "": [ … ] }` envelope +that v3 expects is added for you: + +```bash +smily rentals create --data '{"name":"Villa Sunset","sleeps":6}' +smily clients create --data @client.json # from a file +echo '{"sleeps":8}' | smily rentals update 42 --data - # from stdin +``` -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +For nested creates (e.g. a booking under a rental), point `--path` at the +parent collection (the envelope key stays the resource's): -## Contributing +```bash +smily bookings create --path rentals/42/bookings --data @booking.json +``` + +## The raw `api` command + +`smily api ` is the universal escape hatch — it can reach any +endpoint, with any verb, and sends your body verbatim: + +```bash +smily api get me +smily api get rentals --query per_page=5 +smily api get bookings/123 +smily api post rentals --data '{"rentals":[{"name":"X"}]}' +smily api get rentals --paginate -o json # combine all pages into one array +``` + +By default it prints the raw JSON response envelope. With `--paginate` it +follows pagination and prints the combined records. + +## MCP mode (`smily mcp`) + +Besides the REST API, `smily` can talk to a **BookingSync MCP server** (Model +Context Protocol) — the same servers an AI agent connects to. This is a separate +mode with its own credential (`mcp_token`) and endpoint (`mcp_url`, defaulting to +`/mcp`). + +```bash +# One-time setup: store the MCP token (and optionally a non-default endpoint) +smily mcp login "$MCP_TOKEN" +smily mcp login "$MCP_TOKEN" --url https://www.bookingsync.com/mcp --profile prod + +# Handshake + capabilities, and discover the tools the server exposes +smily mcp info +smily mcp tools + +# Convenience wrappers that mirror the REST commands, over the generic +# list / get / resources / resource_schema tools: +smily mcp resources +smily mcp list rentals --limit 5 +smily mcp list bookings --filter status=booked --account-id 42 +smily mcp get rentals 42 +smily mcp schema bookings + +# Low-level escape hatch — call any tool with a raw JSON argument object: +smily mcp call list --args '{"resource":"rentals","limit":5}' +smily mcp call resource_schema --args @args.json +``` + +The convenience wrappers resolve the generic tool names for you — they work +against a server that exposes `list`/`get`/… as well as one that prefixes them +(`api_v3_list`, `api_v3_get`, …). + +### Advanced filtering (operators & ranges) + +`smily mcp list` speaks the API v3 filter DSL. A plain `attribute=value` is an +exact match; `attribute[op]=value` applies an operator; repeating an attribute +combines the conditions with AND (e.g. a range). Operators: `eq`, `not_eq`, +`gt`, `gteq`, `lt`, `lteq`, `in`, `matches`, `does_not_match` (exactly the set a +resource reports via `smily mcp schema `). + +```bash +# final_price > 1000 +smily mcp list bookings --filter 'final_price[gt]=1000' + +# 600 <= final_price < 800 (compound AND range) +smily mcp list bookings --filter 'final_price[gteq]=600' --filter 'final_price[lt]=800' + +# starts on/after a date, and one of several references +smily mcp list bookings --filter 'start_at[gteq]=2026-01-01T00:00:00Z' --filter 'reference[in]=A123,B456' +``` + +Numeric and boolean values are coerced automatically; ISO 8601 timestamps are +kept as strings. Use `smily mcp schema ` to see which attributes are +filterable and the operators each accepts. + +Credentials/endpoint resolve like everything else — `--mcp-token` / +`SMILY_MCP_TOKEN` / profile `mcp_token`, and `--mcp-url` / `SMILY_MCP_URL` / +profile `mcp_url`. `--account-id` scopes tool calls to a single account (as the +`account_id` tool argument), which is how a multi-account MCP token is pinned. +Output honors `-o` just like the REST commands. + +Transport details: `smily` speaks the Streamable-HTTP JSON-RPC 2.0 transport, +performing the `initialize` handshake (capturing the `Mcp-Session-Id`) and +accepting either `application/json` or SSE-framed responses. + +## Output formats + +`-o, --output` selects the format. The default is a human-readable **table** on +an interactive terminal, and **json** when the output is piped — so scripts get +machine-readable data automatically. + +| Format | Notes | +|----------|---------------------------------------------------| +| `table` | Aligned grid (collections) or key/value (single). | +| `json` | Pretty JSON — array for lists, object for `get`. | +| `yaml` | YAML. | +| `csv` | Header + rows; nested values become JSON. | +| `ndjson` | One compact JSON object per line (a.k.a. `jsonl`).| + +```bash +smily rentals list -o csv --fields id name status > rentals.csv +smily bookings list --all -o ndjson | jq 'select(.status=="booked")' +``` + +## Global options + +Single-value global flags work before or after the subcommand +(`smily -o json rentals list` and `smily rentals list -o json` are equivalent). +Array-valued flags (`--fields`, `--filter`) must follow the subcommand. + +| Flag | Env | Meaning | +|------|-----|---------| +| `--profile NAME` | `SMILY_PROFILE` | Config profile to use | +| `--token TOKEN` | `SMILY_TOKEN` | OAuth access token (REST) | +| `--mcp-token TOKEN` | `SMILY_MCP_TOKEN` | MCP access token (`smily mcp`) | +| `--mcp-url URL` | `SMILY_MCP_URL` | MCP endpoint (default `/mcp`) | +| `--base-url URL` | `SMILY_BASE_URL` | API base (default `https://www.bookingsync.com`) | +| `--account-id ID` | `SMILY_ACCOUNT_ID` | Scope to an account (client-credentials tokens) | +| `--max-pages N` | `SMILY_MAX_PAGES` | Auto-pagination backstop (default 1000) | +| `--retry N` | `SMILY_RETRY` | Retry 429s N times, honoring the rate-limit reset | +| `-o, --output` | `SMILY_OUTPUT` | Output format | +| `--fields a b c` | | Field/column selection | +| `--no-color` | `NO_COLOR` | Disable ANSI color | +| `-q, --quiet` | | Suppress status messages | +| `-v, --verbose` | `SMILY_VERBOSE` | Log HTTP traffic to stderr (secrets redacted) | + +### Security notes + +- Config files are written `0600` in a `0700` directory; secrets are masked in + `smily config list` / `smily auth status`. +- `--verbose` scrubs `Authorization` / `client_secret` / tokens from the logged + traffic, so verbose output is safe to share. +- The CLI warns (like the GitHub CLI) if a credential would be sent over a + non-HTTPS endpoint. + +## Shell completion + +```bash +smily completion bash > /usr/local/etc/bash_completion.d/smily +smily completion zsh > "${fpath[1]}/_smily" +smily completion fish > ~/.config/fish/completions/smily.fish +``` + +## Exit codes + +`0` success · `1` generic/API error · `2` usage error · `3` auth error +(401/403) · `4` not found (404) · `5` rate limited (429). + +## Using it as a library + +The library layer is usable without Thor: + +```ruby +require "smily_cli" + +client = SmilyCli::Client.new(token: ENV["SMILY_TOKEN"]) +result = client.list("rentals", limit: 10) +result.records # => [ { "id" => 1, ... }, ... ] + +puts SmilyCli::Formatters.render(result, format: "json") +``` + +## Development + +```bash +bin/setup # install dependencies +bundle exec rake # run the specs and RuboCop +bundle exec rspec # just the specs +bin/console # an IRB session with the gem loaded +``` -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/smily_cli. +CI (GitHub Actions) runs the specs across Ruby 3.2–3.4, RuboCop, and Brakeman. ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +Available as open source under the terms of the +[MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile index b6ae734..85a7ea1 100644 --- a/Rakefile +++ b/Rakefile @@ -5,4 +5,13 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) -task default: :spec +# RuboCop is a development-only dependency; only define the task when present so +# the gem still loads its Rakefile without it (e.g. in a production install). +begin + require "rubocop/rake_task" + RuboCop::RakeTask.new +rescue LoadError + task(:rubocop) { warn "rubocop is not available" } +end + +task default: %i[spec rubocop] diff --git a/exe/smily b/exe/smily new file mode 100755 index 0000000..37754da --- /dev/null +++ b/exe/smily @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "smily_cli" + +begin + SmilyCli.cli.start(ARGV) +rescue SmilyCli::Error => e + warn SmilyCli::Color.red("Error: #{e.message}") + exit e.exit_status +rescue Interrupt + warn "\nAborted." + exit 130 +rescue Errno::EPIPE + # Downstream pipe closed (e.g. `smily ... | head`); exit quietly. + exit 0 +end diff --git a/lib/smily_cli.rb b/lib/smily_cli.rb index 067ffa1..fbd600c 100644 --- a/lib/smily_cli.rb +++ b/lib/smily_cli.rb @@ -1,8 +1,37 @@ # frozen_string_literal: true +require "zeitwerk" + require_relative "smily_cli/version" +require_relative "smily_cli/errors" +# SmilyCli is a command-line client for the BookingSync (Smily) API v3. +# +# The library layer (everything under {SmilyCli}) is deliberately usable on its +# own, without Thor: build a {SmilyCli::Context}, hand it to a +# {SmilyCli::Client}, and render results with {SmilyCli::Formatters}. The Thor +# command layer ({SmilyCli::CLI} and friends) autoloads lazily, so requiring the +# library does not pull in Thor until the command classes are actually touched. module SmilyCli - class Error < StandardError; end - # Your code goes here... + # The Thor-powered command-line interface. Referencing the constant autoloads + # (and thereby `require`s Thor); kept behind a method so the entry point reads + # as `SmilyCli.cli.start`. + # + # @return [Class] the {SmilyCli::CLI} Thor class + def self.cli + CLI + end end + +# Autoload everything under lib/smily_cli/**. version.rb and errors.rb hold a +# bare constant and a multi-class hierarchy respectively (neither maps 1:1 to a +# file name), so they are required eagerly above and ignored by the loader. +loader = Zeitwerk::Loader.for_gem +loader.inflector.inflect( + "cli" => "CLI", + "cli_options" => "CLIOptions", + "oauth" => "OAuth" +) +loader.ignore("#{__dir__}/smily_cli/version.rb") +loader.ignore("#{__dir__}/smily_cli/errors.rb") +loader.setup diff --git a/lib/smily_cli/base_command.rb b/lib/smily_cli/base_command.rb new file mode 100644 index 0000000..0b6429e --- /dev/null +++ b/lib/smily_cli/base_command.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "thor" +require "json" + +module SmilyCli + # Common behavior shared by every Thor command class in the CLI: building the + # {Context}/{Client} from resolved options, rendering {Result}s in the chosen + # format, reading `--data` payloads, and printing status/errors on the right + # stream (data on stdout, everything else on stderr). + class BaseCommand < Thor + # Make Thor exit non-zero on its own argument errors. + def self.exit_on_failure? + true + end + + no_commands do + # @return [Context] settings resolved from flags/env/profile + def context + @context ||= Context.from(options) + end + + # @return [Client] API client for the current context + def client + @client ||= context.client + end + + # Render a {Result} to stdout in the effective (or overridden) format. + # + # @param result [Result] + # @param format [String, nil] override the context format + # @param fields [Array, nil] override the context fields + # @return [void] + def render_result(result, format: nil, fields: nil) + fmt = format || context.output_format + output = Formatters.render(result, format: fmt, fields: fields || context.fields) + emit(output) + warn_rate_limit(result) + end + + # Print data to stdout (kept free of decoration so it stays pipeable). + # + # @param string [String] + # @return [void] + def emit(string) + return if string.nil? || string.empty? + + $stdout.puts(string) + end + + # Print an informational line to stderr unless `--quiet`. + # + # @param message [String] + # @return [void] + def notice(message) + warn(message) unless context.quiet? + end + + # Print a green success line to stderr. + # + # @param message [String] + # @return [void] + def success(message) + notice(Color.green(message)) + end + + # Read and JSON-parse the `--data` option, which may be inline JSON, + # `@path` to read a file, or `-` to read stdin. Raises when absent. + # + # @return [Hash, Array] + def require_data + raw = options["data"] + if raw.nil? || raw.to_s.empty? + raise UsageError, + "This command needs a JSON body: pass --data '', --data @file.json, or --data - for stdin." + end + + parse_data(raw) + end + + # @param raw [String] + # @return [Object] parsed JSON + def parse_data(raw) + content = + if raw == "-" + $stdin.read + elsif raw.start_with?("@") + read_data_file(raw[1..]) + else + raw + end + JSON.parse(content) + rescue JSON::ParserError => e + raise UsageError, "Invalid JSON in --data: #{e.message}" + end + + # @param path [String] + # @return [String] + def read_data_file(path) + File.read(File.expand_path(path)) + rescue SystemCallError => e + raise UsageError, "Could not read --data file #{path.inspect}: #{e.message}" + end + + # Guard a destructive action behind confirmation unless `--yes` is set. + # In a non-interactive context, refuses rather than blocking. + # + # @param message [String] + # @return [void] + def confirm!(message) + return if options["yes"] + + unless $stdin.respond_to?(:tty?) && $stdin.tty? + raise UsageError, "#{message} Refusing without confirmation; pass --yes to proceed." + end + + $stderr.print("#{message} [y/N] ") + answer = $stdin.gets&.strip&.downcase + raise Error, "Aborted." unless %w[y yes].include?(answer) + end + + # In verbose mode, surface the remaining rate-limit budget. + # + # @param result [Result] + # @return [void] + def warn_rate_limit(result) + return unless context.verbose? + + remaining = result.rate_limit_remaining + notice(Color.dim("rate limit remaining: #{remaining}")) if remaining + end + end + end +end diff --git a/lib/smily_cli/cli.rb b/lib/smily_cli/cli.rb new file mode 100644 index 0000000..54b030b --- /dev/null +++ b/lib/smily_cli/cli.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "thor" +require "json" +require "bookingsync/api" # for the `version` command's BookingSync::API::VERSION + +module SmilyCli + # The top-level `smily` command. It wires the reusable resource CRUD commands + # (one registered subcommand per {Registry} entry), the `auth`/`config` + # subcommand groups, and a handful of core commands (`api`, `resources`, + # `whoami`, `completion`, `version`). Business logic lives in the library + # classes; this class only parses input and dispatches. + class CLI < BaseCommand + CLIOptions.connection(self) + CLIOptions.output(self) + + package_name "smily" + + # Single-value global flags that are safe to move past the subcommand. + HOISTABLE_VALUE_FLAGS = %w[ + --profile --token --base-url --account-id --max-pages --retry + -o --output --mcp-token --mcp-url + ].freeze + # Value-less global flags. + HOISTABLE_BOOL_FLAGS = %w[-v --verbose -q --quiet --no-color].freeze + + # Every top-level command word, so normalization can tell a real flag value + # from a subcommand the user meant to run. + # + # @return [Array] + def self.command_names + @command_names ||= ( + Registry.commands + %w[api auth config mcp resources whoami completion version help] + ).freeze + end + + # Thor's registered subcommands can't see class options that appear *before* + # the subcommand word, so `smily -o json rentals list` would drop `-o`. We + # normalize by moving any leading global flags to the end of the args, where + # every command re-declares them. (Array-valued flags like `--fields` are + # left alone and must follow the subcommand.) + # + # A leading value flag with no attached `=value` whose next token is missing + # or is itself a command word is a forgotten value (`smily --token rentals + # list`). We fail with a clear {UsageError} rather than swallow the + # subcommand into the flag — which otherwise yields a baffling "command not + # found" or a request fired with a bogus value. Use `--flag=value` when a + # value legitimately collides with a command name (e.g. a profile named like + # a resource). + # + # @param argv [Array] + # @return [Array] + # @raise [UsageError] when a leading value flag is missing its value + def self.normalize_argv(argv) + leading = [] + rest = argv.dup + until rest.empty? + token = rest.first + break if token == "--" + + name = token.split("=", 2).first + if HOISTABLE_VALUE_FLAGS.include?(name) + leading << rest.shift + next if token.include?("=") # value already attached + + value = rest.first + unless takes_value?(value) + raise UsageError, + "Missing value for #{name}. Pass it as `#{name} `, or as " \ + "`#{name}=` if the value is also a command name." + end + leading << rest.shift + elsif HOISTABLE_BOOL_FLAGS.include?(token) + leading << rest.shift + else + break + end + end + leading.empty? || rest.empty? ? argv : rest + leading + end + + # A token is a flag's value only when it's present and not a command word + # the user more likely meant to run (a forgotten flag value). + # + # @param token [String, nil] + # @return [Boolean] + def self.takes_value?(token) + !token.nil? && !command_names.include?(token) + end + + def self.start(given_args = ARGV, config = {}) + super(normalize_argv(Array(given_args)), config) + end + + # Register every known resource as a subcommand with list/get/create/... . + Registry.all.each do |resource| + verbs = resource.writable? ? "list|get|create|update|delete" : "list|get" + register( + ResourceCommand.bind(resource), + resource.command, + "#{resource.command} <#{verbs}>", + resource.description + ) + end + + register(Commands::AuthCommand, "auth", "auth ", + "Obtain, refresh and inspect OAuth tokens") + register(Commands::ConfigCommand, "config", "config ", + "Manage config profiles and settings") + register(Commands::McpCommand, "mcp", "mcp ", + "Talk to a BookingSync MCP server (Model Context Protocol)") + + desc "api METHOD PATH", "Make a raw request to any API v3 endpoint" + long_desc <<~DESC + Escape hatch for endpoints (or verbs) the resource commands don't cover. + The body is sent verbatim — no envelope wrapping. + + smily api get rentals --query per_page=5 + smily api get bookings/123 + smily api post rentals/42/bookings --data @booking.json + smily api get rentals --paginate -o ndjson + + By default the raw JSON response is printed. With --paginate, every page + is fetched and the combined records are printed as a JSON array. + DESC + method_option :query, type: :array, banner: "k=v", desc: "Query params (key=value)" + method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-", desc: "Request body" + method_option :paginate, type: :boolean, desc: "Follow pagination and combine records" + def api(method, path) + verb = method.to_s.downcase + unless %w[get post put patch delete head].include?(verb) + raise UsageError, "Unknown HTTP method #{method.inspect}. Use get, post, put, patch or delete." + end + + query = QueryOptions.build(fields: context.fields, query: options["query"] || []) + + if options["paginate"] + result = client.list(path, query: query, all: true) + render_api(result, records: true) + else + body = options["data"] ? parse_data(options["data"]) : nil + result = client.request(verb, path, query: query, body: body) + render_api(result, records: false) + end + end + + desc "resources [FILTER]", "List the API v3 resources this CLI knows about" + def resources(filter = nil) + selected = Registry.all.select { |r| filter.nil? || r.command.include?(filter) } + + if context.output_format == "table" + render_resources_table(selected) + else + payload = selected.map do |r| + { "command" => r.command, "path" => r.path, "group" => r.group, + "description" => r.description, "readonly" => r.readonly } + end + render_result(Result.new(records: payload, single: false)) + end + end + + desc "whoami", "Show the current application and account (/me)" + def whoami + render_result(client.me) + end + + desc "completion SHELL", "Print a completion script (bash, zsh or fish)" + def completion(shell) + emit(Completion.script(shell)) + end + + desc "version", "Show version information" + def version + emit("smily #{SmilyCli::VERSION} (ruby #{RUBY_VERSION}, bookingsync-api #{BookingSync::API::VERSION})") + end + map %w[--version] => :version + + no_commands do + # Render an `api` result: honor an explicit non-json format, otherwise + # print raw JSON (records array when paginating, else the full envelope). + def render_api(result, records:) + fmt = context.requested_output_format + # render_result already surfaces the rate-limit note, so return early to + # avoid warning about it twice. + return render_result(result, format: fmt) if fmt && fmt != "json" + + emit(JSON.pretty_generate(records ? result.records : (result.envelope || {}))) + warn_rate_limit(result) + end + + # The grouped, human-readable resource listing (table output). + def render_resources_table(selected) + selected.group_by(&:group).each do |group, list| + emit(Color.bold(group)) + width = list.map { |r| r.command.length }.max + list.each do |r| + flag = r.readonly ? Color.dim(" (read-only)") : "" + emit(" #{r.command.ljust(width)} #{Color.dim(r.description)}#{flag}") + end + emit("") + end + end + end + end +end diff --git a/lib/smily_cli/cli_options.rb b/lib/smily_cli/cli_options.rb new file mode 100644 index 0000000..0c1a287 --- /dev/null +++ b/lib/smily_cli/cli_options.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module SmilyCli + # Declares the reusable Thor option groups so every command exposes the same + # connection and output flags with identical wording. Because Thor does not + # forward a parent command's `class_option`s to `register`ed subcommands, each + # command class opts in by calling these helpers in its body. + module CLIOptions + module_function + + # Connection / global behavior flags (token, profile, base URL, verbosity). + # + # @param thor [Class] + # @return [void] + def connection(thor) + thor.class_option :profile, type: :string, banner: "NAME", + desc: "Config profile to use (env: SMILY_PROFILE)" + thor.class_option :token, type: :string, banner: "TOKEN", + desc: "OAuth access token (env: SMILY_TOKEN)" + thor.class_option :base_url, type: :string, banner: "URL", + desc: "API base URL (default: https://www.bookingsync.com)" + thor.class_option :account_id, type: :string, banner: "ID", + desc: "Scope requests to an account (for client-credentials tokens)" + thor.class_option :max_pages, type: :numeric, banner: "N", + desc: "Cap auto-pagination at N pages (default 1000)" + thor.class_option :retry, type: :numeric, banner: "N", + desc: "Retry 429s up to N times, honoring rate-limit reset" + thor.class_option :verbose, type: :boolean, aliases: "-v", + desc: "Log HTTP requests/responses to stderr" + thor.class_option :quiet, type: :boolean, aliases: "-q", + desc: "Suppress status messages" + thor.class_option :no_color, type: :boolean, + desc: "Disable ANSI color" + end + + # MCP-mode connection flags. Shares profile/base-url/account/verbosity with + # {connection} but swaps the REST token for the dedicated MCP credential. + # + # @param thor [Class] + # @return [void] + def mcp(thor) + thor.class_option :profile, type: :string, banner: "NAME", + desc: "Config profile to use (env: SMILY_PROFILE)" + thor.class_option :mcp_token, type: :string, banner: "TOKEN", + desc: "MCP access token (env: SMILY_MCP_TOKEN)" + thor.class_option :mcp_url, type: :string, banner: "URL", + desc: "MCP server endpoint (default: /mcp)" + thor.class_option :base_url, type: :string, banner: "URL", + desc: "Base URL used to derive the default MCP endpoint" + thor.class_option :account_id, type: :string, banner: "ID", + desc: "Scope tool calls to an account" + thor.class_option :verbose, type: :boolean, aliases: "-v", + desc: "Log MCP traffic to stderr" + thor.class_option :quiet, type: :boolean, aliases: "-q", + desc: "Suppress status messages" + thor.class_option :no_color, type: :boolean, desc: "Disable ANSI color" + end + + # Output-shaping flags (format and field selection). + # + # @param thor [Class] + # @return [void] + def output(thor) + thor.class_option :output, type: :string, aliases: "-o", banner: "FORMAT", + desc: "Output: table, json, yaml, csv, ndjson (default: table on a TTY, else json)" + thor.class_option :fields, type: :array, banner: "a b c", + desc: "Limit fields/columns (sparse fieldset)" + end + end +end diff --git a/lib/smily_cli/client.rb b/lib/smily_cli/client.rb new file mode 100644 index 0000000..fe44b0e --- /dev/null +++ b/lib/smily_cli/client.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require "logger" +require "bookingsync/api" + +module SmilyCli + # A thin, generic wrapper over the official {BookingSync::API::Client}. It + # reuses that client's connection handling, JSON:API serialization, `Link` + # header pagination and typed errors, and adds exactly what a CLI needs: + # path-based (rather than method-per-resource) access, cross-page collection, + # write-body wrapping, and translation of gem errors into {ApiError}. + # + # Everything is driven by a resource *path*, so any v3 endpoint is reachable — + # including ones the {Registry} doesn't list. + class Client + # @param token [String] OAuth access token + # @param base_url [String] site base URL (the client appends `/api/v3`) + # @param account_id [String, Integer, nil] scope requests to one account. + # Sent as the `account_id` query parameter on every request, which is how + # a Client-Credentials-flow token (spanning many accounts) is pinned to a + # single account. Harmless for single-account (Authorization Code) tokens. + # @param max_pages [Integer] hard backstop on auto-pagination: stop after + # this many pages even if the server keeps advertising a `next` link. + # @param retries [Integer] how many times to retry a `429 Too Many Requests`, + # waiting for the rate-limit window before each retry. + # @param max_retry_wait [Integer] cap (seconds) on any single retry wait. + # @param sleeper [#call] injectable sleep, for tests. Defaults to Kernel#sleep. + # @param verbose [Boolean] log HTTP traffic to stderr + def initialize(token:, base_url: Context::DEFAULT_BASE_URL, account_id: nil, + max_pages: Context::DEFAULT_MAX_PAGES, retries: 0, + max_retry_wait: 60, sleeper: nil, verbose: false) + @account_id = account_id + @max_pages = max_pages + @retries = retries + @max_retry_wait = max_retry_wait + @sleeper = sleeper || ->(seconds) { sleep(seconds) } + @api = BookingSync::API.new(token, base_url: base_url, logger: build_logger(verbose)) + end + + # List a collection, transparently following pagination as requested. + # + # @param path [String] collection path, e.g. "rentals" + # @param query [Hash] query parameters (filters, include, fields, ...) + # @param limit [Integer, nil] stop after this many records (follows pages) + # @param page [Integer, nil] fetch one specific page (disables following) + # @param per_page [Integer, nil] page size hint + # @param all [Boolean] fetch every page + # @return [Result] + def list(path, query: {}, limit: nil, page: nil, per_page: nil, all: false) + q = stringify(query) + q["per_page"] = per_page if per_page + # With a limit but no explicit page size, request exactly what we need + # (capped at the API max of 100) instead of paging at the default 25. + q["per_page"] = [limit, 100].min if per_page.nil? && limit && page.nil? + q["page"] = page if page + + response = call(:get, path, query: q) + first = Result.from_response(response) + records = first.records.dup + last = first + + last = follow_pages(response, records, limit: limit) if all || (limit && page.nil?) + + records = records.first(limit) if limit + Result.new( + records: records, single: false, + envelope: last.envelope, key: last.key, + status: last.status, headers: last.headers + ) + end + + # Fetch a single member. + # + # @param path [String] collection path + # @param id [String, Integer] + # @param query [Hash] + # @return [Result] + def get(path, id, query: {}) + response = call(:get, member_path(path, id), query: stringify(query)) + Result.from_response(response, single: true) + end + + # Create a member. Wraps attributes into the `{ key: [attrs] }` envelope v3 + # expects, unless the caller already supplied that envelope. + # + # @param path [String] collection path + # @param resource_key [String] JSON:API key (usually the last path segment) + # @param attributes [Hash, Array] attributes or a ready envelope + # @param query [Hash] + # @return [Result] + def create(path, resource_key, attributes, query: {}) + response = call(:post, path, query: stringify(query), body: wrap_body(resource_key, attributes)) + Result.from_response(response, single: true) + end + + # Update a member (HTTP PUT, matching the v3 reference). + # + # @return [Result] + def update(path, resource_key, id, attributes, query: {}) + response = call(:put, member_path(path, id), query: stringify(query), body: wrap_body(resource_key, attributes)) + Result.from_response(response, single: true) + end + + # Delete/cancel a member. Returns an empty {Result} on 204. + # + # @return [Result] + def delete(path, id, body: nil, query: {}) + response = call(:delete, member_path(path, id), query: stringify(query), body: body) + Result.from_response(response, single: true) + end + + # Escape hatch: issue an arbitrary request against any path. The body is + # sent verbatim (no envelope wrapping) so callers stay in full control. + # + # @param method [Symbol, String] :get/:post/:put/:patch/:delete + # @param path [String] path or full URL (base + `/api/v3` are stripped) + # @param query [Hash] + # @param body [Hash, String, nil] + # @return [Result] + def request(method, path, query: {}, body: nil) + response = call(method.to_sym, path, query: stringify(query), body: body) + Result.from_response(response) + end + + # Fetch `/me` (current application + account). + # + # @return [Result] + def me + Result.from_response(call(:get, "me", query: {}), single: true) + end + + private + + # Perform one HTTP call through the underlying gem, mapping its typed errors + # into {ApiError}. Returns the raw {BookingSync::API::Response} (or nil on + # 204) so pagination can read `Link` relations. + def call(method, path, query: {}, body: nil) + with_error_handling do + m = method.to_sym + normalized = normalize_path(path) + q = with_account_scope(query) + if %i[get head].include?(m) + @api.call(m, normalized, query: q) + else + @api.call(m, normalized, body, query: q) + end + end + end + + # Fetch the next page from a `Link`-header relation, through the same error + # handling / retry as a normal call (the relation otherwise bypasses it). + def fetch_next(relation) + with_error_handling { relation.call({}, method: :get) } + end + + # Run a request block, mapping gem errors to {ApiError} and retrying on 429 + # (up to @retries times) after waiting for the rate-limit window. + def with_error_handling + attempt = 0 + begin + yield + rescue BookingSync::API::RateLimitExceeded => e + raise ApiError.from_api(e) unless attempt < @retries + + attempt += 1 + @sleeper.call(retry_delay(e.headers)) + retry + rescue BookingSync::API::Error => e + raise ApiError.from_api(e) + rescue Faraday::Error => e + raise ApiError, "Network error talking to the API: #{e.message}" + end + end + + # Seconds to wait before a retry: prefer `Retry-After`, then the time until + # `X-RateLimit-Reset`, clamped to [1, @max_retry_wait]. + def retry_delay(headers) + retry_after = header(headers, "Retry-After").to_i + return clamp_delay(retry_after) if retry_after.positive? + + reset = header(headers, "X-RateLimit-Reset").to_i + return clamp_delay(reset - Time.now.to_i) if reset.positive? + + 1 + end + + def clamp_delay(seconds) + seconds.clamp(1, @max_retry_wait) + end + + def header(headers, name) + return nil unless headers + + headers[name] || headers[name.downcase] + end + + # Walk `next` links, appending records to `records` in place, until the + # limit is reached, there is no next page, a page comes back empty, or the + # max-pages backstop trips. Returns the {Result} of the last page fetched. + def follow_pages(response, records, limit:) + current = response + last = Result.from_response(response) + pages = 1 + + loop do + break if limit && records.length >= limit + + nxt = current&.relations&.[](:next) + break unless nxt + + # We have another page to fetch but have hit the backstop — warn only + # here (i.e. when data is genuinely being left behind), never when the + # page count merely equals @max_pages on a fully-fetched collection. + if pages >= @max_pages + warn_truncated(pages) + break + end + + current = fetch_next(nxt) + pages += 1 + page_result = Result.from_response(current) + break if page_result.records.empty? + + records.concat(page_result.records) + last = page_result + end + + last + end + + def member_path(path, id) + "#{path}/#{id}" + end + + def warn_truncated(pages) + warn("warning: stopped after #{pages} pages (max-pages #{@max_pages}); " \ + "results may be truncated — raise --max-pages to fetch more") + end + + # Add the configured account_id to a query unless the caller set one. + def with_account_scope(query) + return query if @account_id.nil? || @account_id.to_s.empty? + return query if query.key?("account_id") || query.key?(:account_id) + + query.merge("account_id" => @account_id) + end + + def wrap_body(resource_key, attributes) + case attributes + when Array + { resource_key => attributes } + when Hash + return attributes if attributes.key?(resource_key) || attributes.key?(resource_key.to_sym) + + { resource_key => [attributes] } + else + raise UsageError, "Request body must be a JSON object or array of objects." + end + end + + def stringify(query) + (query || {}).each_with_object({}) do |(k, v), acc| + next if v.nil? + + acc[k.to_s] = v + end + end + + # Accept full URLs, `/api/v3/...`, or bare paths and return a bare path. + def normalize_path(path) + p = path.to_s.strip + p = p.sub(%r{\Ahttps?://[^/]+}, "") + p = p.sub(%r{\A/}, "") + p.sub(%r{\Aapi/v3/}, "") + end + + def build_logger(verbose) + return nil unless verbose + + logger = Logger.new($stderr) + logger.level = Logger::DEBUG + # The API gem's logger middleware dumps request/response headers verbatim, + # which includes `Authorization: Bearer ` — scrub before writing. + logger.formatter = lambda do |severity, _time, prog, msg| + label = prog ? "#{prog}: " : "" + "[smily #{severity.downcase}] #{label}#{Redaction.scrub(msg)}\n" + end + logger + end + end +end diff --git a/lib/smily_cli/color.rb b/lib/smily_cli/color.rb new file mode 100644 index 0000000..b9056d6 --- /dev/null +++ b/lib/smily_cli/color.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module SmilyCli + # Minimal, dependency-free ANSI coloring. Coloring is a process-wide setting + # (driven by `--no-color`, the `NO_COLOR`/`SMILY_NO_COLOR` env vars, and + # whether stdout is a TTY) so formatters can call {Color.bold} etc. without + # threading a flag everywhere. + module Color + CODES = { + reset: 0, bold: 1, dim: 2, red: 31, green: 32, yellow: 33, + blue: 34, magenta: 35, cyan: 36, gray: 90 + }.freeze + + class << self + # @return [Boolean] whether ANSI codes are currently emitted + attr_accessor :enabled + end + + # Decide the default enabled state from the environment. Honors the + # informal `NO_COLOR` standard and requires an interactive stdout. + # + # @param stream [IO] stream that output is written to (defaults to $stdout) + # @return [Boolean] + def self.default_enabled?(stream: $stdout) + return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty? + return false if ENV["SMILY_NO_COLOR"] && !ENV["SMILY_NO_COLOR"].empty? + + stream.respond_to?(:tty?) && stream.tty? + end + + # Wrap `text` in the given ANSI styles when coloring is enabled. + # + # @param text [String] + # @param styles [Array] any of {CODES}' keys + # @return [String] + def self.paint(text, *styles) + return text.to_s unless enabled + + codes = styles.flatten.filter_map { |s| CODES[s] } + return text.to_s if codes.empty? + + "\e[#{codes.join(';')}m#{text}\e[0m" + end + + CODES.each_key do |name| + next if name == :reset + + define_singleton_method(name) { |text| paint(text, name) } + end + end + + Color.enabled = Color.default_enabled? +end diff --git a/lib/smily_cli/commands/auth_command.rb b/lib/smily_cli/commands/auth_command.rb new file mode 100644 index 0000000..b5c9550 --- /dev/null +++ b/lib/smily_cli/commands/auth_command.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module SmilyCli + module Commands + # OAuth helpers: obtain, refresh, inspect and (optionally) persist access + # tokens. Client id/secret and refresh token resolve from flags, then env, + # then the active profile, so once a profile holds the app credentials the + # flags become optional. + class AuthCommand < BaseCommand + CLIOptions.connection(self) + namespace "auth" + remove_command "tree" + + desc "client-credentials", "Get an app token via the Client Credentials flow" + long_desc <<~DESC + Request an application-level access token (public scope, valid ~2h) + that can read across every account which authorized the app. + + smily auth client-credentials --client-id ID --client-secret SECRET --save + DESC + method_option :client_id, type: :string, desc: "Application Client ID (env: SMILY_CLIENT_ID)" + method_option :client_secret, type: :string, desc: "Application Client Secret (env: SMILY_CLIENT_SECRET)" + method_option :scope, type: :string, desc: "Requested scope(s), space separated" + method_option :save, type: :boolean, desc: "Save the token to the active profile" + def client_credentials + token = context.oauth.client_credentials( + client_id: require_option(:client_id, context.client_id, "client id"), + client_secret: require_option(:client_secret, context.client_secret, "client secret"), + scope: options["scope"] + ) + render_token(token, save_client_id: options["client_id"] || context.client_id, + save_client_secret: options["client_secret"] || context.client_secret) + end + map "cc" => "client_credentials" + + desc "refresh", "Refresh an access token using a refresh token" + method_option :client_id, type: :string, desc: "Application Client ID" + method_option :client_secret, type: :string, desc: "Application Client Secret" + method_option :refresh_token, type: :string, desc: "Refresh token (env: SMILY_REFRESH_TOKEN)" + method_option :redirect_uri, type: :string, desc: "Original redirect URI (if required)" + method_option :save, type: :boolean, desc: "Save the new tokens to the active profile" + def refresh + token = context.oauth.refresh( + client_id: require_option(:client_id, context.client_id, "client id"), + client_secret: require_option(:client_secret, context.client_secret, "client secret"), + refresh_token: require_option(:refresh_token, context.refresh_token, "refresh token"), + redirect_uri: options["redirect_uri"] + ) + render_token(token, save_client_id: options["client_id"] || context.client_id, + save_client_secret: options["client_secret"] || context.client_secret) + end + + desc "authorize-url", "Print the URL to start the Authorization Code flow" + method_option :client_id, type: :string, required: true, desc: "Application Client ID" + method_option :redirect_uri, type: :string, required: true, desc: "Registered redirect URI" + method_option :scope, type: :string, desc: "Requested scope(s), space separated" + method_option :state, type: :string, desc: "Opaque state value" + method_option :account_id, type: :string, desc: "Pre-select an account to authorize" + method_option :response_type, type: :string, default: "code", desc: "code (server) or token (implicit)" + def authorize_url + url = context.oauth.authorize_url( + client_id: options["client_id"], + redirect_uri: options["redirect_uri"], + scope: options["scope"], + state: options["state"], + account_id: options["account_id"], + response_type: options["response_type"] + ) + notice("Open this URL, approve the app, then use `smily auth exchange` with the returned code:") + emit(url) + end + + desc "exchange", "Exchange an authorization code for tokens" + method_option :client_id, type: :string, desc: "Application Client ID" + method_option :client_secret, type: :string, desc: "Application Client Secret" + method_option :code, type: :string, required: true, desc: "Authorization code from the redirect" + method_option :redirect_uri, type: :string, required: true, desc: "Same redirect URI as the authorize step" + method_option :save, type: :boolean, desc: "Save the tokens to the active profile" + def exchange + token = context.oauth.authorization_code( + client_id: require_option(:client_id, context.client_id, "client id"), + client_secret: require_option(:client_secret, context.client_secret, "client secret"), + code: options["code"], + redirect_uri: options["redirect_uri"] + ) + render_token(token, save_client_id: options["client_id"] || context.client_id, + save_client_secret: options["client_secret"] || context.client_secret) + end + + desc "status", "Show whether a token is configured (and optionally validate it)" + method_option :check, type: :boolean, desc: "Validate the token against /me" + def status + token = context.token + if token.nil? + notice(Color.yellow("No token configured for profile #{context.profile_name.inspect}.")) + notice("Obtain one with `smily auth client-credentials` or configure it via `smily config set token ...`.") + return + end + + notice("Profile : #{context.profile_name}") + notice("Base URL: #{context.base_url}") + notice("Token : #{mask(token)}") + return unless options["check"] + + me = client.me.record || {} + account = me["account"] || me["id"] + success("Token is valid. Account: #{account}.") + end + + desc "token", "Print the resolved access token" + def token + value = context.token! + emit(value) + end + + no_commands do + # Print a token result, respecting -o json, and optionally persist it. + # + # Gate the full-payload dump on an *explicitly* requested json format, + # not the resolved one: non-TTY output defaults to json, so keying off + # {Context#output_format} would spill the whole payload — including the + # refresh_token — whenever the token is piped or captured in a command + # substitution, defeating token-only scripting. Default stays access + # token only; the full payload is opt-in via `-o json` / SMILY_OUTPUT. + def render_token(token, save_client_id: nil, save_client_secret: nil) + if context.requested_output_format == "json" + emit(JSON.pretty_generate(token.raw)) + else + emit(token.access_token) + notice(token_summary(token)) + end + save_token(token, save_client_id, save_client_secret) if options["save"] + end + + def token_summary(token) + parts = [] + parts << "type=#{token.token_type}" if token.token_type + parts << "scope=#{token.scope}" if token.scope + parts << "expires_in=#{token.expires_in}s" if token.expires_in + parts << "refresh_token=#{mask(token.refresh_token)}" if token.refresh_token + Color.dim(parts.join(" ")) + end + + def save_token(token, client_id, client_secret) + cfg = context.config + cfg.set("token", token.access_token) + cfg.set("base_url", context.base_url) + cfg.set("refresh_token", token.refresh_token) if token.refresh_token + cfg.set("client_id", client_id) if client_id + cfg.set("client_secret", client_secret) if client_secret + cfg.save + success("Saved token to profile #{cfg.current_profile_name.inspect} (#{cfg.path}).") + end + + def require_option(flag, resolved, label) + value = options[flag.to_s] || resolved + return value if value && !value.to_s.empty? + + raise UsageError, "Missing #{label}: pass --#{flag.to_s.tr('_', '-')} or set it in your profile." + end + + def mask(secret) + return "" if secret.nil? || secret.empty? + return "****" if secret.length <= 4 + + "#{'*' * (secret.length - 4)}#{secret[-4, 4]}" + end + end + end + end +end diff --git a/lib/smily_cli/commands/config_command.rb b/lib/smily_cli/commands/config_command.rb new file mode 100644 index 0000000..473c631 --- /dev/null +++ b/lib/smily_cli/commands/config_command.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module SmilyCli + module Commands + # Manage the on-disk config file and its profiles. Secrets are masked in any + # human-readable listing; the raw file (mode 0600) remains the source of + # truth. All subcommands accept `--profile` to target a specific profile. + class ConfigCommand < BaseCommand + SECRET_KEYS = %w[token refresh_token client_secret mcp_token].freeze + + namespace "config" + remove_command "tree" + class_option :profile, type: :string, banner: "NAME", desc: "Profile to target" + class_option :quiet, type: :boolean, aliases: "-q" + class_option :no_color, type: :boolean + + desc "path", "Print the config file path" + def path + emit(config.path) + end + + desc "init", "Create/append a profile interactively" + long_desc <<~DESC + Initialize a profile by storing a token (and base URL). Values can be + passed as flags to run non-interactively: + + smily config init --token TOKEN + smily config init --profile staging --base-url https://staging.example.com --token TOKEN + DESC + method_option :token, type: :string, desc: "Access token to store" + method_option :base_url, type: :string, desc: "API base URL to store" + def init + name = target_profile + token = options["token"] || prompt("Access token") + base_url = options["base_url"] || prompt("Base URL [https://www.bookingsync.com]", default: Context::DEFAULT_BASE_URL) + + config.set("token", token, profile: name) unless token.to_s.empty? + config.set("base_url", base_url, profile: name) unless base_url.to_s.empty? + config.current_profile_name = name unless config.profile_names.length > 1 && options["profile"].nil? + config.save + success("Wrote profile #{name.inspect} to #{config.path}.") + end + + desc "list", "List profiles and their settings (secrets masked)" + def list + if config.profile_names.empty? + notice("No profiles configured yet. Create one with `smily config init` or `smily config set`.") + return + end + + if context_output_json? + emit(JSON.pretty_generate(masked_profiles)) + return + end + + lines = config.profile_names.map do |name| + marker = name == config.current_profile_name ? Color.green("* ") : " " + settings = masked_profile(name).map { |k, v| "#{k}=#{v}" }.join(" ") + "#{marker}#{Color.bold(name)} #{Color.dim(settings)}" + end + emit(lines.join("\n")) + end + + desc "get KEY", "Print a single setting from the target profile" + def get(key) + value = config.profile(target_profile)[key] + emit(value.nil? ? "" : value.to_s) + end + + desc "set KEY VALUE", "Set a setting on the target profile" + def set(key, value) + warn_unknown_key(key) + config.set(key, coerce(value), profile: target_profile) + config.save + success("Set #{key} on profile #{target_profile.inspect}.") + end + + desc "unset KEY", "Remove a setting from the target profile" + def unset(key) + config.unset(key, profile: target_profile) + config.save + success("Unset #{key} on profile #{target_profile.inspect}.") + end + + desc "use NAME", "Set the default profile" + def use(name) + unless config.profile?(name) + notice(Color.yellow("Profile #{name.inspect} has no settings yet; selecting it anyway.")) + end + config.current_profile_name = name + config.save + success("Default profile is now #{name.inspect}.") + end + + desc "remove NAME", "Delete a profile" + method_option :yes, type: :boolean, aliases: "-y" + def remove(name) + confirm!("Delete profile #{name.inspect}?") + config.delete_profile(name) + config.save + success("Removed profile #{name.inspect}.") + end + + no_commands do + def config + @config ||= Config.load + end + + def target_profile + options["profile"] || config.current_profile_name + end + + def masked_profiles + config.profile_names.to_h { |n| [n, masked_profile(n)] } + end + + def masked_profile(name) + config.profile(name).each_with_object({}) do |(k, v), h| + h[k] = SECRET_KEYS.include?(k) ? mask(v.to_s) : v + end + end + + def context_output_json? + fmt = options["output"] || ENV.fetch("SMILY_OUTPUT", nil) + fmt.to_s == "json" + end + + def warn_unknown_key(key) + return if Config::KNOWN_KEYS.include?(key) + + notice(Color.yellow("Note: #{key.inspect} isn't a standard setting (#{Config::KNOWN_KEYS.join(', ')}).")) + end + + def coerce(value) + case value + when /\A-?\d+\z/ then value.to_i + when "true" then true + when "false" then false + else value + end + end + + def prompt(label, default: nil) + $stderr.print("#{label}: ") + input = $stdin.gets&.strip + input = default if input.nil? || input.empty? + input + end + + def mask(secret) + return "" if secret.nil? || secret.empty? + return "****" if secret.length <= 4 + + "#{'*' * [secret.length - 4, 4].max}#{secret[-4, 4]}" + end + end + end + end +end diff --git a/lib/smily_cli/commands/mcp_command.rb b/lib/smily_cli/commands/mcp_command.rb new file mode 100644 index 0000000..49d42b7 --- /dev/null +++ b/lib/smily_cli/commands/mcp_command.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +module SmilyCli + module Commands + # `smily mcp` — talk to a BookingSync MCP server (Model Context Protocol) + # instead of the REST API. This is a separate mode with its own credential + # (`mcp_token`) and endpoint (`mcp_url`, defaulting to `/mcp`). + # + # Two layers: + # * low-level — `tools` (discover) and `call` (invoke) work against any + # MCP server, whatever tools it exposes; + # * convenience — `list` / `get` / `schema` / `resources` mirror the REST + # resource commands, routed through the server's generic dispatcher + # tools, so the mental model carries over. + class McpCommand < BaseCommand + namespace "mcp" + remove_command "tree" + CLIOptions.mcp(self) + CLIOptions.output(self) + + desc "info", "Handshake with the MCP server and show its capabilities" + def info + render_payload(mcp_client.connect) + end + + desc "tools", "List the tools the MCP server exposes" + def tools + list = mcp_client.list_tools + if context.output_format == "table" + rows = list.map { |t| { "name" => t["name"], "description" => t["description"] } } + render_result(Result.new(records: rows, single: false)) + else + render_payload(list) + end + end + + desc "call TOOL", "Call an MCP tool by name with --args JSON" + long_desc <<~DESC + Invoke any tool the server exposes. Arguments are a JSON object: + + smily mcp call list --args '{"resource":"rentals","limit":5}' + smily mcp call resource_schema --args '{"resource":"bookings"}' + smily mcp call get --args @args.json + DESC + method_option :args, type: :string, aliases: "-a", banner: "JSON|@file|-", + desc: "Tool arguments as JSON, @file, or - for stdin" + def call(tool) + args = options["args"] ? parse_data(options["args"]) : {} + render_tool_result(mcp_client.call_tool(tool, args)) + end + + desc "resources", "List the resources exposed via MCP (generic `resources` tool)" + def resources + render_tool_result(mcp_client.call_tool(tool_name("resources"), {})) + end + + desc "list RESOURCE", "List a resource's records via the MCP `list` tool" + long_desc <<~DESC + Mirror of `smily list`, routed through the MCP server: + + smily mcp list rentals --limit 5 + smily mcp list bookings --filter status=booked --account-id 42 + + Filters support the API v3 operator DSL via `attribute[op]=value` + (operators: #{SmilyCli::QueryOptions::OPERATORS.join(', ')}). Repeat an + attribute to combine conditions (AND), e.g. a range: + + smily mcp list bookings --filter 'final_price[gteq]=600' --filter 'final_price[lt]=800' + smily mcp list bookings --filter 'start_at[gt]=2026-01-01T00:00:00Z' + smily mcp list bookings --filter 'reference[in]=A123,B456' + DESC + method_option :filter, type: :array, banner: "k[op]=v", desc: "Attribute filters (supports operators + ranges)" + method_option :fields, type: :array, banner: "a b c", desc: "Sparse fieldset" + method_option :limit, type: :numeric, aliases: "-n", desc: "Page size" + method_option :offset, type: :numeric, desc: "Pagination offset" + method_option :ids, type: :string, desc: "Comma-separated ids to fetch" + method_option :updated_since, type: :string, desc: "ISO8601 timestamp filter" + def list(resource) + args = { "resource" => resource } + args["filter"] = QueryOptions.parse_filter(options["filter"]) if options["filter"] + args["fields"] = context.fields if context.fields + args["limit"] = options["limit"] if options["limit"] + args["offset"] = options["offset"] if options["offset"] + args["ids"] = options["ids"] if options["ids"] + args["updated_since"] = options["updated_since"] if options["updated_since"] + args.merge!(account_arg) + render_tool_result(mcp_client.call_tool(tool_name("list"), args)) + end + + desc "get RESOURCE ID", "Fetch one record via the MCP `get` tool" + method_option :fields, type: :array, banner: "a b c", desc: "Sparse fieldset" + def get(resource, id) + args = { "resource" => resource, "id" => coerce_id(id) } + args["fields"] = context.fields if context.fields + args.merge!(account_arg) + render_tool_result(mcp_client.call_tool(tool_name("get"), args), single: true) + end + + desc "schema RESOURCE", "Show a resource's schema via the MCP `resource_schema` tool" + def schema(resource) + render_tool_result(mcp_client.call_tool(tool_name("resource_schema"), { "resource" => resource }), single: true) + end + + desc "login [TOKEN]", "Save MCP credentials (token, and optional URL) to the active profile" + method_option :url, type: :string, desc: "MCP server endpoint to store" + def login(token = nil) + mcp_token = token || options["mcp_token"] + if mcp_token.to_s.empty? + raise UsageError, "Provide the MCP token: smily mcp login (or --mcp-token TOKEN)." + end + + cfg = context.config + cfg.set("mcp_token", mcp_token) + cfg.set("mcp_url", options["url"] || options["mcp_url"]) if options["url"] || options["mcp_url"] + cfg.save + success("Saved MCP credentials to profile #{cfg.current_profile_name.inspect} (#{cfg.path}).") + info_line("Try it with: smily mcp info") + end + + no_commands do + def mcp_client + @mcp_client ||= context.mcp_client + end + + # Resolve a convenience verb to an actual tool name, tolerating servers + # that prefix their generic tools (e.g. `api_v3_list` vs `list`). + def tool_name(base) + @tool_names ||= mcp_client.list_tools.map { |t| t["name"] } + @tool_names.find { |n| n == base } || + @tool_names.find { |n| n.end_with?("_#{base}", ":#{base}") } || + base + end + + def account_arg + id = context.account_id + id.nil? || id.to_s.empty? ? {} : { "account_id" => coerce_id(id) } + end + + def coerce_id(value) + value.to_s.match?(/\A\d+\z/) ? value.to_i : value + end + + # Render an MCP tool result: raise on `isError`, else render the + # structured output (or parsed text content) like any other payload. + # `single:` renders a member (e.g. `get`/`schema`) rather than a list. + def render_tool_result(result, single: false) + raise McpError, tool_text(result) if result["isError"] + + payload = result.key?("structuredContent") ? result["structuredContent"] : parse_text_content(result) + render_payload(payload, single: single) + end + + def tool_text(result) + Array(result["content"]).filter_map { |item| item["text"] }.join("\n") + end + + def parse_text_content(result) + text = tool_text(result) + return text if text.strip.empty? + + JSON.parse(text) + rescue JSON::ParserError + text + end + + # Render an arbitrary payload: a JSON:API envelope becomes a record + # table/list; a bare object renders as a single record; a string prints + # as-is. `single:` forces member (object) rendering for wrappers like + # `get`/`schema`. + def render_payload(payload, single: false) + case payload + when nil then nil + when String then emit(payload) + when Array then render_result(Result.new(records: payload, single: single)) + when Hash then render_hash_payload(payload, single: single) + else emit(JSON.pretty_generate(payload)) + end + end + + def render_hash_payload(payload, single: false) + key = Result.resource_key(payload) + if key && payload[key].is_a?(Array) + render_result(Result.from_body(payload, single: single)) + else + render_result(Result.new(records: [payload], single: true)) + end + end + + def info_line(message) + notice(Color.dim(message)) + end + end + end + end +end diff --git a/lib/smily_cli/completion.rb b/lib/smily_cli/completion.rb new file mode 100644 index 0000000..011a7c7 --- /dev/null +++ b/lib/smily_cli/completion.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module SmilyCli + # Generates shell completion scripts. The scripts are static (they embed the + # known resource and command names at generation time) which keeps completion + # instant — no subshell to the CLI on every . + module Completion + SHELLS = %w[bash zsh fish].freeze + # Subcommands shared by every resource. + RESOURCE_ACTIONS = %w[list get create update delete].freeze + + module_function + + # @param shell [String] one of {SHELLS} + # @return [String] the completion script + # @raise [UsageError] for an unsupported shell + def script(shell) + case shell.to_s + when "bash" then bash + when "zsh" then zsh + when "fish" then fish + else + raise UsageError, "Unsupported shell #{shell.inspect}. Supported: #{SHELLS.join(', ')}." + end + end + + # @return [Array] every top-level command word + def top_level + (Registry.commands + %w[api auth config resources whoami completion version help]).uniq.sort + end + + def bash + <<~BASH + # smily bash completion. Install with: + # smily completion bash > /usr/local/etc/bash_completion.d/smily + _smily() { + local cur prev words cword + _init_completion 2>/dev/null || { cur="${COMP_WORDS[COMP_CWORD]}"; prev="${COMP_WORDS[COMP_CWORD-1]}"; } + local top="#{top_level.join(' ')}" + local actions="#{RESOURCE_ACTIONS.join(' ')}" + if [ "$COMP_CWORD" -eq 1 ]; then + COMPREPLY=( $(compgen -W "$top" -- "$cur") ) + elif [ "$COMP_CWORD" -eq 2 ]; then + COMPREPLY=( $(compgen -W "$actions" -- "$cur") ) + fi + return 0 + } + complete -F _smily smily + BASH + end + + def zsh + <<~ZSH + #compdef smily + # smily zsh completion. Install by placing on your $fpath as _smily, e.g.: + # smily completion zsh > "${fpath[1]}/_smily" + _smily() { + local -a top actions + top=(#{top_level.map { |c| "'#{c}'" }.join(' ')}) + actions=(#{RESOURCE_ACTIONS.map { |c| "'#{c}'" }.join(' ')}) + if (( CURRENT == 2 )); then + compadd -- $top + elif (( CURRENT == 3 )); then + compadd -- $actions + fi + } + compdef _smily smily + ZSH + end + + def fish + lines = [] + lines << "# smily fish completion. Install with:" + lines << "# smily completion fish > ~/.config/fish/completions/smily.fish" + lines << "complete -c smily -f" + top_level.each do |cmd| + lines << "complete -c smily -n '__fish_use_subcommand' -a '#{cmd}'" + end + RESOURCE_ACTIONS.each do |action| + lines << "complete -c smily -n '__fish_seen_subcommand_from #{Registry.commands.join(' ')}' -a '#{action}'" + end + "#{lines.join("\n")}\n" + end + end +end diff --git a/lib/smily_cli/config.rb b/lib/smily_cli/config.rb new file mode 100644 index 0000000..e9ac4a7 --- /dev/null +++ b/lib/smily_cli/config.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "yaml" +require "fileutils" + +module SmilyCli + # On-disk configuration: a YAML file holding one or more named profiles, each + # a bag of credentials/settings (token, base_url, refresh_token, client_id, + # client_secret, ...). Precedence between file, environment and flags lives in + # {Context}; this class only owns reading and writing the file. + # + # File shape: + # current_profile: default + # profiles: + # default: + # token: "..." + # base_url: "https://www.bookingsync.com" + class Config + # Keys that are meaningful inside a profile. Unknown keys are preserved on + # save but this list drives validation and `config set` completion. + KNOWN_KEYS = %w[ + token base_url refresh_token client_id client_secret redirect_uri + scope account_id mcp_token mcp_url + ].freeze + + DEFAULT_PROFILE = "default" + + # @return [String] absolute path to the config file backing this instance + attr_reader :path + + # Resolve the default config path, honoring `SMILY_CONFIG` (full path), + # then `XDG_CONFIG_HOME`, then `~/.config`. + # + # @return [String] + def self.default_path + return ENV["SMILY_CONFIG"] if ENV["SMILY_CONFIG"] && !ENV["SMILY_CONFIG"].empty? + + base = ENV.fetch("XDG_CONFIG_HOME", nil) + base = File.join(Dir.home, ".config") if base.nil? || base.empty? + File.join(base, "smily", "config.yml") + end + + # Load configuration from `path` (missing file yields an empty config). + # + # @param path [String] + # @return [Config] + def self.load(path = default_path) + new(path) + end + + # @param path [String] + def initialize(path = self.class.default_path) + @path = path + @data = read + end + + # @return [Boolean] whether the backing file exists on disk + def exist? + File.exist?(path) + end + + # Name of the active profile: explicit `current_profile` or DEFAULT_PROFILE. + # @return [String] + def current_profile_name + @data["current_profile"] || DEFAULT_PROFILE + end + + # @param name [String] + def current_profile_name=(name) + @data["current_profile"] = name.to_s + end + + # @return [Array] sorted profile names present in the file + def profile_names + profiles.keys.sort + end + + # Fetch a profile's settings hash (never nil). + # + # @param name [String, nil] defaults to the current profile + # @return [Hash{String=>Object}] + def profile(name = current_profile_name) + profiles[name.to_s] || {} + end + + # @param name [String, nil] + # @return [Boolean] + def profile?(name = current_profile_name) + profiles.key?(name.to_s) + end + + # Set a single key on a profile (creating the profile if needed). + # + # @param key [String, Symbol] + # @param value [Object] + # @param profile [String] + # @return [void] + def set(key, value, profile: current_profile_name) + profiles[profile.to_s] ||= {} + profiles[profile.to_s][key.to_s] = value + end + + # Remove a key from a profile. + # + # @return [Object, nil] the removed value + def unset(key, profile: current_profile_name) + (profiles[profile.to_s] || {}).delete(key.to_s) + end + + # Delete an entire profile. + # + # @param name [String] + # @return [Hash, nil] the removed profile + def delete_profile(name) + profiles.delete(name.to_s) + end + + # Persist the config to {#path}, creating the directory and locking down + # permissions (the file holds secrets). + # + # @return [String] the path written + def save + dir = File.dirname(path) + created_dir = !File.directory?(dir) + FileUtils.mkdir_p(dir) + # Only tighten a directory we created, so we never clobber the perms of a + # pre-existing (possibly shared) parent. + File.chmod(0o700, dir) if created_dir + # Create with 0600 from the start so there is no world-readable window; + # re-chmod covers a pre-existing file whose perms may have drifted. + File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file| + file.write(YAML.dump(prune(@data))) + end + File.chmod(0o600, path) + path + end + + # @return [Hash{String=>Hash}] the profiles map (mutable) + def profiles + @data["profiles"] ||= {} + end + + private + + def read + return {} unless File.exist?(path) + + parsed = YAML.safe_load_file(path, permitted_classes: [], aliases: false) + data = parsed.is_a?(Hash) ? parsed : {} + validate_shape!(data) + data + rescue Psych::SyntaxError => e + raise ConfigurationError, "Config file #{path} is not valid YAML: #{e.message}" + rescue Psych::Exception => e + # A structurally valid YAML file can still carry a value safe_load refuses + # (a bare date/time -> DisallowedClass, an alias -> AliasesNotEnabled). + # Surface it as a clean CLI error instead of an uncaught Psych backtrace. + raise ConfigurationError, + "Config file #{path} has an unsupported value (#{e.message}). " \ + "Quote any value that looks like a date, time, or symbol." + end + + # Guard against a well-formed-YAML-but-wrong-shape config (e.g. a hand edit + # leaving `profiles: []`), which would otherwise blow up with a raw + # NoMethodError deep in a later command instead of a clean CLI error. + def validate_shape!(data) + profiles = data["profiles"] + return if profiles.nil? + + unless profiles.is_a?(Hash) + raise ConfigurationError, + "Config file #{path}: `profiles` must be a mapping of name => settings, got #{profiles.class}." + end + + profiles.each do |name, settings| + next if settings.nil? || settings.is_a?(Hash) + + raise ConfigurationError, + "Config file #{path}: profile #{name.inspect} must be a mapping of settings, got #{settings.class}." + end + end + + # Drop empty profiles/containers so we never write noise. + def prune(data) + copy = data.dup + if copy["profiles"].is_a?(Hash) + copy["profiles"] = copy["profiles"].reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } + end + copy + end + end +end diff --git a/lib/smily_cli/context.rb b/lib/smily_cli/context.rb new file mode 100644 index 0000000..d1abc52 --- /dev/null +++ b/lib/smily_cli/context.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require "uri" + +module SmilyCli + # Resolves effective settings for a command from three layers, highest wins: + # + # 1. explicit flags (the Thor `options` hash) + # 2. environment variables (`SMILY_*`, with `BOOKINGSYNC_*` fallbacks) + # 3. the selected config profile + # + # A Context is the single object a command needs: it exposes the resolved + # token/base_url/output settings and can build a {Client}. It never performs + # I/O beyond reading the config file. + class Context + DEFAULT_BASE_URL = "https://www.bookingsync.com" + OUTPUT_FORMATS = %w[table json yaml csv ndjson jsonl].freeze + DEFAULT_MAX_PAGES = 1000 + + # @return [Config] + attr_reader :config + # @return [Hash] the raw flags this context was built from + attr_reader :options + + # Build a Context from a Thor options hash. + # + # @param options [Hash] parsed CLI options (string or symbol keys) + # @param config [Config] loaded configuration + # @return [Context] + def self.from(options, config: nil) + opts = options.each_with_object({}) { |(k, v), h| h[k.to_s] = v } + cfg = config || Config.load + if (profile = opts["profile"] || ENV.fetch("SMILY_PROFILE", nil)) + cfg.current_profile_name = profile + end + new(opts, cfg) + end + + # @param options [Hash{String=>Object}] + # @param config [Config] + def initialize(options, config) + @options = options + @config = config + apply_color_setting + end + + # @return [String] the profile these settings resolve against + def profile_name + config.current_profile_name + end + + # The OAuth access token. Resolution order applies. + # + # @return [String, nil] + def token + resolve("token", env: %w[SMILY_TOKEN BOOKINGSYNC_TOKEN SMILY_ACCESS_TOKEN]) + end + + # Like {#token} but raises when nothing is configured. + # + # @return [String] + def token! + token || raise(ConfigurationError, <<~MSG.strip) + No API token configured. Provide one with --token, set SMILY_TOKEN, or run: + smily config set token + You can obtain a token with `smily auth client-credentials` or `smily auth exchange`. + MSG + end + + # API base URL (no trailing `/api/v3`; the client appends that). + # + # @return [String] + def base_url + resolve("base_url", env: %w[SMILY_BASE_URL BOOKINGSYNC_URL]) || DEFAULT_BASE_URL + end + + # Account to scope requests to. Primarily for Client-Credentials-flow tokens + # that span multiple accounts; sent as the `account_id` query parameter (REST) + # or `account_id` tool argument (MCP). + # + # @return [String, nil] + def account_id + resolve("account_id", env: %w[SMILY_ACCOUNT_ID BOOKINGSYNC_ACCOUNT_ID]) + end + + # The MCP-mode access token (distinct from the REST API token). + # + # @return [String, nil] + def mcp_token + resolve("mcp_token", env: %w[SMILY_MCP_TOKEN]) + end + + # @return [String] + def mcp_token! + mcp_token || raise(ConfigurationError, <<~MSG.strip) + No MCP token configured. Provide one with --mcp-token, set SMILY_MCP_TOKEN, or run: + smily mcp login --token + MSG + end + + # The MCP server endpoint. Defaults to `/mcp`, which is where a + # BookingSync app mounts its MCP server; override for other servers. + # + # @return [String] + def mcp_url + explicit = resolve("mcp_url", env: %w[SMILY_MCP_URL]) + return explicit if explicit && !explicit.empty? + + URI.join("#{base_url.chomp('/')}/", "mcp").to_s + end + + # The output format explicitly requested via `--output` or `SMILY_OUTPUT`, + # validated, or nil when none was given. Commands whose default isn't the + # usual table/json (e.g. `api`, which defaults to the raw envelope) use this + # so they still honor an explicit flag/env choice. + # + # @return [String, nil] + def requested_output_format + raw = (options["output"] || ENV.fetch("SMILY_OUTPUT", nil))&.to_s + return nil if raw.nil? || raw.empty? + + validate_output_format!(raw) + end + + # Chosen output format. Defaults to `table` on a TTY, `json` otherwise, so + # piping produces machine-readable output without a flag. + # + # @return [String] + def output_format + requested_output_format || default_output_format + end + + # Field selection shared between sparse fieldsets (sent to the API) and + # column selection (used by table/csv). Accepts repeated flags and/or + # comma-separated values. + # + # @return [Array, nil] + def fields + raw = options["fields"] + return nil if raw.nil? + + list = Array(raw).flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?) + list.empty? ? nil : list + end + + # @return [Boolean] + def verbose? + truthy(options["verbose"]) || truthy(ENV.fetch("SMILY_VERBOSE", nil)) + end + + # @return [Boolean] + def quiet? + truthy(options["quiet"]) + end + + # @return [Boolean] whether ANSI color is active for this run + def color? + Color.enabled + end + + # Values used to seed OAuth commands, pulled from flags then profile. + # @return [String, nil] + def client_id = resolve("client_id", env: %w[SMILY_CLIENT_ID]) + # @return [String, nil] + def client_secret = resolve("client_secret", env: %w[SMILY_CLIENT_SECRET]) + # @return [String, nil] + def refresh_token = resolve("refresh_token", env: %w[SMILY_REFRESH_TOKEN]) + + # Build an API client from these settings. + # + # Hard backstop for auto-pagination (`--all` / `--limit`), so a server that + # keeps advertising a `next` link can't loop forever. + # + # @return [Integer] + def max_pages + raw = resolve("max_pages", env: %w[SMILY_MAX_PAGES]) + value = raw.to_i + value.positive? ? value : DEFAULT_MAX_PAGES + end + + # Number of times to retry a `429 Too Many Requests` (waiting for the + # rate-limit window each time). Off by default, like the GitHub CLI. + # + # @return [Integer] + def retries + resolve("retry", env: %w[SMILY_RETRY]).to_i.clamp(0, 10) + end + + # @return [Client] + def client + warn_insecure_transport(base_url) + Client.new( + token: token!, + base_url: base_url, + account_id: account_id, + max_pages: max_pages, + retries: retries, + verbose: verbose? + ) + end + + # An OAuth helper bound to this context's base URL. + # + # @return [OAuth] + def oauth + warn_insecure_transport(base_url) + OAuth.new(base_url: base_url, verbose: verbose?) + end + + # An MCP client for the configured MCP server and token. + # + # @return [Mcp::Client] + def mcp_client + warn_insecure_transport(mcp_url) + Mcp::Client.new(token: mcp_token!, url: mcp_url, verbose: verbose?) + end + + private + + # flag ? env ? profile + def resolve(key, env: []) + flag = options[key] + return flag if present?(flag) + + env.each do |name| + value = ENV.fetch(name, nil) + return value if present?(value) + end + + profile_value = config.profile[key] + present?(profile_value) ? profile_value : nil + end + + def validate_output_format!(fmt) + return fmt if OUTPUT_FORMATS.include?(fmt) + + raise UsageError, "Unknown output format #{fmt.inspect}. Valid: #{OUTPUT_FORMATS.join(', ')}." + end + + def default_output_format + $stdout.respond_to?(:tty?) && $stdout.tty? ? "table" : "json" + end + + def apply_color_setting + if truthy(options["no_color"]) || (ENV.fetch("NO_COLOR", nil) && !ENV["NO_COLOR"].empty?) + Color.enabled = false + elsif options.key?("color") && truthy(options["color"]) + Color.enabled = true + end + end + + # Warn (once, to stderr) when credentials would travel over a non-HTTPS + # endpoint. Like the GitHub CLI, we warn rather than hard-refuse so local + # test servers still work. + def warn_insecure_transport(url) + return if quiet? + + scheme = URI(url).scheme + return if scheme == "https" + + label = scheme.nil? || scheme.empty? ? "an insecure" : scheme.upcase + warn(Color.yellow("warning: sending credentials over #{label} to #{url} — prefer HTTPS")) + rescue URI::InvalidURIError + nil + end + + def present?(value) + !value.nil? && !(value.respond_to?(:empty?) && value.empty?) + end + + def truthy(value) + return false if value.nil? + return value if [true, false].include?(value) + + %w[1 true yes on].include?(value.to_s.strip.downcase) + end + end +end diff --git a/lib/smily_cli/errors.rb b/lib/smily_cli/errors.rb new file mode 100644 index 0000000..0095987 --- /dev/null +++ b/lib/smily_cli/errors.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "json" + +module SmilyCli + # Base class for every error the CLI raises deliberately. Command handlers + # rescue this and print a clean, single-line message instead of a backtrace. + class Error < StandardError + # Process exit status to use when this error reaches the top level. + # @return [Integer] + def exit_status + 1 + end + end + + # Raised when configuration/credentials are missing or malformed, e.g. no + # token could be resolved from flags, environment, or the config file. + class ConfigurationError < Error; end + + # Raised when the user asks for something the CLI cannot satisfy: an unknown + # resource, a bad flag combination, unparseable `--data`, etc. + class UsageError < Error + def exit_status + 2 + end + end + + # Raised when an MCP interaction fails: a JSON-RPC error envelope, a tool that + # returns `isError: true`, a missing session, or a transport failure. + class McpError < Error + # @return [Integer, nil] JSON-RPC error code, when present + attr_reader :code + + # @param message [String] + # @param code [Integer, nil] + def initialize(message, code: nil) + super(message) + @code = code + end + + def exit_status + # -32000 "Unauthorized", -32001 "Session not found" map to auth failures. + [-32_000, -32_001].include?(code) ? 3 : 1 + end + end + + # Raised when the API returns a non-success HTTP status. Carries the parsed + # status/body so callers can render a helpful message. + class ApiError < Error + # @return [Integer, nil] HTTP status code + attr_reader :status + # @return [Object, nil] parsed response body (Hash/Array) when JSON, else the raw string + attr_reader :body + # @return [Hash] response headers + attr_reader :headers + + # Build an {ApiError} from a {BookingSync::API::Error}. + # + # @param error [BookingSync::API::Error] + # @return [ApiError] + def self.from_api(error) + status = error.respond_to?(:status) ? error.status : nil + headers = error.respond_to?(:headers) ? error.headers : {} + body = error.respond_to?(:body) ? error.body : nil + new(summary(status, body), status: status, body: parse_body(body), headers: headers || {}) + end + + # @param message [String] + # @param status [Integer, nil] + # @param body [Object, nil] + # @param headers [Hash] + def initialize(message, status: nil, body: nil, headers: {}) + super(message) + @status = status + @body = body + @headers = headers || {} + end + + def exit_status + case status + when 401, 403 then 3 + when 404 then 4 + when 429 then 5 + else 1 + end + end + + # Best-effort extraction of API error detail into a one-line summary. + # + # @param status [Integer, nil] + # @param raw_body [String, nil] + # @return [String] + def self.summary(status, raw_body) + parsed = parse_body(raw_body) + detail = extract_detail(parsed) + label = status ? "API request failed (HTTP #{status})" : "API request failed" + detail ? "#{label}: #{detail}" : label + end + + # @api private + def self.parse_body(raw_body) + return raw_body unless raw_body.is_a?(String) + return nil if raw_body.empty? + + JSON.parse(raw_body) + rescue JSON::ParserError + raw_body + end + + # Pull a human message out of the many shapes BookingSync error bodies take: + # { "errors": [ { "title": ..., "detail": ... } ] } + # { "errors": { "field": ["msg"] } } + # { "error": "..." } / { "error_description": "..." } (OAuth) + # @api private + def self.extract_detail(parsed) + return parsed if parsed.is_a?(String) + return nil unless parsed.is_a?(Hash) + + if (oauth = parsed["error_description"] || parsed["error"]) + return oauth.to_s + end + + errors = parsed["errors"] || parsed["error"] + case errors + when Array + errors.map { |e| error_line(e) }.compact.join("; ") + when Hash + errors.map { |field, msgs| "#{field}: #{Array(msgs).join(', ')}" }.join("; ") + when String + errors + end + end + + # @api private + def self.error_line(entry) + return entry if entry.is_a?(String) + return nil unless entry.is_a?(Hash) + + [entry["title"], entry["detail"]].compact.uniq.join(" - ") + end + private_class_method :error_line + end +end diff --git a/lib/smily_cli/formatters.rb b/lib/smily_cli/formatters.rb new file mode 100644 index 0000000..7f31119 --- /dev/null +++ b/lib/smily_cli/formatters.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "json" +require "yaml" +require "csv" + +module SmilyCli + # Rendering of {Result} records into the output formats the CLI supports. + # Formatters operate on plain record hashes (string keys) and return a String; + # the command layer decides where to print it. Table output is human-first + # (aligned columns, optional color, vertical layout for single records); the + # rest are pipe-friendly and lossless. + module Formatters + # Maximum width of a single table column before truncation. + MAX_COL_WIDTH = 48 + # Default cap on the number of columns shown in a multi-row table. + MAX_TABLE_COLUMNS = 8 + + module_function + + # Render a result in the requested format. + # + # @param result [Result] + # @param format [String] one of {Context::OUTPUT_FORMATS} + # @param fields [Array, nil] explicit column/field selection + # @return [String] + def render(result, format:, fields: nil) + records = result.records + payload = result.single? ? records.first : records + + case format + when "json" then json(payload) + when "yaml" then yaml(payload) + when "ndjson", "jsonl" then ndjson(records) + when "csv" then csv(records, fields: fields) + when "table" then table(result, fields: fields) + else + raise UsageError, "Unknown output format #{format.inspect}." + end + end + + # @return [String] pretty JSON (object for single, array for collections) + def json(payload) + JSON.pretty_generate(payload.nil? ? [] : payload) + end + + # @return [String] + def yaml(payload) + YAML.dump(deep_stringify(payload.nil? ? [] : payload)) + end + + # @return [String] one compact JSON object per line + def ndjson(records) + records.map { |r| JSON.generate(r) }.join("\n") + end + + # @return [String] CSV with a header row; nested values become compact JSON + def csv(records, fields: nil) + return "" if records.empty? + + rows = rowify(records) + columns = fields || union_columns(rows) + CSV.generate do |out| + out << columns + rows.each do |record| + out << columns.map { |c| scalarize(record[c]) } + end + end + end + + # Render a table: a vertical key/value layout for a single record, or an + # aligned grid for a collection. + # + # @param result [Result] + # @param fields [Array, nil] + # @return [String] + def table(result, fields: nil) + records = rowify(result.records) + return Color.dim("No records found.") if records.empty? + + if result.single? + vertical_table(records.first, fields: fields) + else + grid_table(records, fields: fields, meta: table_footer(result, records)) + end + end + + # ---- table internals --------------------------------------------------- + + # Coerce records so table/CSV rendering never assumes a Hash: a non-hash + # record (a scalar or array an arbitrary MCP tool might return) becomes a + # single `value` column. + # + # @api private + def rowify(records) + records.map { |record| record.is_a?(Hash) ? record : { "value" => record } } + end + + # @api private + def vertical_table(record, fields: nil) + columns = fields || record.keys + width = columns.map { |c| c.to_s.length }.max || 0 + lines = columns.map do |col| + label = Color.bold(col.to_s.ljust(width)) + "#{label} #{scalarize(record[col], limit: 200)}" + end + lines.join("\n") + end + + # @api private + def grid_table(records, fields:, meta: nil) + columns = fields || default_columns(records) + widths = column_widths(records, columns) + numeric = numeric_columns(records, columns) + + header = columns.map.with_index do |col, i| + Color.bold(pad(col.to_s, widths[i], right: numeric[col])) + end.join(" ") + rule = Color.dim(columns.map.with_index { |_, i| "-" * widths[i] }.join(" ")) + + rows = records.map do |record| + columns.map.with_index do |col, i| + pad(scalarize(record[col]), widths[i], right: numeric[col]) + end.join(" ") + end + + [header, rule, *rows, meta].compact.join("\n") + end + + # @api private + def table_footer(result, records) + count = records.length + parts = [] + parts << "#{count} #{count == 1 ? 'record' : 'records'}" + parts << "#{result.total_pages} pages total" if result.total_pages + hidden = hidden_field_note(records) + parts << hidden if hidden + Color.dim("(#{parts.join(', ')})") + end + + # @api private + def hidden_field_note(records) + return nil if records.empty? + + total = union_columns(records).length + shown = default_columns(records).length + return nil if total <= shown + + "#{total - shown} more fields — use --fields or -o json" + end + + # @api private + def default_columns(records) + cols = union_columns(records) + preferred = %w[id] + ordered = preferred.select { |c| cols.include?(c) } + (cols - preferred) + ordered.first(MAX_TABLE_COLUMNS) + end + + # @api private + def union_columns(records) + records.each_with_object([]) do |record, acc| + record.each_key { |k| acc << k.to_s unless acc.include?(k.to_s) } + end + end + + # @api private + def column_widths(records, columns) + columns.map do |col| + cells = records.map { |r| scalarize(r[col]).length } + widest = [col.to_s.length, *cells].max + [widest, MAX_COL_WIDTH].min + end + end + + # @api private + def numeric_columns(records, columns) + columns.each_with_object({}) do |col, acc| + values = records.map { |r| r[col] }.compact + acc[col] = !values.empty? && values.all?(Numeric) + end + end + + # @api private + def pad(text, width, right: false) + s = truncate(text.to_s, width) + right ? s.rjust(width) : s.ljust(width) + end + + # @api private + def truncate(text, width) + return text if text.length <= width + return text[0, width] if width <= 1 + + "#{text[0, width - 1]}…" + end + + # Render a value as a single-line string suitable for a cell/CSV field. + # Scalars pass through; Hash/Array become compact JSON. + # + # @api private + def scalarize(value, limit: nil) + s = case value + when nil then "" + when String then value + when Hash, Array then JSON.generate(value) + else value.to_s + end + s = s.gsub(/\s+/, " ").strip + limit ? truncate(s, limit) : s + end + + # @api private + def deep_stringify(value) + case value + when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) } + when Array then value.map { |v| deep_stringify(v) } + else value + end + end + end +end diff --git a/lib/smily_cli/mcp/client.rb b/lib/smily_cli/mcp/client.rb new file mode 100644 index 0000000..0fb0534 --- /dev/null +++ b/lib/smily_cli/mcp/client.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" + +module SmilyCli + module Mcp + # A minimal MCP client for BookingSync's Streamable-HTTP transport. + # + # It speaks JSON-RPC 2.0 over `POST `: it performs the `initialize` + # handshake (capturing the `Mcp-Session-Id` and negotiated protocol version), + # then issues `tools/list` / `tools/call` (or any method) within that session. + # Responses are accepted as either `application/json` or a single SSE + # `message` frame, so it interoperates with both response modes the server + # may pick. + # + # The session is established lazily on first use and reused for the life of + # the client (i.e. for a single CLI invocation). + class Client + PROTOCOL_VERSION = "2025-06-18" + SESSION_HEADER = "Mcp-Session-Id" + + # @param token [String] MCP access token (sent as a Bearer token) + # @param url [String] MCP server endpoint, e.g. https://www.bookingsync.com/mcp + # @param verbose [Boolean] log traffic to stderr + def initialize(token:, url:, verbose: false) + @token = token + @uri = URI(url) + @verbose = verbose + @id = 0 + @session_id = nil + @protocol_version = nil + @server_info = nil + end + + # Perform (once) the initialize handshake and return the server's info: + # { "protocolVersion" =>, "capabilities" =>, "serverInfo" => }. + # + # @return [Hash] + def connect + return @server_info if @server_info + + result = rpc("initialize", { + "protocolVersion" => PROTOCOL_VERSION, + "capabilities" => {}, + "clientInfo" => { "name" => "smily-cli", "version" => SmilyCli::VERSION } + }, expect_session: true) + + @protocol_version = result["protocolVersion"] || PROTOCOL_VERSION + # Best-effort per MCP spec; the server accepts it as a 202 notification. + notify("notifications/initialized") + @server_info = result + end + + # @return [Array] tool definitions ({ "name", "description", "inputSchema" }) + def list_tools + connect + collect_paginated("tools/list", "tools") + end + + # Call a tool by name. + # + # @param name [String] + # @param arguments [Hash] + # @return [Hash] the tool result ({ "content" =>, "structuredContent" =>, "isError" => }) + def call_tool(name, arguments = {}) + connect + rpc("tools/call", { "name" => name, "arguments" => arguments }) + end + + # @return [Array] MCP resource descriptors, if the server exposes any + def list_resources + connect + collect_paginated("resources/list", "resources") + end + + # End the session (best-effort; ignores failures). + # + # @return [void] + def close + return unless @session_id + + http_request(Net::HTTP::Delete.new(@uri)) + rescue StandardError + nil + ensure + @session_id = nil + end + + private + + # Issue a JSON-RPC request and return its `result`, raising {McpError} on a + # JSON-RPC error envelope. + def rpc(method, params = {}, expect_session: false) + response = post({ "jsonrpc" => "2.0", "id" => next_id, "method" => method, "params" => params }) + capture_session(response) if expect_session + envelope = parse_body(response) + + if (error = envelope["error"]) + raise McpError.new(error["message"] || "MCP error", code: error["code"]) + end + + envelope["result"] || {} + end + + # Fire a notification (no id, no result expected). + def notify(method, params = {}) + post({ "jsonrpc" => "2.0", "method" => method, "params" => params }) + rescue McpError + nil # notifications never carry a meaningful error for us + end + + # Follow `nextCursor` pagination, concatenating `field` across pages. + def collect_paginated(method, field) + items = [] + cursor = nil + loop do + params = cursor ? { "cursor" => cursor } : {} + result = rpc(method, params) + items.concat(Array(result[field])) + cursor = result["nextCursor"] + break if cursor.nil? || cursor.to_s.empty? + end + items + end + + def post(payload) + request = Net::HTTP::Post.new(@uri) + request["Content-Type"] = "application/json" + request["Accept"] = "application/json, text/event-stream" + request.body = JSON.generate(payload) + http_request(request) + end + + def http_request(request) + request["Authorization"] = "Bearer #{@token}" + request[SESSION_HEADER] = @session_id if @session_id + request["MCP-Protocol-Version"] = @protocol_version if @protocol_version + + http = Net::HTTP.new(@uri.host, @uri.port) + http.use_ssl = @uri.scheme == "https" + # Scrub the wire dump so the bearer token never hits stderr in clear. + http.set_debug_output(Redaction.stream($stderr)) if @verbose + + response = http.request(request) + check_transport!(response) + response + rescue SocketError, Errno::ECONNREFUSED, Timeout::Error => e + raise McpError, "Could not reach the MCP server at #{@uri}: #{e.message}" + end + + def check_transport!(response) + return if response.is_a?(Net::HTTPSuccess) + + # Non-2xx: try to surface a JSON-RPC error message, else a generic one. + detail = begin + JSON.parse(response.body.to_s).dig("error", "message") + rescue StandardError + nil + end + code = response.code.to_i + raise McpError.new(detail || "MCP request failed (HTTP #{response.code})", + code: code == 401 ? -32_000 : nil) + end + + def capture_session(response) + session = response[SESSION_HEADER] || response["mcp-session-id"] + @session_id = session if session && !session.empty? + end + + # Parse a response body that may be plain JSON or a single SSE frame + # (`event: message\ndata: {json}\n\n`). + def parse_body(response) + body = response.body.to_s + return {} if body.empty? || response.code.to_i == 202 + + json = sse?(response, body) ? extract_sse_data(body) : body + JSON.parse(json) + rescue JSON::ParserError => e + raise McpError, "Malformed MCP response: #{e.message}" + end + + def sse?(response, body) + content_type = response["content-type"].to_s + content_type.include?("text/event-stream") || body.lstrip.start_with?("event:", "data:") + end + + def extract_sse_data(body) + body.each_line.filter_map do |line| + line.start_with?("data:") ? line.sub(/\Adata:\s?/, "").chomp : nil + end.join + end + + def next_id + @id += 1 + end + end + end +end diff --git a/lib/smily_cli/oauth.rb b/lib/smily_cli/oauth.rb new file mode 100644 index 0000000..e7dbe86 --- /dev/null +++ b/lib/smily_cli/oauth.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" + +module SmilyCli + # OAuth 2.0 helper for obtaining and refreshing access tokens against the + # BookingSync token endpoint (`/oauth/token`). Supports the flows + # documented in the API reference: client credentials, refresh token, and the + # authorization-code exchange (plus building the authorize URL for the + # interactive step of that flow). + # + # Kept deliberately on Net::HTTP so token acquisition has no dependency on the + # API client's connection stack. + class OAuth + # Structured token response. + Token = Struct.new( + :access_token, :token_type, :expires_in, :refresh_token, :scope, + :created_at, :raw, keyword_init: true + ) do + # @return [Time, nil] absolute expiry, when derivable + def expires_at + return nil unless created_at && expires_in + + Time.at(created_at.to_i + expires_in.to_i) + end + end + + # @param base_url [String] site base URL + # @param verbose [Boolean] + def initialize(base_url: Context::DEFAULT_BASE_URL, verbose: false) + @base_url = base_url + @verbose = verbose + end + + # Client Credentials flow — an app-level token limited to the `public` + # scope, able to read across every account that authorized the app. + # + # @return [Token] + def client_credentials(client_id:, client_secret:, scope: nil) + request_token( + grant_type: "client_credentials", + client_id: client_id, + client_secret: client_secret, + scope: scope + ) + end + + # Refresh an access token using a refresh token. + # + # @return [Token] + def refresh(client_id:, client_secret:, refresh_token:, redirect_uri: nil) + request_token( + grant_type: "refresh_token", + client_id: client_id, + client_secret: client_secret, + refresh_token: refresh_token, + redirect_uri: redirect_uri + ) + end + + # Exchange an authorization code (from the redirect) for tokens. + # + # @return [Token] + def authorization_code(client_id:, client_secret:, code:, redirect_uri:) + request_token( + grant_type: "authorization_code", + client_id: client_id, + client_secret: client_secret, + code: code, + redirect_uri: redirect_uri + ) + end + + # Build the URL a user visits to authorize the app (step 1 of the + # authorization-code / implicit flows). + # + # @param response_type [String] "code" (server-side) or "token" (implicit) + # @return [String] + def authorize_url(client_id:, redirect_uri:, scope: nil, state: nil, account_id: nil, response_type: "code") + params = { + client_id: client_id, + redirect_uri: redirect_uri, + response_type: response_type, + scope: scope, + state: state, + account_id: account_id + }.reject { |_, v| v.nil? || v.to_s.empty? } + + uri = URI.join(@base_url, "/oauth/authorize") + uri.query = URI.encode_www_form(params) + uri.to_s + end + + private + + def request_token(params) + body = params.reject { |_, v| v.nil? || v.to_s.empty? } + uri = URI.join(@base_url, "/oauth/token") + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + # Scrub the wire dump so client_secret / tokens never hit stderr in clear. + http.set_debug_output(Redaction.stream($stderr)) if @verbose + + req = Net::HTTP::Post.new(uri) + req["Accept"] = "application/json" + req.set_form_data(body) + + response = http.request(req) + parse_token(response) + rescue SocketError, Errno::ECONNREFUSED, Timeout::Error => e + raise ApiError.new("Could not reach the OAuth endpoint at #{uri}: #{e.message}") + end + + def parse_token(response) + data = begin + JSON.parse(response.body.to_s) + rescue JSON::ParserError + {} + end + + unless response.is_a?(Net::HTTPSuccess) + message = data["error_description"] || data["error"] || response.message + raise ApiError.new("OAuth request failed (HTTP #{response.code}): #{message}", + status: response.code.to_i, body: data) + end + + Token.new( + access_token: data["access_token"], + token_type: data["token_type"], + expires_in: data["expires_in"], + refresh_token: data["refresh_token"], + scope: data["scope"], + created_at: data["created_at"], + raw: data + ) + end + end +end diff --git a/lib/smily_cli/query_options.rb b/lib/smily_cli/query_options.rb new file mode 100644 index 0000000..6b3e83a --- /dev/null +++ b/lib/smily_cli/query_options.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module SmilyCli + # Turns user-facing flags (`--filter status=booked`, `--query per_page=100`, + # `--fields id,name`, `--include availability`) into the flat query hash the + # API v3 expects. v3 filtering is plain query parameters, so `--filter` and + # `--query` share one mechanism; `--filter` simply reads as intent. + module QueryOptions + # Comparison operators the API v3 filter DSL accepts (mirrors the operators + # reported per attribute by the `resource_schema` / `api_v3_resource_schema` + # tool). Used to recognize the `attribute[op]=value` filter syntax. + OPERATORS = %w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze + + module_function + + # Parse `key=value` strings into a hash. A repeated key accumulates into an + # array (so `--filter status=booked --filter status=tentative` sends both). + # + # @param pairs [Array] + # @return [Hash{String=>Object}] + # @raise [UsageError] when an entry has no `=` + def parse_pairs(pairs) + Array(pairs).each_with_object({}) do |pair, acc| + key, value = split_pair(pair) + acc[key] = if acc.key?(key) + Array(acc[key]) << value + else + value + end + end + end + + # Parse `key=value` / `key[op]=value` filter pairs into the *structured* + # filter object the API v3 list tool accepts (via MCP `api_v3_list`): + # + # * a bare (coerced) value for plain equality, + # * an `{ "op", "value" }` object for a single operator, and + # * an array of such objects when one attribute carries more than one + # condition (AND) — e.g. a numeric or date range. + # + # Examples: + # ["currency=EUR"] => { "currency" => "EUR" } + # ["adults[gteq]=2"] => { "adults" => { "op" => "gteq", "value" => 2 } } + # ["final_price[gteq]=600", + # "final_price[lt]=800"] => { "final_price" => + # [ { "op" => "gteq", "value" => 600 }, + # { "op" => "lt", "value" => 800 } ] } + # ["reference[in]=A,B"] => { "reference" => { "op" => "in", "value" => %w[A B] } } + # + # Values are coerced (integer/float/boolean) so numeric attributes compare + # as numbers rather than strings; ISO 8601 timestamps stay strings. + # + # @param pairs [Array] + # @return [Hash{String=>Object}] + # @raise [UsageError] on a missing `=` or an unknown operator + def parse_filter(pairs) + Array(pairs).each_with_object({}) do |pair, acc| + key, value = split_pair(pair) + attribute, operator = parse_operator(key) + merge_condition(acc, attribute, condition_for(operator, value)) + end + end + + # Assemble the final query hash for a request from the standard flags. + # + # @param fields [Array, nil] sparse fieldset + # @param include [Array, String, nil] associations to sideload + # @param filter [Array] `key=value` filter pairs + # @param query [Array] `key=value` passthrough params + # @return [Hash{String=>Object}] + def build(fields: nil, include: nil, filter: [], query: []) + result = {} + result.merge!(parse_pairs(query)) + result.merge!(parse_pairs(filter)) + if (inc = normalize_list(include)) + result["include"] = inc.join(",") + end + result["fields"] = fields.join(",") if fields && !fields.empty? + result + end + + # @api private + def split_pair(pair) + str = pair.to_s + key, sep, value = str.partition("=") + raise UsageError, "Invalid key=value pair #{pair.inspect} (expected e.g. status=booked)" if sep.empty? + + [key.strip, value] + end + + # A single filter condition: an `{ op, value }` object when an operator was + # given, else the bare coerced equality value. + # @api private + def condition_for(operator, value) + return coerce_scalar(value) unless operator + + { "op" => operator, "value" => coerce_filter_value(operator, value) } + end + + # Split an `attribute[op]` key into `[attribute, op]`, validating the + # operator; a plain `attribute` returns `[attribute, nil]` (equality). + # @api private + def parse_operator(key) + match = key.match(/\A(?.+?)\[(?[a-z_]+)\]\z/) + return [key, nil] unless match + + operator = match[:op] + unless OPERATORS.include?(operator) + raise UsageError, + "Unknown filter operator #{operator.inspect} in #{key.inspect}. Valid: #{OPERATORS.join(', ')}." + end + [match[:attribute], operator] + end + + # Add one condition to the filter, arraying (AND) when the attribute already + # has one. A bare equality value is promoted to an `{ op: "eq" }` object when + # it must share an array with operator conditions, keeping the array uniform. + # @api private + def merge_condition(filter, attribute, condition) + if filter.key?(attribute) + existing = filter[attribute] + list = existing.is_a?(Array) ? existing : [as_operator(existing)] + filter[attribute] = list << as_operator(condition) + else + filter[attribute] = condition + end + end + + # @api private + def as_operator(condition) + condition.is_a?(Hash) ? condition : { "op" => "eq", "value" => condition } + end + + # `in` takes a comma-separated set; every other operator a single value. + # @api private + def coerce_filter_value(operator, value) + return value.split(",").map { |v| coerce_scalar(v.strip) } if operator == "in" + + coerce_scalar(value) + end + + # Coerce a raw string into the natural JSON scalar so numeric/boolean + # attributes compare as themselves; anything else (incl. ISO 8601) stays a + # string. + # @api private + def coerce_scalar(value) + case value + when /\A-?\d+\z/ then value.to_i + when /\A-?\d+\.\d+\z/ then Float(value) + when "true" then true + when "false" then false + else value + end + end + + # @api private + def normalize_list(value) + return nil if value.nil? + + list = Array(value).flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?) + list.empty? ? nil : list + end + end +end diff --git a/lib/smily_cli/redaction.rb b/lib/smily_cli/redaction.rb new file mode 100644 index 0000000..4a51013 --- /dev/null +++ b/lib/smily_cli/redaction.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module SmilyCli + # Scrubs secrets out of any text before it is written to a log or debug + # stream. `--verbose` wires the HTTP stacks' debug output through here so that + # access tokens, client secrets and refresh tokens never reach stderr in the + # clear (verbose output routinely ends up pasted into tickets/chat). + module Redaction + PLACEHOLDER = "[REDACTED]" + + # Each pattern captures a leading label ($1) that is kept, and a secret + # value ($2) that is replaced. + PATTERNS = [ + /(authorization:\s*bearer\s+)(\S+)/i, + /(bearer\s+)([A-Za-z0-9._-]{6,})/i, + /(access[_-]?token["'=:\s]+)([^&"'\s]+)/i, + /(refresh[_-]?token["'=:\s]+)([^&"'\s]+)/i, + /(client[_-]?secret["'=:\s]+)([^&"'\s]+)/i, + /(mcp[_-]?token["'=:\s]+)([^&"'\s]+)/i + ].freeze + + module_function + + # Return `text` with any recognized secret replaced by {PLACEHOLDER}. + # + # @param text [Object] coerced to String + # @return [String] + def scrub(text) + PATTERNS.reduce(text.to_s) do |acc, pattern| + acc.gsub(pattern) { "#{Regexp.last_match(1)}#{PLACEHOLDER}" } + end + end + + # Wrap an IO so everything written to it is scrubbed first. Suitable for + # `Net::HTTP#set_debug_output`, which appends wire data via `<<`/`write`. + # + # @param io [IO] + # @return [ScrubbedIO] + def stream(io) + ScrubbedIO.new(io) + end + + # An IO-ish wrapper that scrubs every chunk before forwarding it. + class ScrubbedIO + # @param io [IO] + def initialize(io) + @io = io + end + + def write(string) + @io.write(Redaction.scrub(string)) + end + + def <<(string) + write(string) + self + end + + def print(*strings) + strings.each { |string| write(string) } + nil + end + + def puts(*strings) + strings.each { |string| @io.puts(Redaction.scrub(string.to_s)) } + nil + end + + def flush + @io.flush if @io.respond_to?(:flush) + end + end + end +end diff --git a/lib/smily_cli/registry.rb b/lib/smily_cli/registry.rb new file mode 100644 index 0000000..bd3ff31 --- /dev/null +++ b/lib/smily_cli/registry.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +module SmilyCli + # The catalog of BookingSync API v3 resources the CLI exposes as first-class + # commands. Sourced from the public API reference + # (https://developers.bookingsync.com/reference). Resources absent here can + # still be reached with `smily api `. + module Registry + # @api private + # Compact DSL row: [command, group, description, opts] + # `path` defaults to the command; `readonly` and `singular` are optional. + ROWS = [ + # --- Bookings --------------------------------------------------------- + ["bookings", "Bookings", "Reservations and unavailable periods."], + ["bookings_fees", "Bookings", "Per-booking fees (cleaning, tax, ...)."], + ["bookings_payments", "Bookings", "Per-booking payment line items."], + ["bookings_taxes", "Bookings", "Per-booking tax line items."], + ["bookings_tags", "Bookings", "Booking tags (account taxonomy)."], + ["booking_comments", "Bookings", "Internal comments on bookings."], + ["booking_discount_codes", "Bookings", "Booking discount codes configured by the account."], + ["booking_discount_code_usages", "Bookings", "Records of discount code applications on bookings."], + + # --- Rentals ---------------------------------------------------------- + ["rentals", "Rentals", "Listings / properties."], + ["rentals_amenities", "Rentals", "Per-rental amenity availability."], + ["rentals_fees", "Rentals", "Per-rental fee configuration."], + ["rentals_taxes", "Rentals", "Per-rental tax configuration."], + ["rentals_tags", "Rentals", "Account tags applied to rentals."], + ["rentals_contents_overrides", "Rentals", "Per-application content overrides."], + ["rental_agreements", "Rentals", "Localized rental agreement contracts."], + ["rental_cancelation_policies", "Rentals", "Cancelation policies on rentals."], + ["rental_cancelation_policy_items", "Rentals", "Refund tiers of cancelation policies."], + ["rental_contacts", "Rentals", "Contacts linked to rentals."], + ["rental_urls", "Rentals", "External URLs for rentals."], + ["rental_link_groups", "Rentals", "Groups of linked rentals."], + ["rental_links", "Rentals", "Rental-to-link-group memberships."], + ["bathrooms", "Rentals", "Bathroom layout per rental."], + ["bedrooms", "Rentals", "Bedroom layout per rental."], + ["living_rooms", "Rentals", "Living room layout per rental."], + ["photos", "Rentals", "Photos attached to rentals."], + ["change_overs", "Rentals", "Change-over (turnover) days."], + ["availabilities", "Rentals", "Availability windows per rental."], + ["special_offers", "Rentals", "Promotional offers on rentals."], + + # --- Rates & pricing -------------------------------------------------- + ["rates", "Rates", "Nightly rates per rental/season."], + ["rates_rules", "Rates", "Pricing rules (min-stay, discounts...)."], + ["rates_tables", "Rates", "Named pricing strategies."], + ["seasons", "Rates", "Pricing seasons."], + ["periods", "Rates", "Date ranges belonging to seasons."], + ["los_records", "Rates", "Length-of-stay precomputed rates.", { readonly: true }], + ["nightly_rate_maps", "Rates", "Nightly rate maps per rental."], + ["mid_term_rate_maps", "Rates", "Mid-term (monthly) rate maps."], + + # --- Clients & people ------------------------------------------------- + ["clients", "People", "Guests / customers at account level."], + ["hosts", "People", "Hosts (greeters, on-site staff)."], + ["contacts", "People", "Address book (cleaners, owners...)."], + ["inquiries", "People", "Pre-booking inquiries."], + ["reviews", "People", "Guest reviews of rentals."], + + # --- Inbox ------------------------------------------------------------ + ["inbox_conversations", "Inbox", "Conversation threads."], + ["inbox_messages", "Inbox", "Messages within conversations."], + ["inbox_attachments", "Inbox", "Attachments on inbox messages."], + ["inbox_participants", "Inbox", "Participants in conversations."], + + # --- Billing & payments ---------------------------------------------- + ["payments", "Billing", "Top-level payment transactions."], + ["payment_gateways", "Billing", "Configured payment gateways."], + ["fees", "Billing", "Account-level fee catalog."], + ["fees_taxes", "Billing", "Joins between fees and taxes."], + ["taxes", "Billing", "Account-level tax catalog."], + ["tax_rules", "Billing", "Rules controlling when taxes apply."], + + # --- Catalog & account ------------------------------------------------ + ["accounts", "Account", "Accounts installed for the app.", { readonly: true }], + ["applications", "Account", "Applications registered on the account.", { readonly: true }], + ["amenities", "Account", "Global amenity catalog.", { readonly: true }], + ["destinations", "Account", "Destinations for the account's rentals."], + ["sources", "Account", "Booking sources / channels."], + ["hosts_reviews", "Account", "Reviews written by hosts about guests.", { path: "host_reviews" }], + ["webhooks", "Account", "Configured outbound webhooks."] + ].freeze + private_constant :ROWS + + # @return [Array] every registered resource, ordered as declared + def self.all + @all ||= ROWS.map do |command, group, description, opts| + opts ||= {} + Resource.new( + command: command, + path: opts[:path] || command, + singular: opts[:singular] || singularize(command), + group: group, + description: description, + readonly: opts.fetch(:readonly, false) + ) + end.freeze + end + + # @return [Array] every command word, sorted + def self.commands + @commands ||= all.map(&:command).sort.freeze + end + + # Look up a resource by its command word. + # + # @param command [String] + # @return [Resource, nil] + def self.find(command) + index[command.to_s] + end + + # Like {find} but raises a helpful {UsageError} when unknown. + # + # @param command [String] + # @return [Resource] + def self.fetch(command) + find(command) || raise(UsageError, unknown_message(command)) + end + + # Registered resources grouped by their {Resource#group}, preserving the + # declaration order of both groups and members. + # + # @return [Hash{String => Array}] + def self.grouped + all.each_with_object({}) do |resource, groups| + (groups[resource.group] ||= []) << resource + end + end + + # @api private + def self.index + @index ||= all.to_h { |r| [r.command, r] }.freeze + end + + # @api private + def self.singularize(command) + # Good-enough English singularization for the labels we actually use. + case command + when /ies\z/ then command.sub(/ies\z/, "y") + when /ses\z/ then command.sub(/es\z/, "") + when /s\z/ then command.sub(/s\z/, "") + else command + end + end + + # @api private + def self.unknown_message(command) + suggestion = commands.min_by { |c| levenshtein(command.to_s, c) } + base = "Unknown resource #{command.inspect}." + hint = suggestion ? " Did you mean #{suggestion.inspect}?" : "" + "#{base}#{hint} Run `smily resources` to list them, or use `smily api`." + end + private_class_method :unknown_message + + # @api private + def self.levenshtein(from, to) + row = (0..to.length).to_a + (1..from.length).each do |i| + prev = row[0] + row[0] = i + (1..to.length).each do |j| + current = row[j] + cost = from[i - 1] == to[j - 1] ? 0 : 1 + row[j] = [row[j] + 1, row[j - 1] + 1, prev + cost].min + prev = current + end + end + row[to.length] + end + private_class_method :levenshtein + end +end diff --git a/lib/smily_cli/resource.rb b/lib/smily_cli/resource.rb new file mode 100644 index 0000000..ceeb738 --- /dev/null +++ b/lib/smily_cli/resource.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module SmilyCli + # A single API v3 resource the CLI knows about by name: the command word the + # user types (`rentals`), the HTTP path it maps to (`rentals`, or + # `booking/discount_codes`), the singular label for help text, and grouping + # metadata used by `smily resources`. + # + # Anything *not* in the {Registry} is still reachable through `smily api`, + # so the registry is about ergonomics and discoverability, not a hard gate. + Resource = Struct.new( + :command, :path, :singular, :group, :description, :readonly, + keyword_init: true + ) do + # The JSON:API top-level key used in request/response bodies for this + # resource. For v3 this is the pluralized resource name, i.e. the last + # path segment (`booking/discount_codes` -> `discount_codes`). + # + # @return [String] + def resource_key + path.split("/").last + end + + # @return [Boolean] whether write commands (create/update/delete) apply + def writable? + !readonly + end + + # Path to the collection endpoint (e.g. "rentals"). + # @return [String] + def collection_path + path + end + + # Path to a member endpoint (e.g. "rentals/42"). + # @param id [String, Integer] + # @return [String] + def member_path(id) + "#{path}/#{id}" + end + end +end diff --git a/lib/smily_cli/resource_command.rb b/lib/smily_cli/resource_command.rb new file mode 100644 index 0000000..7eaf186 --- /dev/null +++ b/lib/smily_cli/resource_command.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module SmilyCli + # The set of subcommands every registered resource gets: `list`, `get`, and + # (unless the resource is read-only) `create`, `update`, `delete`. One bound + # subclass is generated per {Resource} via {.bind}; the bound class carries + # the resource so the shared command bodies know which path/key to use. + class ResourceCommand < BaseCommand + CLIOptions.connection(self) + CLIOptions.output(self) + + # Generated subclasses live here so they have real constant names (Thor + # derives a command namespace from the class name). + module Bound; end + + class << self + # @return [Resource, nil] the resource this class is bound to + attr_reader :resource + + # Build a Thor subcommand class bound to `resource`. + # + # @param resource [Resource] + # @return [Class] + def bind(resource) + klass = Class.new(self) + klass.instance_variable_set(:@resource, resource) + # Make Thor render help/usage as `smily ...` rather than + # deriving a namespace from the generated constant name. + klass.namespace(resource.command) + klass.remove_command("create", "update", "delete") if resource.readonly + klass.remove_command("tree") + Bound.const_set("#{camelize(resource.command)}Command", klass) + klass + end + + # @api private + def camelize(command) + command.split(%r{[_/]}).map(&:capitalize).join + end + end + + desc "list", "List records (supports --filter, --fields, pagination)" + long_desc <<~DESC + List records for this resource. + + Filtering uses plain query parameters, e.g.: + smily bookings list --filter status=booked from=2026-01-01 + smily rentals list --fields id name --limit 10 + smily bookings list --all -o ndjson + + Use --all to fetch every page, or --limit N to stop after N records. + DESC + method_option :filter, type: :array, banner: "k=v", desc: "Filter by query params (key=value)" + method_option :query, type: :array, banner: "k=v", desc: "Extra raw query params (key=value)" + method_option :include, type: :array, banner: "assoc", desc: "Sideload associations" + method_option :limit, type: :numeric, aliases: "-n", desc: "Stop after N records (follows pages)" + method_option :page, type: :numeric, desc: "Fetch a specific page" + method_option :per_page, type: :numeric, desc: "Page size" + method_option :all, type: :boolean, desc: "Fetch every page" + def list + query = QueryOptions.build( + fields: context.fields, + include: options["include"], + filter: options["filter"] || [], + query: options["query"] || [] + ) + result = client.list( + resource.collection_path, + query: query, + limit: options["limit"], + page: options["page"], + per_page: options["per_page"], + all: options["all"] + ) + render_result(result) + end + + desc "get ID", "Get a single record by ID" + method_option :include, type: :array, banner: "assoc", desc: "Sideload associations" + def get(id) + query = QueryOptions.build(fields: context.fields, include: options["include"]) + render_result(client.get(resource.collection_path, id, query: query)) + end + + desc "create", "Create a record from --data JSON" + long_desc <<~DESC + Create a record. Provide the attributes as a JSON object with --data; + the resource envelope is added for you: + + smily rentals create --data '{"name":"Villa"}' + smily clients create --data @client.json + + For nested creates, point --path at the parent collection (the resource + envelope key is unchanged): + + smily bookings create --path rentals/42/bookings --data '{"start_at":"..."}' + DESC + method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-", + desc: "Attributes as JSON, @file, or - for stdin" + method_option :path, type: :string, banner: "PATH", + desc: "Override the endpoint path (for nested creates)" + def create + path = options["path"] || resource.collection_path + render_result(client.create(path, resource.resource_key, require_data)) + end + + desc "update ID", "Update a record from --data JSON" + method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-", + desc: "Attributes as JSON, @file, or - for stdin" + def update(id) + render_result(client.update(resource.collection_path, resource.resource_key, id, require_data)) + end + + desc "delete ID", "Delete (or cancel) a record by ID" + method_option :yes, type: :boolean, aliases: "-y", desc: "Skip the confirmation prompt" + method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-", + desc: "Optional JSON body (e.g. cancelation_reason)" + def delete(id) + confirm!("Delete #{resource.singular} #{id}?") + body = options["data"] ? wrap_delete_body(parse_data(options["data"])) : nil + client.delete(resource.collection_path, id, body: body) + success("Deleted #{resource.singular} #{id}.") + end + + no_commands do + # @return [Resource] the resource bound to this command class + def resource + self.class.resource + end + + # Wrap a delete/cancel body in the resource envelope when needed. + # @api private + def wrap_delete_body(attrs) + return attrs if attrs.is_a?(Hash) && attrs.key?(resource.resource_key) + + { resource.resource_key => [attrs] } + end + end + end +end diff --git a/lib/smily_cli/result.rb b/lib/smily_cli/result.rb new file mode 100644 index 0000000..32c2956 --- /dev/null +++ b/lib/smily_cli/result.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "json" + +module SmilyCli + # A normalized view over one or more API v3 responses. It pulls the record + # array out of the JSON:API envelope (the single top-level key that isn't + # `links`/`linked`/`meta`), while keeping the raw envelope, `meta`, sideloaded + # `linked` records, pagination relations and rate-limit headers available for + # formatters and verbose output. + class Result + SPECIAL_KEYS = %w[links linked meta].freeze + + # @return [Array] the resource records (always an array) + attr_reader :records + # @return [Hash, nil] the raw parsed envelope of the last response + attr_reader :envelope + # @return [Hash, nil] JSON:API `meta` + attr_reader :meta + # @return [Hash, nil] sideloaded associations (`linked`) + attr_reader :linked + # @return [Integer, nil] HTTP status of the last response + attr_reader :status + # @return [Hash] response headers of the last response + attr_reader :headers + # @return [String, nil] resolved JSON:API key the records live under + attr_reader :key + + # @param records [Array] + # @param single [Boolean] whether this represents one member (get/create) + # @param envelope [Hash, nil] + # @param key [String, nil] + # @param status [Integer, nil] + # @param headers [Hash] + def initialize(records:, single: false, envelope: nil, key: nil, status: nil, headers: {}) + @records = records + @single = single + @envelope = envelope + @key = key + @meta = envelope.is_a?(Hash) ? envelope["meta"] : nil + @linked = envelope.is_a?(Hash) ? envelope["linked"] : nil + @status = status + @headers = headers || {} + end + + # Build a Result from a single {BookingSync::API::Response}. + # + # @param response [BookingSync::API::Response, nil] nil for 204 No Content + # @param single [Boolean] + # @return [Result] + def self.from_response(response, single: false) + return empty(single: single) if response.nil? + + body = parse(response.body) + key = resource_key(body) + records = extract_records(body, key) + new( + records: records, + single: single, + envelope: body, + key: key, + status: response.status, + headers: response.headers + ) + end + + # An empty result (e.g. a 204 delete). `records` is empty. + # + # @return [Result] + def self.empty(single: false, status: nil, headers: {}) + new(records: [], single: single, envelope: nil, key: nil, status: status, headers: headers) + end + + # Build a Result from an already-parsed JSON:API envelope (e.g. the payload + # an MCP tool returns), rather than an HTTP response. + # + # @param body [Hash] + # @param single [Boolean] + # @return [Result] + def self.from_body(body, single: false) + key = resource_key(body) + new(records: extract_records(body, key), single: single, envelope: body, key: key) + end + + # @return [Boolean] whether this result represents a single member + def single? + @single + end + + # @return [Hash, nil] the sole record, when {#single?} + def record + records.first + end + + # @return [Boolean] + def empty? + records.empty? + end + + # @return [Integer] + def count + records.length + end + + # Remaining requests in the current rate-limit window, if reported. + # + # @return [Integer, nil] + def rate_limit_remaining + value = headers["X-RateLimit-Remaining"] || headers["x-ratelimit-remaining"] + value&.to_i + end + + # Total number of pages reported by the API, if any. + # + # @return [Integer, nil] + def total_pages + value = headers["X-Total-Pages"] || headers["x-total-pages"] + value&.to_i + end + + # @api private + def self.parse(raw) + return {} if raw.nil? || raw.to_s.empty? + + JSON.parse(raw) + rescue JSON::ParserError + {} + end + + # @api private + def self.resource_key(body) + return nil unless body.is_a?(Hash) + + (body.keys.map(&:to_s) - SPECIAL_KEYS).first + end + + # @api private + def self.extract_records(body, key) + return [] unless body.is_a?(Hash) && key + + # v3 always wraps records in an array, but tolerate a bare object too. + case (value = body[key]) + when Array then value + when nil then [] + else [value] + end + end + end +end diff --git a/sig/smily_cli.rbs b/sig/smily_cli.rbs index e9ad05b..9ab0aca 100644 --- a/sig/smily_cli.rbs +++ b/sig/smily_cli.rbs @@ -1,4 +1,21 @@ module SmilyCli VERSION: String - # See the writing guide of rbs: https://github.com/ruby/rbs#guides + + def self.cli: () -> untyped + + class Error < StandardError + def exit_status: () -> Integer + end + + class ConfigurationError < Error + end + + class UsageError < Error + end + + class ApiError < Error + attr_reader status: Integer? + attr_reader body: untyped + attr_reader headers: Hash[String, untyped] + end end diff --git a/smily_cli.gemspec b/smily_cli.gemspec index a7c5996..c49baac 100644 --- a/smily_cli.gemspec +++ b/smily_cli.gemspec @@ -8,21 +8,21 @@ Gem::Specification.new do |spec| spec.authors = ["Karol Galanciak"] spec.email = ["karol.galanciak@gmail.com"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "Command-line client for the BookingSync (Smily) API v3" + spec.description = <<~DESC + smily is an ergonomic, comprehensive command-line client for the BookingSync + (Smily) API v3. It offers per-resource commands (list/get/create/update/delete) + for the documented endpoints, a raw `api` escape hatch for anything else, + OAuth token helpers, config profiles, pagination, and table/json/yaml/csv/ndjson + output. Built on the official bookingsync-api client. + DESC + spec.homepage = "https://github.com/BookingSync/smily_cli" spec.license = "MIT" spec.required_ruby_version = ">= 3.2.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." - - # Uncomment the line below to require MFA for gem pushes. - # This helps protect your gem from supply chain attacks by ensuring - # no one can publish a new version without multi-factor authentication. - # See: https://guides.rubygems.org/mfa-requirement-opt-in/ - # spec.metadata["rubygems_mfa_required"] = "true" + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -37,9 +37,16 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" + # Runtime dependencies: the CLI framework and the official API client that + # provides the HTTP/JSON:API/OAuth transport layer. + spec.add_dependency "bookingsync-api", ">= 1.0" + spec.add_dependency "thor", ">= 1.2", "< 2.0" + # Autoloading for the library (replaces hand-maintained require_relative). + spec.add_dependency "zeitwerk", ">= 2.6" - # For more information and examples about making a new gem, check out our - # guide at: https://guides.rubygems.org/make-your-own-gem/ + # Formerly-default gems that are only bundled from Ruby 3.4 onward. `csv` is + # used directly for CSV output; `base64` is required transitively by + # bookingsync-api at load time. + spec.add_dependency "base64" + spec.add_dependency "csv" end diff --git a/spec/acceptance/cli_acceptance_spec.rb b/spec/acceptance/cli_acceptance_spec.rb new file mode 100644 index 0000000..1b577e8 --- /dev/null +++ b/spec/acceptance/cli_acceptance_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# End-to-end acceptance tests: they drive the real `smily` argv parser through +# the whole stack (Context -> Client/Mcp::Client -> transport -> Result -> +# Formatters) and replay pre-recorded VCR cassettes for the HTTP layer. Unlike +# the unit specs' targeted WebMock stubs, these exercise a full command as a +# user would invoke it, against realistic API v3 / MCP payloads. +RSpec.describe "smily (acceptance)" do + describe "REST" do + it "lists rentals and prints them as JSON" do + VCR.use_cassette("rest_rentals_list") do + result = run_cli("rentals", "list", "--limit", "2", "--token", "TESTTOKEN", "-o", "json") + + expect(result[:status]).to eq(0) + records = JSON.parse(result[:stdout]) + expect(records.map { |r| r["name"] }).to eq(["Villa Sunset", "City Loft"]) + end + end + + it "follows Link-header pagination end-to-end with --all" do + VCR.use_cassette("rest_rentals_paginate") do + result = run_cli("rentals", "list", "--all", "--token", "TESTTOKEN", "-o", "ndjson") + + expect(result[:status]).to eq(0) + ids = result[:stdout].each_line.map { |line| JSON.parse(line)["id"] } + expect(ids).to eq([1, 2]) + end + end + + it "maps a 404 to a clean error and exit code 4" do + VCR.use_cassette("rest_rental_not_found") do + result = run_cli("rentals", "get", "999999", "--token", "TESTTOKEN") + + expect(result[:status]).to eq(4) + expect(result[:stderr]).to match(/Not Found/) + end + end + end + + describe "MCP" do + it "runs `mcp list` with an operator+range filter over the full JSON-RPC handshake" do + VCR.use_cassette("mcp_list_filtered") do + result = run_cli( + "mcp", "list", "bookings", + "--filter", "final_price[gteq]=600", "status=booked", + "--mcp-token", "MCPTOKEN", "-o", "json" + ) + + expect(result[:status]).to eq(0) + bookings = JSON.parse(result[:stdout]) + expect(bookings.first).to include("id" => 101, "reference" => "BKG-101") + end + end + end +end diff --git a/spec/cassettes/mcp_list_filtered.yml b/spec/cassettes/mcp_list_filtered.yml new file mode 100644 index 0000000..6ad4c87 --- /dev/null +++ b/spec/cassettes/mcp_list_filtered.yml @@ -0,0 +1,93 @@ +--- +http_interactions: +- request: + method: post + uri: https://www.bookingsync.com/mcp + body: + encoding: UTF-8 + string: '{"jsonrpc":"2.0","id":1,"method":"initialize"}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json + Mcp-Session-Id: + - sess-abc-123 + body: + encoding: UTF-8 + string: '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"bookingsync-mcp","version":"1.0.0"}}}' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:00 GMT +- request: + method: post + uri: https://www.bookingsync.com/mcp + body: + encoding: UTF-8 + string: '{"jsonrpc":"2.0","method":"notifications/initialized"}' + headers: + Content-Type: + - application/json + response: + status: + code: 202 + message: Accepted + headers: + Content-Type: + - application/json + body: + encoding: UTF-8 + string: '' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:00 GMT +- request: + method: post + uri: https://www.bookingsync.com/mcp + body: + encoding: UTF-8 + string: '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + headers: + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json + body: + encoding: UTF-8 + string: '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"api_v3_list","description":"List + records"},{"name":"api_v3_get","description":"Fetch one record"},{"name":"api_v3_resource_schema","description":"Describe + a resource"},{"name":"api_v3_resources","description":"List resources"}]}}' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:00 GMT +- request: + method: post + uri: https://www.bookingsync.com/mcp + body: + encoding: UTF-8 + string: '{"jsonrpc":"2.0","id":3,"method":"tools/call"}' + headers: + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json + body: + encoding: UTF-8 + string: '{"jsonrpc":"2.0","id":3,"result":{"structuredContent":{"bookings":[{"id":101,"reference":"BKG-101","final_price":"650.0","status":"booked"}]},"isError":false}}' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:00 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/cassettes/rest_rental_not_found.yml b/spec/cassettes/rest_rental_not_found.yml new file mode 100644 index 0000000..22d8c9d --- /dev/null +++ b/spec/cassettes/rest_rental_not_found.yml @@ -0,0 +1,25 @@ +--- +http_interactions: +- request: + method: get + uri: https://www.bookingsync.com/api/v3/rentals/999999 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + response: + status: + code: 404 + message: Not Found + headers: + Content-Type: + - application/vnd.api+json + body: + encoding: UTF-8 + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Rental not + found"}]}' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:00 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/cassettes/rest_rentals_list.yml b/spec/cassettes/rest_rentals_list.yml new file mode 100644 index 0000000..b7e6b5f --- /dev/null +++ b/spec/cassettes/rest_rentals_list.yml @@ -0,0 +1,31 @@ +--- +http_interactions: +- request: + method: get + uri: https://www.bookingsync.com/api/v3/rentals?per_page=2 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + Accept: + - application/vnd.api+json + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/vnd.api+json + X-Total-Pages: + - '1' + X-RateLimit-Remaining: + - '4998' + body: + encoding: UTF-8 + string: '{"rentals":[{"id":1,"name":"Villa Sunset","sleeps":6,"bedrooms_count":3},{"id":2,"name":"City + Loft","sleeps":2,"bedrooms_count":1}],"meta":{"count":2}}' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:00 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/cassettes/rest_rentals_paginate.yml b/spec/cassettes/rest_rentals_paginate.yml new file mode 100644 index 0000000..d5ee2fa --- /dev/null +++ b/spec/cassettes/rest_rentals_paginate.yml @@ -0,0 +1,47 @@ +--- +http_interactions: +- request: + method: get + uri: https://www.bookingsync.com/api/v3/rentals + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/vnd.api+json + Link: + - ; rel="next" + body: + encoding: UTF-8 + string: '{"rentals":[{"id":1,"name":"Villa Sunset"}]}' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:00 GMT +- request: + method: get + uri: https://www.bookingsync.com/api/v3/rentals?page=2 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/vnd.api+json + body: + encoding: UTF-8 + string: '{"rentals":[{"id":2,"name":"City Loft"}]}' + http_version: '1.1' + recorded_at: Wed, 08 Jul 2026 09:00:01 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/smily_cli/cli_spec.rb b/spec/smily_cli/cli_spec.rb new file mode 100644 index 0000000..e3cba9e --- /dev/null +++ b/spec/smily_cli/cli_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::CLI do + let(:api) { "https://www.bookingsync.com/api/v3" } + let(:json_headers) { { "Content-Type" => "application/vnd.api+json" } } + + describe ".normalize_argv (global flags before the subcommand)" do + it "moves leading value and boolean flags past the subcommand" do + expect(described_class.normalize_argv(%w[-o json rentals list])) + .to eq(%w[rentals list -o json]) + expect(described_class.normalize_argv(%w[--token X --verbose whoami])) + .to eq(%w[whoami --token X --verbose]) + expect(described_class.normalize_argv(%w[--base-url=https://x.test rentals get 1])) + .to eq(%w[rentals get 1 --base-url=https://x.test]) + end + + it "leaves args without leading flags untouched" do + expect(described_class.normalize_argv(%w[rentals list --token X])).to eq(%w[rentals list --token X]) + expect(described_class.normalize_argv(%w[version])).to eq(%w[version]) + end + + it "raises on a forgotten value flag whose next token is a command word" do + # `--token` is missing its value; `rentals` is a command word, so this is + # a forgotten value, not `--token=rentals`. Fail clearly instead of + # swallowing the subcommand. + expect { described_class.normalize_argv(%w[--token rentals list]) } + .to raise_error(SmilyCli::UsageError, /Missing value for --token/) + end + + it "still hoists a value flag whose value is not a command word" do + expect(described_class.normalize_argv(%w[--profile staging rentals list])) + .to eq(%w[rentals list --profile staging]) + end + + it "reports a clear missing-value error end-to-end (no request fired)" do + result = run_cli("--token", "rentals", "list") + expect(result[:status]).to eq(2) + expect(result[:stderr]).to match(/Missing value for --token/) + end + + it "works end-to-end with a global flag before the subcommand" do + stub_request(:get, "#{api}/rentals").to_return( + status: 200, headers: json_headers, body: JSON.generate("rentals" => [{ "id" => 1 }]) + ) + result = run_cli("--token", "T", "-o", "json", "rentals", "list") + expect(result[:status]).to eq(0) + expect(JSON.parse(result[:stdout])).to eq([{ "id" => 1 }]) + end + end + + describe "resource list" do + it "prints records as JSON" do + stub_request(:get, "#{api}/rentals").to_return( + status: 200, headers: json_headers, + body: JSON.generate("rentals" => [{ "id" => 1, "name" => "Villa" }]) + ) + + result = run_cli("rentals", "list", "--token", "T", "-o", "json") + expect(result[:status]).to eq(0) + expect(JSON.parse(result[:stdout])).to eq([{ "id" => 1, "name" => "Villa" }]) + end + + it "passes filters and fields through to the request" do + stub = stub_request(:get, "#{api}/bookings") + .with(query: { "status" => "booked", "fields" => "id,start_at" }) + .to_return(status: 200, headers: json_headers, body: JSON.generate("bookings" => [])) + + run_cli("bookings", "list", "--token", "T", "--filter", "status=booked", "--fields", "id", "start_at") + expect(stub).to have_been_requested + end + end + + describe "account scoping (client-credentials tokens)" do + it "sends --account-id as a query param on requests" do + stub = stub_request(:get, "#{api}/rentals") + .with(query: { "account_id" => "55" }) + .to_return(status: 200, headers: json_headers, body: JSON.generate("rentals" => [])) + + run_cli("rentals", "list", "--token", "T", "--account-id", "55") + expect(stub).to have_been_requested + end + end + + describe "resource get" do + it "prints a single record" do + stub_request(:get, "#{api}/rentals/42").to_return( + status: 200, headers: json_headers, body: JSON.generate("rentals" => [{ "id" => 42 }]) + ) + result = run_cli("rentals", "get", "42", "--token", "T", "-o", "json") + expect(JSON.parse(result[:stdout])).to eq("id" => 42) + end + end + + describe "resource create" do + it "wraps --data and posts it" do + stub = stub_request(:post, "#{api}/clients") do |req| + JSON.parse(req.body) == { "clients" => [{ "fullname" => "Jane" }] } + end.to_return(status: 201, headers: json_headers, body: JSON.generate("clients" => [{ "id" => 5 }])) + + result = run_cli("clients", "create", "--data", '{"fullname":"Jane"}', "--token", "T", "-o", "json") + expect(result[:status]).to eq(0) + expect(stub).to have_been_requested + end + + it "supports --path for nested creates" do + stub = stub_request(:post, "#{api}/rentals/42/bookings") do |req| + JSON.parse(req.body) == { "bookings" => [{ "start_at" => "2026-01-01" }] } + end.to_return(status: 201, headers: json_headers, body: JSON.generate("bookings" => [{ "id" => 9 }])) + + result = run_cli("bookings", "create", "--path", "rentals/42/bookings", + "--data", '{"start_at":"2026-01-01"}', "--token", "T", "-o", "json") + expect(result[:status]).to eq(0) + expect(stub).to have_been_requested + end + + it "reads --data from stdin" do + stub = stub_request(:post, "#{api}/clients") do |req| + JSON.parse(req.body) == { "clients" => [{ "fullname" => "Stdin" }] } + end.to_return(status: 201, headers: json_headers, body: JSON.generate("clients" => [{ "id" => 6 }])) + + original = $stdin + $stdin = StringIO.new('{"fullname":"Stdin"}') + begin + run_cli("clients", "create", "--data", "-", "--token", "T", "-o", "json") + ensure + $stdin = original + end + expect(stub).to have_been_requested + end + end + + describe "read-only resources" do + it "does not expose write subcommands" do + result = run_cli("accounts", "create", "--data", "{}", "--token", "T") + expect(result[:status]).not_to eq(0) + expect(result[:stderr]).to match(/could not find|Unknown|help/i) + end + end + + describe "api escape hatch" do + it "prints the raw envelope for a GET" do + stub_request(:get, "#{api}/rentals").with(query: { "per_page" => "5" }).to_return( + status: 200, headers: json_headers, + body: JSON.generate("rentals" => [{ "id" => 1 }], "meta" => { "count" => 1 }) + ) + result = run_cli("api", "get", "rentals", "--query", "per_page=5", "--token", "T") + body = JSON.parse(result[:stdout]) + expect(body["rentals"]).to eq([{ "id" => 1 }]) + expect(body["meta"]).to eq("count" => 1) + end + + it "rejects an unknown HTTP method" do + result = run_cli("api", "fly", "rentals", "--token", "T") + expect(result[:status]).to eq(2) + expect(result[:stderr]).to match(/Unknown HTTP method/) + end + end + + describe "whoami" do + it "fetches /me" do + stub_request(:get, "#{api}/me").to_return( + status: 200, headers: json_headers, body: JSON.generate("me" => [{ "account" => 123 }]) + ) + result = run_cli("whoami", "--token", "T", "-o", "json") + expect(JSON.parse(result[:stdout])).to eq("account" => 123) + end + end + + describe "resources / version" do + it "lists resources as JSON" do + result = run_cli("resources", "-o", "json") + names = JSON.parse(result[:stdout]).map { |r| r["command"] } + expect(names).to include("rentals", "bookings") + end + + it "prints version info" do + result = run_cli("version") + expect(result[:stdout]).to match(/smily \d+\.\d+\.\d+/) + end + + it "honors -o yaml for resources (not just json)" do + result = run_cli("resources", "-o", "yaml", "rentals") + loaded = YAML.safe_load(result[:stdout]) + expect(loaded.map { |r| r["command"] }).to include("rentals") + end + + it "honors SMILY_OUTPUT for resources" do + ENV["SMILY_OUTPUT"] = "json" + result = run_cli("resources") + expect(JSON.parse(result[:stdout]).map { |r| r["command"] }).to include("rentals") + end + end + + describe "output format resolution for api" do + it "honors SMILY_OUTPUT for the api command" do + stub_request(:get, "#{api}/rentals").to_return( + status: 200, headers: json_headers, body: JSON.generate("rentals" => [{ "id" => 1 }]) + ) + ENV["SMILY_OUTPUT"] = "yaml" + result = run_cli("api", "get", "rentals", "--token", "T") + expect(YAML.safe_load(result[:stdout])).to eq([{ "id" => 1 }]) + end + end + + describe "config command" do + it "sets and lists a profile" do + run_cli("config", "set", "token", "abc123", "--profile", "prod") + result = run_cli("config", "list") + expect(result[:stdout]).to include("prod") + expect(result[:stdout]).to include("****") + expect(result[:stdout]).not_to include("abc123") + end + end + + describe "missing token" do + it "fails cleanly with a configuration error" do + result = run_cli("rentals", "list") + expect(result[:status]).to eq(1) + expect(result[:stderr]).to match(/No API token configured/) + end + end +end diff --git a/spec/smily_cli/client_spec.rb b/spec/smily_cli/client_spec.rb new file mode 100644 index 0000000..9f760d3 --- /dev/null +++ b/spec/smily_cli/client_spec.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Client do + subject(:client) { described_class.new(token: "TOKEN", base_url: "https://www.bookingsync.com") } + + let(:api) { "https://www.bookingsync.com/api/v3" } + let(:json_headers) { { "Content-Type" => "application/vnd.api+json" } } + + def body_for(hash) + { status: 200, headers: json_headers, body: JSON.generate(hash) } + end + + describe "#list" do + it "fetches and parses a collection with the bearer token" do + stub_request(:get, "#{api}/rentals") + .with(headers: { "Authorization" => "Bearer TOKEN" }) + .to_return(body_for("rentals" => [{ "id" => 1 }, { "id" => 2 }])) + + result = client.list("rentals") + expect(result.records).to eq([{ "id" => 1 }, { "id" => 2 }]) + expect(result).not_to be_single + end + + it "sends fields and filter params as query string" do + stub = stub_request(:get, "#{api}/rentals") + .with(query: { "fields" => "id,name", "status" => "published" }) + .to_return(body_for("rentals" => [])) + + client.list("rentals", query: { "fields" => "id,name", "status" => "published" }) + expect(stub).to have_been_requested + end + + it "sends per_page and page when provided" do + stub = stub_request(:get, "#{api}/rentals") + .with(query: { "per_page" => "50", "page" => "2" }) + .to_return(body_for("rentals" => [])) + + client.list("rentals", per_page: 50, page: 2) + expect(stub).to have_been_requested + end + + it "follows Link-header pagination when all: true" do + stub_request(:get, "#{api}/rentals") + .with(query: {}) + .to_return( + status: 200, + headers: json_headers.merge("Link" => %(<#{api}/rentals?page=2>; rel="next")), + body: JSON.generate("rentals" => [{ "id" => 1 }]) + ) + stub_request(:get, "#{api}/rentals").with(query: { "page" => "2" }) + .to_return(body_for("rentals" => [{ "id" => 2 }])) + + result = client.list("rentals", all: true) + expect(result.records.map { |r| r["id"] }).to eq([1, 2]) + end + + it "stops at max_pages and warns, even if the server keeps advertising next" do + capped = described_class.new(token: "TOKEN", base_url: "https://www.bookingsync.com", max_pages: 2) + # Every page advertises a next link (a server that never terminates). + stub_request(:get, "#{api}/rentals").with(query: {}).to_return( + status: 200, + headers: json_headers.merge("Link" => %(<#{api}/rentals?page=2>; rel="next")), + body: JSON.generate("rentals" => [{ "id" => 1 }]) + ) + stub_request(:get, "#{api}/rentals").with(query: { "page" => "2" }).to_return( + status: 200, + headers: json_headers.merge("Link" => %(<#{api}/rentals?page=3>; rel="next")), + body: JSON.generate("rentals" => [{ "id" => 2 }]) + ) + page3 = stub_request(:get, "#{api}/rentals").with(query: { "page" => "3" }) + + result = nil + expect { result = capped.list("rentals", all: true) } + .to output(/stopped after 2 pages/).to_stderr + expect(result.records.map { |r| r["id"] }).to eq([1, 2]) + expect(page3).not_to have_been_requested + end + + it "does not warn about truncation when the last page coincides with max_pages" do + capped = described_class.new(token: "TOKEN", base_url: "https://www.bookingsync.com", max_pages: 2) + stub_request(:get, "#{api}/rentals").with(query: {}).to_return( + status: 200, + headers: json_headers.merge("Link" => %(<#{api}/rentals?page=2>; rel="next")), + body: JSON.generate("rentals" => [{ "id" => 1 }]) + ) + # Page 2 is genuinely the last page (no `next`), reached within the cap — + # so the collection is complete and no truncation warning is warranted. + stub_request(:get, "#{api}/rentals").with(query: { "page" => "2" }) + .to_return(body_for("rentals" => [{ "id" => 2 }])) + + result = nil + expect { result = capped.list("rentals", all: true) }.not_to output(/truncated/).to_stderr + expect(result.records.map { |r| r["id"] }).to eq([1, 2]) + end + + it "stops at the limit without fetching further pages" do + # A limit with no explicit page size asks the API for exactly `limit` rows. + stub_request(:get, "#{api}/rentals") + .with(query: { "per_page" => "1" }) + .to_return( + status: 200, + headers: json_headers.merge("Link" => %(<#{api}/rentals?page=2>; rel="next")), + body: JSON.generate("rentals" => [{ "id" => 1 }, { "id" => 2 }]) + ) + page2 = stub_request(:get, "#{api}/rentals").with(query: { "page" => "2" }) + + result = client.list("rentals", limit: 1) + expect(result.records).to eq([{ "id" => 1 }]) + expect(page2).not_to have_been_requested + end + + it "requests per_page = min(limit, 100) when no page size is given" do + small = stub_request(:get, "#{api}/rentals").with(query: { "per_page" => "5" }) + .to_return(body_for("rentals" => [])) + capped = stub_request(:get, "#{api}/rentals").with(query: { "per_page" => "100" }) + .to_return(body_for("rentals" => [])) + + client.list("rentals", limit: 5) + client.list("rentals", limit: 250) + expect(small).to have_been_requested + expect(capped).to have_been_requested + end + end + + describe "#get" do + it "fetches a single member" do + stub_request(:get, "#{api}/rentals/42").to_return(body_for("rentals" => [{ "id" => 42 }])) + result = client.get("rentals", 42) + expect(result).to be_single + expect(result.record).to eq("id" => 42) + end + end + + describe "account scoping" do + subject(:scoped) { described_class.new(token: "TOKEN", base_url: "https://www.bookingsync.com", account_id: 99) } + + it "adds account_id to every request when configured" do + stub = stub_request(:get, "#{api}/rentals").with(query: { "account_id" => "99" }) + .to_return(body_for("rentals" => [])) + scoped.list("rentals") + expect(stub).to have_been_requested + end + + it "does not override an explicit account_id" do + stub = stub_request(:get, "#{api}/rentals").with(query: { "account_id" => "7" }) + .to_return(body_for("rentals" => [])) + scoped.list("rentals", query: { "account_id" => "7" }) + expect(stub).to have_been_requested + end + end + + describe "#create" do + it "wraps attributes in the resource envelope and POSTs them" do + stub = stub_request(:post, "#{api}/rentals") + .with(headers: { "Content-Type" => "application/vnd.api+json" }) do |req| + JSON.parse(req.body) == { "rentals" => [{ "name" => "Villa" }] } + end + .to_return(body_for("rentals" => [{ "id" => 7, "name" => "Villa" }])) + + result = client.create("rentals", "rentals", { "name" => "Villa" }) + expect(result.record).to eq("id" => 7, "name" => "Villa") + expect(stub).to have_been_requested + end + + it "does not double-wrap an already-enveloped body" do + stub = stub_request(:post, "#{api}/rentals") do |req| + JSON.parse(req.body) == { "rentals" => [{ "name" => "X" }] } + end.to_return(body_for("rentals" => [{ "id" => 8 }])) + + client.create("rentals", "rentals", { "rentals" => [{ "name" => "X" }] }) + expect(stub).to have_been_requested + end + end + + describe "#update" do + it "PUTs the enveloped attributes to the member path" do + stub = stub_request(:put, "#{api}/rentals/42") do |req| + JSON.parse(req.body) == { "rentals" => [{ "sleeps" => 3 }] } + end.to_return(body_for("rentals" => [{ "id" => 42, "sleeps" => 3 }])) + + client.update("rentals", "rentals", 42, { "sleeps" => 3 }) + expect(stub).to have_been_requested + end + end + + describe "#delete" do + it "returns an empty result on 204 without raising" do + stub_request(:delete, "#{api}/rentals/42").to_return(status: 204, body: "") + result = client.delete("rentals", 42) + expect(result).to be_empty + end + end + + describe "#request" do + it "normalizes full URLs and /api/v3 prefixes" do + stub_request(:get, "#{api}/rentals").to_return(body_for("rentals" => [])) + expect { client.request(:get, "/api/v3/rentals") }.not_to raise_error + expect { client.request(:get, "https://www.bookingsync.com/api/v3/rentals") }.not_to raise_error + end + end + + describe "429 rate-limit retry" do + it "retries after Retry-After and succeeds when retries remain" do + slept = [] + retrying = described_class.new( + token: "TOKEN", base_url: "https://www.bookingsync.com", + retries: 2, sleeper: ->(seconds) { slept << seconds } + ) + stub_request(:get, "#{api}/rentals").to_return( + { status: 429, headers: json_headers.merge("Retry-After" => "3"), + body: JSON.generate("errors" => [{ "title" => "rate limited" }]) }, + body_for("rentals" => [{ "id" => 1 }]) + ) + + result = retrying.list("rentals") + expect(result.records).to eq([{ "id" => 1 }]) + expect(slept).to eq([3]) + end + + it "raises a 429 ApiError (exit 5) when no retries are configured" do + stub_request(:get, "#{api}/rentals").to_return( + status: 429, headers: json_headers, body: JSON.generate("errors" => [{ "title" => "rate limited" }]) + ) + expect { client.list("rentals") }.to raise_error(SmilyCli::ApiError) { |e| + expect(e.status).to eq(429) + expect(e.exit_status).to eq(5) + } + end + end + + describe "error handling" do + it "raises ApiError with the status and parsed detail" do + stub_request(:get, "#{api}/rentals/999").to_return( + status: 404, headers: json_headers, + body: JSON.generate("errors" => [{ "title" => "Not Found" }]) + ) + expect { client.get("rentals", 999) } + .to raise_error(SmilyCli::ApiError) { |e| + expect(e.status).to eq(404) + expect(e.message).to include("Not Found") + } + end + + it "raises ApiError on 422 validation errors" do + stub_request(:post, "#{api}/rentals").to_return( + status: 422, headers: json_headers, + body: JSON.generate("errors" => { "name" => ["is required"] }) + ) + expect { client.create("rentals", "rentals", {}) } + .to raise_error(SmilyCli::ApiError, /name: is required/) + end + end +end diff --git a/spec/smily_cli/commands/auth_command_spec.rb b/spec/smily_cli/commands/auth_command_spec.rb new file mode 100644 index 0000000..25f5502 --- /dev/null +++ b/spec/smily_cli/commands/auth_command_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Commands::AuthCommand do + let(:token_url) { "https://www.bookingsync.com/oauth/token" } + + def token_body(overrides = {}) + { + status: 200, headers: { "Content-Type" => "application/json" }, + body: JSON.generate({ "access_token" => "tok-abc", "token_type" => "bearer", + "expires_in" => 7200, "scope" => "public" }.merge(overrides)) + } + end + + describe "client-credentials" do + it "fetches and prints an access token" do + stub_request(:post, token_url).with(body: hash_including("grant_type" => "client_credentials")) + .to_return(token_body) + result = run_cli("auth", "client-credentials", "--client-id", "CID", "--client-secret", "SECRET") + expect(result[:status]).to eq(0) + expect(result[:stdout]).to include("tok-abc") + end + + it "saves the token (and refresh token) to the active profile with --save" do + stub_request(:post, token_url).to_return(token_body("refresh_token" => "ref-1")) + run_cli("auth", "client-credentials", "--client-id", "CID", "--client-secret", "SECRET", + "--save", "--profile", "prod") + expect(SmilyCli::Config.load.profile("prod")) + .to include("token" => "tok-abc", "refresh_token" => "ref-1") + end + + it "prints the full token as JSON with -o json" do + stub_request(:post, token_url).to_return(token_body) + result = run_cli("auth", "client-credentials", "--client-id", "CID", "--client-secret", "SECRET", "-o", "json") + expect(JSON.parse(result[:stdout])["access_token"]).to eq("tok-abc") + end + + it "prints only the access token (never the refresh token) when piped without -o json" do + # Captured stdout is non-TTY, whose default format is json; the full + # payload — refresh_token included — must NOT leak unless json was asked + # for explicitly, so token-only scripting stays safe. + stub_request(:post, token_url).to_return(token_body("refresh_token" => "ref-supersecret-999")) + result = run_cli("auth", "client-credentials", "--client-id", "CID", "--client-secret", "SECRET") + expect(result[:stdout].strip).to eq("tok-abc") + expect(result[:stdout]).not_to include("ref-supersecret-999") + end + + it "errors clearly when the client id is missing" do + result = run_cli("auth", "client-credentials", "--client-secret", "SECRET") + expect(result[:status]).to eq(2) + expect(result[:stderr]).to match(/Missing client id/) + end + end + + describe "refresh" do + it "refreshes using a refresh token" do + stub_request(:post, token_url).with(body: hash_including("grant_type" => "refresh_token")) + .to_return(token_body("access_token" => "new-tok")) + result = run_cli("auth", "refresh", "--client-id", "CID", "--client-secret", "SECRET", "--refresh-token", "RT") + expect(result[:stdout]).to include("new-tok") + end + end + + describe "exchange" do + it "exchanges an authorization code for a token" do + stub_request(:post, token_url) + .with(body: hash_including("grant_type" => "authorization_code", "code" => "CODE")) + .to_return(token_body) + result = run_cli("auth", "exchange", "--client-id", "CID", "--client-secret", "SECRET", + "--code", "CODE", "--redirect-uri", "https://app.test/cb") + expect(result[:stdout]).to include("tok-abc") + end + end + + describe "authorize-url" do + it "prints the authorization URL (no network)" do + result = run_cli("auth", "authorize-url", "--client-id", "CID", "--redirect-uri", "https://app.test/cb") + expect(result[:stdout]).to include("https://www.bookingsync.com/oauth/authorize?", "client_id=CID") + end + end + + describe "status" do + it "reports when no token is configured" do + result = run_cli("auth", "status") + expect(result[:status]).to eq(0) + expect(result[:stderr]).to match(/No token configured/) + end + + it "shows a configured token, masked" do + SmilyCli::Config.load.tap { |c| c.set("token", "supersecrettoken") }.save + result = run_cli("auth", "status") + expect(result[:stderr]).to match(/Token/) + expect(result[:stderr]).not_to include("supersecrettoken") + end + end + + describe "token" do + it "prints the resolved token" do + result = run_cli("auth", "token", "--token", "T0KEN") + expect(result[:stdout].strip).to eq("T0KEN") + end + end +end diff --git a/spec/smily_cli/commands/config_command_spec.rb b/spec/smily_cli/commands/config_command_spec.rb new file mode 100644 index 0000000..51e5da2 --- /dev/null +++ b/spec/smily_cli/commands/config_command_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Commands::ConfigCommand do + it "prints the config path" do + result = run_cli("config", "path") + expect(result[:stdout].strip).to eq(ENV.fetch("SMILY_CONFIG")) + end + + it "sets then gets a value on a profile" do + run_cli("config", "set", "base_url", "https://x.test", "--profile", "prod") + result = run_cli("config", "get", "base_url", "--profile", "prod") + expect(result[:stdout].strip).to eq("https://x.test") + end + + it "coerces integer values" do + run_cli("config", "set", "account_id", "42", "--profile", "prod") + expect(SmilyCli::Config.load.profile("prod")["account_id"]).to eq(42) + end + + it "unsets a value" do + run_cli("config", "set", "token", "abc", "--profile", "prod") + run_cli("config", "unset", "token", "--profile", "prod") + expect(SmilyCli::Config.load.profile("prod")).not_to include("token") + end + + it "changes the default profile with use" do + run_cli("config", "set", "token", "abc", "--profile", "staging") + run_cli("config", "use", "staging") + expect(SmilyCli::Config.load.current_profile_name).to eq("staging") + end + + it "removes a profile with --yes" do + run_cli("config", "set", "token", "abc", "--profile", "doomed") + run_cli("config", "remove", "doomed", "--yes") + expect(SmilyCli::Config.load.profile_names).not_to include("doomed") + end + + it "fails cleanly (not a backtrace) on a malformed config" do + File.write(ENV.fetch("SMILY_CONFIG"), "profiles: []\n") + result = run_cli("config", "list") + expect(result[:status]).to eq(1) + expect(result[:stderr]).to match(/`profiles` must be a mapping/) + end + + it "masks secrets in list output" do + run_cli("config", "set", "token", "topsecretvalue", "--profile", "prod") + result = run_cli("config", "list") + expect(result[:stdout]).to include("prod", "****") + expect(result[:stdout]).not_to include("topsecretvalue") + end +end diff --git a/spec/smily_cli/config_spec.rb b/spec/smily_cli/config_spec.rb new file mode 100644 index 0000000..6ad166d --- /dev/null +++ b/spec/smily_cli/config_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Config do + subject(:config) { described_class.new(path) } + + let(:path) { File.join(Dir.mktmpdir, "smily", "config.yml") } + + it "starts empty and reports the default profile" do + expect(config).not_to be_exist + expect(config.current_profile_name).to eq("default") + expect(config.profile_names).to be_empty + end + + it "sets, saves and reloads profile values" do + config.set("token", "abc", profile: "prod") + config.set("base_url", "https://example.test", profile: "prod") + config.save + + reloaded = described_class.new(path) + expect(reloaded.profile("prod")).to eq("token" => "abc", "base_url" => "https://example.test") + expect(reloaded.profile_names).to eq(["prod"]) + end + + it "writes the file with owner-only permissions" do + config.set("token", "secret") + config.save + mode = File.stat(path).mode & 0o777 + expect(mode).to eq(0o600) + end + + it "creates the config directory as owner-only" do + config.set("token", "secret") + config.save + dir_mode = File.stat(File.dirname(path)).mode & 0o777 + expect(dir_mode).to eq(0o700) + end + + it "tracks the current profile across reloads" do + config.set("token", "x", profile: "staging") + config.current_profile_name = "staging" + config.save + expect(described_class.new(path).current_profile_name).to eq("staging") + end + + it "unsets keys and deletes profiles" do + config.set("token", "x", profile: "a") + config.set("token", "y", profile: "b") + config.unset("token", profile: "a") + config.delete_profile("a") + config.save + expect(described_class.new(path).profile_names).to eq(["b"]) + end + + it "raises a clear error on malformed YAML" do + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "\tnot: [valid") + expect { described_class.new(path) }.to raise_error(SmilyCli::ConfigurationError, /not valid YAML/) + end + + it "raises ConfigurationError when profiles is the wrong shape" do + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "profiles: []\n") + expect { described_class.new(path) } + .to raise_error(SmilyCli::ConfigurationError, /`profiles` must be a mapping/) + end + + it "raises ConfigurationError when a profile is not a mapping" do + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "profiles:\n default: just-a-string\n") + expect { described_class.new(path) } + .to raise_error(SmilyCli::ConfigurationError, /profile "default" must be a mapping/) + end + + it "raises ConfigurationError on a value safe_load refuses (e.g. a bare date)" do + # Well-formed YAML, but an unquoted date scalar makes safe_load raise + # Psych::DisallowedClass — which must surface as a clean CLI error, not an + # uncaught backtrace. + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "profiles:\n default:\n base_url: 2026-01-01\n") + expect { described_class.new(path) } + .to raise_error(SmilyCli::ConfigurationError, /unsupported value/) + end + + describe ".default_path" do + it "honors SMILY_CONFIG" do + expect(described_class.default_path).to eq(ENV.fetch("SMILY_CONFIG")) + end + + it "falls back to XDG_CONFIG_HOME" do + ENV.delete("SMILY_CONFIG") + ENV["XDG_CONFIG_HOME"] = "/tmp/xdg" + expect(described_class.default_path).to eq("/tmp/xdg/smily/config.yml") + ensure + ENV.delete("XDG_CONFIG_HOME") + end + end +end diff --git a/spec/smily_cli/context_spec.rb b/spec/smily_cli/context_spec.rb new file mode 100644 index 0000000..876d26f --- /dev/null +++ b/spec/smily_cli/context_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Context do + def context(options = {}) + described_class.from(options) + end + + describe "resolution precedence" do + it "prefers an explicit flag over env and profile" do + ENV["SMILY_TOKEN"] = "from-env" + expect(context("token" => "from-flag").token).to eq("from-flag") + end + + it "falls back to env when no flag is given" do + ENV["SMILY_TOKEN"] = "from-env" + expect(context.token).to eq("from-env") + end + + it "falls back to the profile when no flag or env is set" do + config = SmilyCli::Config.load + config.set("token", "from-profile") + config.save + expect(context.token).to eq("from-profile") + end + + it "treats empty strings as absent" do + ENV["SMILY_TOKEN"] = "env-token" + expect(context("token" => "").token).to eq("env-token") + end + end + + it "raises a ConfigurationError from token! when nothing is configured" do + expect { context.token! }.to raise_error(SmilyCli::ConfigurationError, /No API token/) + end + + it "defaults the base URL and allows overrides" do + expect(context.base_url).to eq("https://www.bookingsync.com") + expect(context("base_url" => "https://x.test").base_url).to eq("https://x.test") + end + + describe "#output_format" do + it "defaults to json when stdout is not a TTY" do + allow($stdout).to receive(:tty?).and_return(false) + expect(context.output_format).to eq("json") + end + + it "honors an explicit format" do + expect(context("output" => "yaml").output_format).to eq("yaml") + end + + it "rejects unknown formats" do + expect { context("output" => "xml").output_format } + .to raise_error(SmilyCli::UsageError, /Unknown output format/) + end + end + + describe "#fields" do + it "splits comma and array forms" do + expect(context("fields" => %w[id name]).fields).to eq(%w[id name]) + expect(context("fields" => ["id,name", "status"]).fields).to eq(%w[id name status]) + expect(context.fields).to be_nil + end + end + + describe "insecure transport warning" do + it "warns when a token would be sent over http" do + expect { context("token" => "T", "base_url" => "http://insecure.test").client } + .to output(/warning: sending credentials over HTTP/).to_stderr + end + + it "stays silent over https" do + expect { context("token" => "T", "base_url" => "https://secure.test").client } + .not_to output.to_stderr + end + end + + it "selects the profile from the --profile flag" do + config = SmilyCli::Config.load + config.set("token", "staging-token", profile: "staging") + config.save + expect(context("profile" => "staging").token).to eq("staging-token") + end +end diff --git a/spec/smily_cli/errors_spec.rb b/spec/smily_cli/errors_spec.rb new file mode 100644 index 0000000..39a9209 --- /dev/null +++ b/spec/smily_cli/errors_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::ApiError do + # Mimic BookingSync::API::Error's interface. + def api_error(status:, body:) + Struct.new(:status, :headers, :body).new(status, {}, body) + end + + it "extracts a detail message from a JSON:API errors array" do + body = JSON.generate("errors" => [{ "title" => "Invalid", "detail" => "name is required" }]) + error = described_class.from_api(api_error(status: 422, body: body)) + expect(error.status).to eq(422) + expect(error.message).to eq("API request failed (HTTP 422): Invalid - name is required") + expect(error.exit_status).to eq(1) + end + + it "handles a field => messages hash" do + body = JSON.generate("errors" => { "name" => ["is required"], "email" => ["is invalid"] }) + error = described_class.from_api(api_error(status: 422, body: body)) + expect(error.message).to include("name: is required", "email: is invalid") + end + + it "surfaces OAuth-style error descriptions" do + body = JSON.generate("error" => "invalid_grant", "error_description" => "bad code") + error = described_class.from_api(api_error(status: 401, body: body)) + expect(error.message).to include("bad code") + expect(error.exit_status).to eq(3) + end + + it "degrades gracefully with a non-JSON body" do + error = described_class.from_api(api_error(status: 500, body: "boom")) + expect(error.message).to eq("API request failed (HTTP 500): boom") + end + + it "maps 404 to its own exit status" do + error = described_class.from_api(api_error(status: 404, body: "{}")) + expect(error.exit_status).to eq(4) + end +end diff --git a/spec/smily_cli/formatters_spec.rb b/spec/smily_cli/formatters_spec.rb new file mode 100644 index 0000000..8ab0569 --- /dev/null +++ b/spec/smily_cli/formatters_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Formatters do + def collection(records) + SmilyCli::Result.new(records: records, single: false) + end + + def member(record) + SmilyCli::Result.new(records: [record], single: true) + end + + let(:records) do + [ + { "id" => 1, "name" => "Villa", "sleeps" => 4 }, + { "id" => 2, "name" => "Cabin", "sleeps" => 2 } + ] + end + + before { SmilyCli::Color.enabled = false } + + describe "json" do + it "renders a collection as a JSON array" do + out = described_class.render(collection(records), format: "json") + expect(JSON.parse(out)).to eq(records) + end + + it "renders a single record as a JSON object" do + out = described_class.render(member(records.first), format: "json") + expect(JSON.parse(out)).to eq(records.first) + end + end + + describe "yaml" do + it "renders records as YAML" do + out = described_class.render(collection(records), format: "yaml") + expect(YAML.safe_load(out)).to eq(records) + end + end + + describe "ndjson" do + it "renders one JSON object per line" do + out = described_class.render(collection(records), format: "ndjson") + lines = out.split("\n") + expect(lines.size).to eq(2) + expect(JSON.parse(lines.first)).to eq(records.first) + end + end + + describe "csv" do + it "renders a header and rows, selecting fields when given" do + out = described_class.render(collection(records), format: "csv", fields: %w[id name]) + expect(out).to eq("id,name\n1,Villa\n2,Cabin\n") + end + + it "serializes nested values as JSON" do + rec = [{ "id" => 1, "name" => { "en" => "Villa" } }] + out = described_class.render(collection(rec), format: "csv", fields: %w[id name]) + row = CSV.parse(out).last + expect(row).to eq(["1", %({"en":"Villa"})]) + end + + it "does not crash on non-hash records" do + out = described_class.render(collection(%w[a b]), format: "csv") + expect(CSV.parse(out)).to eq([["value"], ["a"], ["b"]]) + end + end + + describe "table" do + it "renders an aligned grid with a footer for collections" do + out = described_class.render(collection(records), format: "table") + expect(out).to include("id", "name", "sleeps") + expect(out).to include("Villa", "Cabin") + expect(out).to match(/2 records/) + end + + it "renders a vertical key/value layout for a single record" do + out = described_class.render(member(records.first), format: "table") + expect(out).to match(/^id\s+1$/) + expect(out).to match(/^name\s+Villa$/) + end + + it "reports when no records are found" do + out = described_class.render(collection([]), format: "table") + expect(out).to eq("No records found.") + end + + it "does not crash on non-hash records (renders a value column)" do + out = described_class.render(collection(%w[a b]), format: "table") + expect(out).to include("value", "a", "b") + end + + it "limits columns to the selected fields" do + out = described_class.render(collection(records), format: "table", fields: %w[id]) + expect(out).to include("id") + expect(out).not_to include("Villa") + end + end +end diff --git a/spec/smily_cli/mcp/client_spec.rb b/spec/smily_cli/mcp/client_spec.rb new file mode 100644 index 0000000..0ce6070 --- /dev/null +++ b/spec/smily_cli/mcp/client_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Mcp::Client do + subject(:client) { described_class.new(token: "MCP", url: url) } + + let(:url) { "https://www.bookingsync.com/mcp" } + let(:json_headers) { { "Content-Type" => "application/json" } } + + def rpc_ok(result, session = nil) + headers = json_headers.dup + headers["Mcp-Session-Id"] = session if session + { status: 200, headers: headers, body: JSON.generate("jsonrpc" => "2.0", "id" => 1, "result" => result) } + end + + def stub_method(name) + stub_request(:post, url).with(body: hash_including("method" => name)) + end + + before do + stub_method("initialize").to_return( + rpc_ok({ "protocolVersion" => "2025-06-18", + "capabilities" => { "tools" => {} }, + "serverInfo" => { "name" => "bsa-mcp", "version" => "1.2.0" } }, + "sess-1") + ) + stub_method("notifications/initialized").to_return(status: 202, body: "") + end + + describe "#connect" do + it "performs the initialize handshake and returns server info" do + info = client.connect + expect(info["serverInfo"]).to eq("name" => "bsa-mcp", "version" => "1.2.0") + end + + it "only handshakes once even across multiple calls" do + init = stub_method("initialize") # re-declare to count + .to_return(rpc_ok({ "protocolVersion" => "2025-06-18", "serverInfo" => {} }, "sess-1")) + client.connect + client.connect + expect(init).to have_been_requested.once + end + end + + describe "#list_tools" do + it "returns the tool list and carries the session id" do + tools = stub_method("tools/list") + .with(headers: { "Mcp-Session-Id" => "sess-1" }) + .to_return(rpc_ok("tools" => [{ "name" => "list" }, { "name" => "get" }])) + + expect(client.list_tools.map { |t| t["name"] }).to eq(%w[list get]) + expect(tools).to have_been_requested + end + + it "follows nextCursor pagination" do + stub_request(:post, url) + .with(body: hash_including("method" => "tools/list", "params" => {})) + .to_return(rpc_ok("tools" => [{ "name" => "a" }], "nextCursor" => "c2")) + stub_request(:post, url) + .with(body: hash_including("method" => "tools/list", "params" => { "cursor" => "c2" })) + .to_return(rpc_ok("tools" => [{ "name" => "b" }])) + + expect(client.list_tools.map { |t| t["name"] }).to eq(%w[a b]) + end + end + + describe "#call_tool" do + it "returns the tool result" do + stub_method("tools/call") + .with(body: hash_including("params" => hash_including("name" => "list"))) + .to_return(rpc_ok("content" => [{ "type" => "text", "text" => "{}" }], "isError" => false)) + + result = client.call_tool("list", { "resource" => "rentals" }) + expect(result["content"].first["text"]).to eq("{}") + end + + it "parses an SSE-framed response" do + body = "event: message\ndata: #{JSON.generate('jsonrpc' => '2.0', 'id' => 2, + 'result' => { 'content' => [] })}\n\n" + stub_method("tools/call").to_return( + status: 200, headers: { "Content-Type" => "text/event-stream" }, body: body + ) + expect(client.call_tool("noop")).to eq("content" => []) + end + end + + describe "errors" do + it "raises McpError on a JSON-RPC error envelope" do + stub_method("tools/call").to_return( + status: 200, headers: json_headers, + body: JSON.generate("jsonrpc" => "2.0", "id" => 1, "error" => { "code" => -32_602, "message" => "bad params" }) + ) + expect { client.call_tool("list") }.to raise_error(SmilyCli::McpError, /bad params/) + end + + it "raises an auth McpError on HTTP 401" do + stub_method("tools/call").to_return( + status: 401, headers: json_headers, + body: JSON.generate("error" => { "message" => "Unauthorized: bad token" }) + ) + expect { client.call_tool("list") }.to raise_error(SmilyCli::McpError) { |e| + expect(e.message).to include("Unauthorized") + expect(e.exit_status).to eq(3) + } + end + end +end diff --git a/spec/smily_cli/mcp/command_spec.rb b/spec/smily_cli/mcp/command_spec.rb new file mode 100644 index 0000000..8a68a47 --- /dev/null +++ b/spec/smily_cli/mcp/command_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +RSpec.describe "smily mcp (command layer)" do + let(:url) { "https://www.bookingsync.com/mcp" } + let(:json_headers) { { "Content-Type" => "application/json" } } + + def stub_method(name) + stub_request(:post, url).with(body: hash_including("method" => name)) + end + + def rpc_ok(result, session = nil) + headers = json_headers.dup + headers["Mcp-Session-Id"] = session if session + { status: 200, headers: headers, body: JSON.generate("jsonrpc" => "2.0", "id" => 1, "result" => result) } + end + + before do + stub_method("initialize").to_return( + rpc_ok({ "protocolVersion" => "2025-06-18", "serverInfo" => { "name" => "bsa-mcp" } }, "s1") + ) + stub_method("notifications/initialized").to_return(status: 202, body: "") + end + + it "lists tools as JSON" do + stub_method("tools/list").to_return(rpc_ok("tools" => [ + { "name" => "list", "description" => "List records" }, + { "name" => "get", "description" => "Get a record" } + ])) + result = run_cli("mcp", "tools", "--mcp-token", "T", "-o", "json") + names = JSON.parse(result[:stdout]).map { |t| t["name"] } + expect(names).to eq(%w[list get]) + end + + it "calls a tool and renders its structured content" do + stub_method("tools/call") + .with(body: hash_including("params" => hash_including("name" => "list"))) + .to_return(rpc_ok("structuredContent" => { "rentals" => [{ "id" => 1 }] })) + + result = run_cli("mcp", "call", "list", "--args", '{"resource":"rentals"}', "--mcp-token", "T", "-o", "json") + expect(JSON.parse(result[:stdout])).to eq([{ "id" => 1 }]) + end + + it "runs the list convenience wrapper over the generic tool" do + stub_method("tools/list").to_return(rpc_ok("tools" => [{ "name" => "list" }, { "name" => "get" }])) + call = stub_method("tools/call") + .with(body: hash_including("params" => hash_including( + "name" => "list", "arguments" => hash_including("resource" => "rentals", "account_id" => 42) + ))) + .to_return(rpc_ok("structuredContent" => { "rentals" => [{ "id" => 7 }] })) + + result = run_cli("mcp", "list", "rentals", "--account-id", "42", "--mcp-token", "T", "-o", "json") + expect(JSON.parse(result[:stdout])).to eq([{ "id" => 7 }]) + expect(call).to have_been_requested + end + + it "sends advanced operator + range filters as a structured filter object" do + stub_method("tools/list").to_return(rpc_ok("tools" => [{ "name" => "list" }])) + call = stub_method("tools/call") + .with(body: hash_including("params" => hash_including( + "name" => "list", + "arguments" => hash_including( + "resource" => "bookings", + "filter" => { + "final_price" => [ + { "op" => "gteq", "value" => 600 }, + { "op" => "lt", "value" => 800 } + ], + "status" => "booked" + } + ) + ))) + .to_return(rpc_ok("structuredContent" => { "bookings" => [] })) + + run_cli("mcp", "list", "bookings", + "--filter", "final_price[gteq]=600", "final_price[lt]=800", "status=booked", + "--mcp-token", "T", "-o", "json") + expect(call).to have_been_requested + end + + describe "convenience wrappers" do + before do + stub_method("tools/list").to_return(rpc_ok("tools" => [ + { "name" => "list" }, { "name" => "get" }, + { "name" => "resource_schema" }, { "name" => "resources" } + ])) + end + + it "get fetches a single record via the get tool" do + stub_method("tools/call") + .with(body: hash_including("params" => hash_including( + "name" => "get", "arguments" => hash_including("resource" => "rentals", "id" => 42) + ))) + .to_return(rpc_ok("structuredContent" => { "rentals" => [{ "id" => 42 }] })) + result = run_cli("mcp", "get", "rentals", "42", "--mcp-token", "T", "-o", "json") + expect(JSON.parse(result[:stdout])).to eq("id" => 42) + end + + it "schema calls the resource_schema tool" do + call = stub_method("tools/call") + .with(body: hash_including("params" => hash_including("name" => "resource_schema"))) + .to_return(rpc_ok("structuredContent" => { "attributes" => {} })) + run_cli("mcp", "schema", "bookings", "--mcp-token", "T", "-o", "json") + expect(call).to have_been_requested + end + + it "resources calls the resources tool" do + call = stub_method("tools/call") + .with(body: hash_including("params" => hash_including("name" => "resources"))) + .to_return(rpc_ok("structuredContent" => { "resources" => [{ "name" => "rentals" }] })) + result = run_cli("mcp", "resources", "--mcp-token", "T", "-o", "json") + expect(JSON.parse(result[:stdout])).to eq([{ "name" => "rentals" }]) + expect(call).to have_been_requested + end + end + + it "renders a scalar-array tool result in a table without crashing" do + stub_method("tools/call").to_return(rpc_ok("structuredContent" => %w[alpha beta])) + result = run_cli("mcp", "call", "sometool", "--mcp-token", "T", "-o", "table") + expect(result[:status]).to eq(0) + expect(result[:stdout]).to include("alpha", "beta") + end + + it "surfaces tool errors (isError) as a failure" do + stub_method("tools/call").to_return(rpc_ok( + "isError" => true, + "content" => [{ "type" => "text", "text" => "resource not found" }] + )) + result = run_cli("mcp", "call", "get", "--args", '{"resource":"nope"}', "--mcp-token", "T") + expect(result[:status]).to eq(1) + expect(result[:stderr]).to match(/resource not found/) + end + + it "fails cleanly when no MCP token is configured" do + result = run_cli("mcp", "tools") + expect(result[:status]).to eq(1) + expect(result[:stderr]).to match(/No MCP token configured/) + end + + it "saves MCP credentials via login" do + run_cli("mcp", "login", "MCPTOKEN", "--url", "https://x.test/mcp", "--profile", "prod") + config = SmilyCli::Config.load + expect(config.profile("prod")).to include("mcp_token" => "MCPTOKEN", "mcp_url" => "https://x.test/mcp") + end +end diff --git a/spec/smily_cli/oauth_spec.rb b/spec/smily_cli/oauth_spec.rb new file mode 100644 index 0000000..1f81eac --- /dev/null +++ b/spec/smily_cli/oauth_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::OAuth do + subject(:oauth) { described_class.new(base_url: "https://www.bookingsync.com") } + + let(:token_url) { "https://www.bookingsync.com/oauth/token" } + + def token_response(overrides = {}) + { + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate({ + "access_token" => "abc123", + "token_type" => "bearer", + "expires_in" => 7200, + "scope" => "public", + "created_at" => 1_700_000_000 + }.merge(overrides)) + } + end + + describe "#client_credentials" do + it "posts the grant and returns a parsed token" do + stub = stub_request(:post, token_url) + .with(body: hash_including("grant_type" => "client_credentials", + "client_id" => "CID", "client_secret" => "SECRET")) + .to_return(token_response) + + token = oauth.client_credentials(client_id: "CID", client_secret: "SECRET") + expect(token.access_token).to eq("abc123") + expect(token.scope).to eq("public") + expect(token.expires_at).to eq(Time.at(1_700_000_000 + 7200)) + expect(stub).to have_been_requested + end + end + + describe "#refresh" do + it "posts a refresh_token grant" do + stub = stub_request(:post, token_url) + .with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "RT")) + .to_return(token_response("refresh_token" => "new-rt")) + + token = oauth.refresh(client_id: "CID", client_secret: "SECRET", refresh_token: "RT") + expect(token.refresh_token).to eq("new-rt") + expect(stub).to have_been_requested + end + end + + describe "#authorization_code" do + it "exchanges a code for tokens" do + stub_request(:post, token_url) + .with(body: hash_including("grant_type" => "authorization_code", "code" => "CODE")) + .to_return(token_response) + + token = oauth.authorization_code(client_id: "CID", client_secret: "SECRET", + code: "CODE", redirect_uri: "https://app.test/cb") + expect(token.access_token).to eq("abc123") + end + end + + describe "#authorize_url" do + it "builds the authorize URL with encoded params" do + url = oauth.authorize_url(client_id: "CID", redirect_uri: "https://app.test/cb", + scope: "rentals_read bookings_read", state: "xyz") + expect(url).to start_with("https://www.bookingsync.com/oauth/authorize?") + expect(url).to include("client_id=CID") + expect(url).to include("redirect_uri=https%3A%2F%2Fapp.test%2Fcb") + expect(url).to include("scope=rentals_read+bookings_read") + expect(url).to include("response_type=code") + end + end + + describe "error handling" do + it "raises ApiError with the OAuth error description" do + stub_request(:post, token_url).to_return( + status: 401, headers: { "Content-Type" => "application/json" }, + body: JSON.generate("error" => "invalid_client", "error_description" => "bad secret") + ) + expect { oauth.client_credentials(client_id: "CID", client_secret: "bad") } + .to raise_error(SmilyCli::ApiError, /bad secret/) + end + end +end diff --git a/spec/smily_cli/query_options_spec.rb b/spec/smily_cli/query_options_spec.rb new file mode 100644 index 0000000..c712dd7 --- /dev/null +++ b/spec/smily_cli/query_options_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::QueryOptions do + describe ".parse_pairs" do + it "parses key=value into a hash" do + expect(described_class.parse_pairs(["status=booked", "from=2026-01-01"])) + .to eq("status" => "booked", "from" => "2026-01-01") + end + + it "accumulates repeated keys into an array" do + expect(described_class.parse_pairs(["status=booked", "status=tentative"])) + .to eq("status" => %w[booked tentative]) + end + + it "keeps values containing '=' intact" do + expect(described_class.parse_pairs(["q=a=b"])).to eq("q" => "a=b") + end + + it "raises UsageError when '=' is missing" do + expect { described_class.parse_pairs(["oops"]) } + .to raise_error(SmilyCli::UsageError, /Invalid key=value/) + end + end + + describe ".parse_filter" do + it "keeps a plain pair as coerced equality" do + expect(described_class.parse_filter(["currency=EUR"])).to eq("currency" => "EUR") + end + + it "coerces numeric and boolean equality values" do + expect(described_class.parse_filter(["adults=4", "imported=true"])) + .to eq("adults" => 4, "imported" => true) + end + + it "builds an { op, value } object for a single operator" do + expect(described_class.parse_filter(["final_price[gt]=1000"])) + .to eq("final_price" => { "op" => "gt", "value" => 1000 }) + end + + it "combines repeated conditions on one attribute into an AND array (range)" do + expect(described_class.parse_filter(["final_price[gteq]=600", "final_price[lt]=800"])) + .to eq("final_price" => [ + { "op" => "gteq", "value" => 600 }, + { "op" => "lt", "value" => 800 } + ]) + end + + it "promotes a bare equality to an eq object when combined with an operator" do + expect(described_class.parse_filter(["status=booked", "status[not_eq]=tentative"])) + .to eq("status" => [ + { "op" => "eq", "value" => "booked" }, + { "op" => "not_eq", "value" => "tentative" } + ]) + end + + it "splits an `in` operator value into an array" do + expect(described_class.parse_filter(["reference[in]=A123,B456"])) + .to eq("reference" => { "op" => "in", "value" => %w[A123 B456] }) + end + + it "leaves ISO 8601 timestamps as strings" do + expect(described_class.parse_filter(["start_at[gteq]=2026-01-01T00:00:00Z"])) + .to eq("start_at" => { "op" => "gteq", "value" => "2026-01-01T00:00:00Z" }) + end + + it "raises UsageError on an unknown operator" do + expect { described_class.parse_filter(["final_price[between]=1"]) } + .to raise_error(SmilyCli::UsageError, /Unknown filter operator "between"/) + end + end + + describe ".build" do + it "merges query then filter, adding include and fields" do + result = described_class.build( + fields: %w[id name], + include: %w[availability photos], + filter: ["status=booked"], + query: ["per_page=50"] + ) + expect(result).to eq( + "per_page" => "50", + "status" => "booked", + "include" => "availability,photos", + "fields" => "id,name" + ) + end + + it "omits include/fields when not provided" do + expect(described_class.build).to eq({}) + end + + it "splits comma-separated include values" do + expect(described_class.build(include: "a,b,c")["include"]).to eq("a,b,c") + end + end +end diff --git a/spec/smily_cli/redaction_spec.rb b/spec/smily_cli/redaction_spec.rb new file mode 100644 index 0000000..35c1b85 --- /dev/null +++ b/spec/smily_cli/redaction_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Redaction do + describe ".scrub" do + it "redacts a bearer authorization header but keeps the label" do + expect(described_class.scrub("Authorization: Bearer abc123secret")) + .to eq("Authorization: Bearer [REDACTED]") + end + + it "redacts client_secret, access_token and refresh_token in form/JSON text" do + wire = "client_id=CID&client_secret=shhh&grant_type=client_credentials" + expect(described_class.scrub(wire)).to eq("client_id=CID&client_secret=[REDACTED]&grant_type=client_credentials") + + json = '{"access_token":"tok-123","refresh_token":"ref-456"}' + scrubbed = described_class.scrub(json) + expect(scrubbed).not_to include("tok-123", "ref-456") + expect(scrubbed).to include("[REDACTED]") + end + + it "leaves non-secret text untouched" do + expect(described_class.scrub("GET /api/v3/rentals 200")).to eq("GET /api/v3/rentals 200") + end + end + + describe SmilyCli::Redaction::ScrubbedIO do + it "scrubs everything written through it" do + buffer = StringIO.new + io = described_class.new(buffer) + io << "Authorization: Bearer supersecrettoken\n" + io.write("client_secret=nope") + expect(buffer.string).to include("Authorization: Bearer [REDACTED]") + expect(buffer.string).to include("client_secret=[REDACTED]") + expect(buffer.string).not_to include("supersecrettoken", "nope") + end + end + + describe "verbose HTTP output (integration)" do + it "does not leak the token or client_secret from an OAuth request" do + stub_request(:post, "https://www.bookingsync.com/oauth/token") + .to_return(status: 200, headers: { "Content-Type" => "application/json" }, + body: JSON.generate("access_token" => "leaky-token-xyz", "token_type" => "bearer")) + + captured = StringIO.new + original = $stderr + $stderr = captured + begin + SmilyCli::OAuth.new(base_url: "https://www.bookingsync.com", verbose: true) + .client_credentials(client_id: "CID", client_secret: "topsecret") + ensure + $stderr = original + end + + expect(captured.string).not_to include("leaky-token-xyz") + expect(captured.string).not_to include("topsecret") + end + end +end diff --git a/spec/smily_cli/registry_spec.rb b/spec/smily_cli/registry_spec.rb new file mode 100644 index 0000000..8eb366e --- /dev/null +++ b/spec/smily_cli/registry_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Registry do + it "exposes documented resources with paths and groups" do + rentals = described_class.find("rentals") + expect(rentals.path).to eq("rentals") + expect(rentals.resource_key).to eq("rentals") + expect(rentals.group).to eq("Rentals") + expect(rentals).to be_writable + end + + it "keeps discount-code resources at their flat top-level v3 path" do + codes = described_class.find("booking_discount_codes") + expect(codes.path).to eq("booking_discount_codes") + expect(codes.resource_key).to eq("booking_discount_codes") + expect(codes.member_path(7)).to eq("booking_discount_codes/7") + end + + it "lets a command map to a different underlying path" do + reviews = described_class.find("hosts_reviews") + expect(reviews.path).to eq("host_reviews") + expect(reviews.resource_key).to eq("host_reviews") + end + + it "marks catalog resources read-only" do + expect(described_class.find("accounts")).not_to be_writable + expect(described_class.find("amenities")).not_to be_writable + end + + it "fetch raises a helpful UsageError with a suggestion for typos" do + expect { described_class.fetch("rental") } + .to raise_error(SmilyCli::UsageError, /Unknown resource "rental".*Did you mean "rentals"/m) + end + + it "groups resources preserving declaration order" do + grouped = described_class.grouped + expect(grouped.keys).to include("Bookings", "Rentals", "Rates", "Billing") + expect(grouped["Bookings"].first.command).to eq("bookings") + end + + it "has unique command names" do + commands = described_class.all.map(&:command) + expect(commands).to eq(commands.uniq) + end +end diff --git a/spec/smily_cli/result_spec.rb b/spec/smily_cli/result_spec.rb new file mode 100644 index 0000000..77aa107 --- /dev/null +++ b/spec/smily_cli/result_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +RSpec.describe SmilyCli::Result do + # A minimal stand-in for BookingSync::API::Response. + def api_response(status: 200, headers: {}, body: "{}") + Struct.new(:status, :headers, :body).new(status, headers, body) + end + + it "extracts records from the JSON:API envelope" do + body = { "rentals" => [{ "id" => 1 }, { "id" => 2 }], "meta" => { "count" => 2 }, "links" => {} } + result = described_class.from_response(api_response(body: JSON.generate(body))) + expect(result.key).to eq("rentals") + expect(result.records).to eq([{ "id" => 1 }, { "id" => 2 }]) + expect(result.meta).to eq("count" => 2) + expect(result.count).to eq(2) + expect(result).not_to be_single + end + + it "captures sideloaded linked records" do + body = { "rentals" => [{ "id" => 1 }], "linked" => { "photos" => [{ "id" => 9 }] } } + result = described_class.from_response(api_response(body: JSON.generate(body))) + expect(result.linked).to eq("photos" => [{ "id" => 9 }]) + end + + it "treats nil responses (204) as empty" do + result = described_class.from_response(nil, single: true) + expect(result).to be_empty + expect(result.record).to be_nil + end + + it "exposes the single record for member responses" do + body = { "rentals" => [{ "id" => 42, "name" => "Villa" }] } + result = described_class.from_response(api_response(body: JSON.generate(body)), single: true) + expect(result).to be_single + expect(result.record).to eq("id" => 42, "name" => "Villa") + end + + it "reads rate-limit and pagination headers" do + headers = { "X-RateLimit-Remaining" => "997", "X-Total-Pages" => "5" } + result = described_class.from_response(api_response(headers: headers)) + expect(result.rate_limit_remaining).to eq(997) + expect(result.total_pages).to eq(5) + end + + it "survives non-JSON bodies" do + result = described_class.from_response(api_response(body: "not json")) + expect(result.records).to eq([]) + end +end diff --git a/spec/smily_cli_spec.rb b/spec/smily_cli_spec.rb index 628be87..2114a64 100644 --- a/spec/smily_cli_spec.rb +++ b/spec/smily_cli_spec.rb @@ -2,10 +2,16 @@ RSpec.describe SmilyCli do it "has a version number" do - expect(SmilyCli::VERSION).not_to be nil + expect(SmilyCli::VERSION).to match(/\A\d+\.\d+\.\d+/) end - it "does something useful" do - expect(false).to eq(true) + it "exposes the Thor CLI lazily" do + expect(SmilyCli.cli).to eq(SmilyCli::CLI) + expect(SmilyCli::CLI.ancestors).to include(Thor) + end + + it "eager-loads cleanly (Zeitwerk file/constant names all line up)" do + require "thor" + expect { Zeitwerk::Loader.eager_load_all }.not_to raise_error end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f8a022d..b80abef 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,15 +1,90 @@ # frozen_string_literal: true require "smily_cli" +require "webmock/rspec" +require "vcr" +require "tmpdir" +require "stringio" + +WebMock.disable_net_connect! + +# Acceptance specs replay hand-authored cassettes (never re-recorded against a +# live server, so no real credentials are involved). Matching ignores query +# strings and headers, so the Bearer token isn't part of the match — but we +# still scrub any Authorization value defensively in case a cassette is ever +# regenerated. +VCR.configure do |vcr| + vcr.cassette_library_dir = File.expand_path("cassettes", __dir__) + vcr.hook_into :webmock + vcr.default_cassette_options = { record: :none, match_requests_on: %i[method path] } + vcr.filter_sensitive_data("") do |interaction| + interaction.request.headers["Authorization"]&.first&.sub(/\ABearer\s+/, "") + end +end + +module CLIHelpers + # Run the CLI with argv, capturing stdout/stderr and the exit status. Thor + # raises our typed errors (rescued at exe level), so we mirror that handling + # here to assert on messages/exit codes. + # + # @return [Hash] { stdout:, stderr:, status: } + def run_cli(*argv) + out = StringIO.new + err = StringIO.new + status = 0 + original_out = $stdout + original_err = $stderr + $stdout = out + $stderr = err + begin + SmilyCli::CLI.start(argv) + rescue SmilyCli::Error => e + err.puts("Error: #{e.message}") + status = e.exit_status + rescue SystemExit => e + status = e.status + ensure + $stdout = original_out + $stderr = original_err + end + { stdout: out.string, stderr: err.string, status: status } + end + + # Base API v3 URL used by stubs. + API = "https://www.bookingsync.com/api/v3" +end RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! - config.expect_with :rspec do |c| c.syntax = :expect end + + config.include CLIHelpers + + # Isolate config + credentials from the developer's real environment so specs + # are deterministic and never read ~/.config/smily. + config.around do |example| + Dir.mktmpdir do |dir| + previous = ENV.to_h.slice( + "SMILY_CONFIG", "SMILY_TOKEN", "BOOKINGSYNC_TOKEN", "SMILY_ACCESS_TOKEN", + "SMILY_PROFILE", "SMILY_BASE_URL", "BOOKINGSYNC_URL", "SMILY_OUTPUT", + "NO_COLOR", "SMILY_NO_COLOR", "SMILY_CLIENT_ID", "SMILY_CLIENT_SECRET", + "SMILY_REFRESH_TOKEN", "SMILY_VERBOSE" + ) + %w[SMILY_TOKEN BOOKINGSYNC_TOKEN SMILY_ACCESS_TOKEN SMILY_PROFILE SMILY_BASE_URL + BOOKINGSYNC_URL SMILY_OUTPUT SMILY_CLIENT_ID SMILY_CLIENT_SECRET + SMILY_REFRESH_TOKEN SMILY_VERBOSE].each { |k| ENV.delete(k) } + ENV["SMILY_CONFIG"] = File.join(dir, "config.yml") + ENV["SMILY_NO_COLOR"] = "1" + SmilyCli::Color.enabled = false + begin + example.run + ensure + previous.each { |k, v| ENV[k] = v } + %w[SMILY_CONFIG SMILY_NO_COLOR].each { |k| ENV.delete(k) unless previous.key?(k) } + end + end + end end