From 966039864540ff5069946191dde096bd9b1997ef Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 09:44:28 +0200 Subject: [PATCH 01/18] Implement BookingSync API v3 command-line client Build the `smily` CLI on top of the official bookingsync-api client: per-resource list/get/create/update/delete for all documented v3 resources, a raw `api` escape hatch, OAuth helpers, config profiles, account scoping (--account-id for client-credentials tokens), and table/json/yaml/csv/ndjson output with filtering, sparse fieldsets and pagination. Add RSpec (WebMock) coverage, RuboCop, and a GitHub Actions CI running tests (Ruby 3.2-3.4), RuboCop and Brakeman. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- .circleci/config.yml | 13 -- .github/workflows/ci.yml | 55 +++++ .gitignore | 6 + .rubocop.yml | 67 ++++++ CHANGELOG.md | 18 +- Gemfile | 4 + README.md | 253 +++++++++++++++++++++-- Rakefile | 11 +- exe/smily | 17 ++ lib/smily_cli.rb | 27 ++- lib/smily_cli/base_command.rb | 134 ++++++++++++ lib/smily_cli/cli.rb | 133 ++++++++++++ lib/smily_cli/cli_options.rb | 43 ++++ lib/smily_cli/client.rb | 202 ++++++++++++++++++ lib/smily_cli/color.rb | 53 +++++ lib/smily_cli/commands/auth_command.rb | 167 +++++++++++++++ lib/smily_cli/commands/config_command.rb | 161 +++++++++++++++ lib/smily_cli/completion.rb | 85 ++++++++ lib/smily_cli/config.rb | 154 ++++++++++++++ lib/smily_cli/context.rb | 190 +++++++++++++++++ lib/smily_cli/errors.rb | 124 +++++++++++ lib/smily_cli/formatters.rb | 212 +++++++++++++++++++ lib/smily_cli/oauth.rb | 140 +++++++++++++ lib/smily_cli/query_options.rb | 63 ++++++ lib/smily_cli/registry.rb | 178 ++++++++++++++++ lib/smily_cli/resource.rb | 42 ++++ lib/smily_cli/resource_command.rb | 137 ++++++++++++ lib/smily_cli/result.rb | 138 +++++++++++++ sig/smily_cli.rbs | 19 +- smily_cli.gemspec | 37 ++-- spec/smily_cli/cli_spec.rb | 144 +++++++++++++ spec/smily_cli/client_spec.rb | 172 +++++++++++++++ spec/smily_cli/config_spec.rb | 66 ++++++ spec/smily_cli/context_spec.rb | 71 +++++++ spec/smily_cli/errors_spec.rb | 39 ++++ spec/smily_cli/formatters_spec.rb | 88 ++++++++ spec/smily_cli/oauth_spec.rb | 83 ++++++++ spec/smily_cli/query_options_spec.rb | 49 +++++ spec/smily_cli/registry_spec.rb | 39 ++++ spec/smily_cli/result_spec.rb | 49 +++++ spec/smily_cli_spec.rb | 7 +- spec/spec_helper.rb | 69 ++++++- 42 files changed, 3702 insertions(+), 57 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .rubocop.yml create mode 100755 exe/smily create mode 100644 lib/smily_cli/base_command.rb create mode 100644 lib/smily_cli/cli.rb create mode 100644 lib/smily_cli/cli_options.rb create mode 100644 lib/smily_cli/client.rb create mode 100644 lib/smily_cli/color.rb create mode 100644 lib/smily_cli/commands/auth_command.rb create mode 100644 lib/smily_cli/commands/config_command.rb create mode 100644 lib/smily_cli/completion.rb create mode 100644 lib/smily_cli/config.rb create mode 100644 lib/smily_cli/context.rb create mode 100644 lib/smily_cli/errors.rb create mode 100644 lib/smily_cli/formatters.rb create mode 100644 lib/smily_cli/oauth.rb create mode 100644 lib/smily_cli/query_options.rb create mode 100644 lib/smily_cli/registry.rb create mode 100644 lib/smily_cli/resource.rb create mode 100644 lib/smily_cli/resource_command.rb create mode 100644 lib/smily_cli/result.rb create mode 100644 spec/smily_cli/cli_spec.rb create mode 100644 spec/smily_cli/client_spec.rb create mode 100644 spec/smily_cli/config_spec.rb create mode 100644 spec/smily_cli/context_spec.rb create mode 100644 spec/smily_cli/errors_spec.rb create mode 100644 spec/smily_cli/formatters_spec.rb create mode 100644 spec/smily_cli/oauth_spec.rb create mode 100644 spec/smily_cli/query_options_spec.rb create mode 100644 spec/smily_cli/registry_spec.rb create mode 100644 spec/smily_cli/result_spec.rb 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..c1e62c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +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 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..c3f3849 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,67 @@ +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 + +# 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..f06266f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,20 @@ ## [0.1.0] - 2026-07-06 -- Initial release +Initial release: 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`). +- `whoami`, `resources`, `completion` (bash/zsh/fish) and `version` commands. +- Built on the official `bookingsync-api` client. diff --git a/Gemfile b/Gemfile index caecbf9..e868b86 100644 --- a/Gemfile +++ b/Gemfile @@ -9,3 +9,7 @@ gem "irb" gem "rake", "~> 13.0" gem "rspec", "~> 3.0" +gem "webmock", "~> 3.0" + +gem "brakeman", "~> 8.0", require: false +gem "rubocop", "~> 1.80", require: false diff --git a/README.md b/README.md index bd77dd1..4aeeeb8 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,260 @@ -# 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: -Install the gem and add to the application's Gemfile by executing: +```ruby +gem "smily_cli" +``` + +This installs the `smily` executable. + +## Quick start ```bash -bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +# 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 ``` -If bundler is not being used to manage dependencies, install the gem by executing: +## 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 -gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +# 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 ``` -## Usage +`--save` stores the token (and any refresh token / client credentials) in the +active profile so subsequent commands just work. -TODO: Write usage instructions here +### Scoping a Client-Credentials token to one account -## Development +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). +- `--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). +- `--all` — fetch every page. +- `--page` / `--per-page` — fetch one specific page. -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. +### Creating & updating -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). +Provide attributes as a JSON object; the `{ "": [ … ] }` envelope +that v3 expects is added for you: -## Contributing +```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 +``` + +For nested creates (e.g. a booking under a rental) use the raw `api` command: + +```bash +smily api post 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. + +## 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 + +These work on every command (place them after the subcommand, e.g. +`smily rentals list --token …`): + +| Flag | Env | Meaning | +|------|-----|---------| +| `--profile NAME` | `SMILY_PROFILE` | Config profile to use | +| `--token TOKEN` | `SMILY_TOKEN` | OAuth access token | +| `--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) | +| `-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 | + +## 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..508b44e 100644 --- a/lib/smily_cli.rb +++ b/lib/smily_cli.rb @@ -1,8 +1,31 @@ # frozen_string_literal: true require_relative "smily_cli/version" +require_relative "smily_cli/errors" +require_relative "smily_cli/color" +require_relative "smily_cli/resource" +require_relative "smily_cli/registry" +require_relative "smily_cli/config" +require_relative "smily_cli/context" +require_relative "smily_cli/query_options" +require_relative "smily_cli/result" +require_relative "smily_cli/client" +require_relative "smily_cli/oauth" +require_relative "smily_cli/formatters" +# 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 (loaded lazily from `smily_cli/cli`) is a thin shell over it. module SmilyCli - class Error < StandardError; end - # Your code goes here... + # Lazily load the Thor-powered command-line interface. Keeping this out of the + # default require keeps the library usable without pulling in Thor. + # + # @return [Class] the {SmilyCli::CLI} Thor class + def self.cli + require_relative "smily_cli/cli" + CLI + end end diff --git a/lib/smily_cli/base_command.rb b/lib/smily_cli/base_command.rb new file mode 100644 index 0000000..8886060 --- /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 info(message) + warn(message) unless context.quiet? + end + + # Print a green success line to stderr. + # + # @param message [String] + # @return [void] + def success(message) + info(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 + info(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..60eb8b6 --- /dev/null +++ b/lib/smily_cli/cli.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "thor" +require "json" + +require_relative "../smily_cli" +require_relative "base_command" +require_relative "cli_options" +require_relative "resource_command" +require_relative "completion" +require_relative "commands/auth_command" +require_relative "commands/config_command" + +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" + + # 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") + + 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) + if options["output"] == "json" + payload = Registry.all.map do |r| + { "command" => r.command, "path" => r.path, "group" => r.group, + "description" => r.description, "readonly" => r.readonly } + end + emit(JSON.pretty_generate(payload)) + return + end + + Registry.grouped.each do |group, list| + matched = filter ? list.select { |r| r.command.include?(filter) } : list + next if matched.empty? + + emit(Color.bold(group)) + width = matched.map { |r| r.command.length }.max + matched.each do |r| + flag = r.readonly ? Color.dim(" (read-only)") : "" + emit(" #{r.command.ljust(width)} #{Color.dim(r.description)}#{flag}") + end + emit("") + 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 = options["output"] + if fmt && fmt != "json" + render_result(result, format: fmt) + elsif records + emit(JSON.pretty_generate(result.records)) + else + emit(JSON.pretty_generate(result.envelope || {})) + end + warn_rate_limit(result) + 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..7700ff6 --- /dev/null +++ b/lib/smily_cli/cli_options.rb @@ -0,0 +1,43 @@ +# 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 :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 + + # 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..07bbabd --- /dev/null +++ b/lib/smily_cli/client.rb @@ -0,0 +1,202 @@ +# 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 verbose [Boolean] log HTTP traffic to stderr + def initialize(token:, base_url: Context::DEFAULT_BASE_URL, account_id: nil, verbose: false) + @account_id = account_id + @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 + q["page"] = page if page + + response = call(:get, path, query: q) + first = Result.from_response(response) + records = first.records.dup + last = first + + if all || (limit && page.nil?) + current = response + loop do + break if limit && records.length >= limit + + nxt = current&.relations&.[](:next) + break unless nxt + + current = nxt.call({}, method: :get) + page_result = Result.from_response(current) + break if page_result.records.empty? + + records.concat(page_result.records) + last = page_result + end + end + + 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) + 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 + rescue BookingSync::API::Error => e + raise ApiError.from_api(e) + rescue Faraday::Error => e + raise ApiError.new("Network error talking to the API: #{e.message}") + end + + def member_path(path, id) + "#{path}/#{id}" + 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 + logger.formatter = ->(severity, _time, _prog, msg) { "[smily #{severity.downcase}] #{msg}\n" } + 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..ad974d5 --- /dev/null +++ b/lib/smily_cli/commands/auth_command.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require_relative "../base_command" +require_relative "../cli_options" + +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"] + ) + info("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? + info(Color.yellow("No token configured for profile #{context.profile_name.inspect}.")) + info("Obtain one with `smily auth client-credentials` or configure it via `smily config set token ...`.") + return + end + + info("Profile : #{context.profile_name}") + info("Base URL: #{context.base_url}") + info("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. + def render_token(token, save_client_id: nil, save_client_secret: nil) + if context.output_format == "json" + emit(JSON.pretty_generate(token.raw)) + else + emit(token.access_token) + info(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..37bcadf --- /dev/null +++ b/lib/smily_cli/commands/config_command.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require_relative "../base_command" + +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].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? + info("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) + info(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) + + info(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/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..d513768 --- /dev/null +++ b/lib/smily_cli/config.rb @@ -0,0 +1,154 @@ +# 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 + ].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 + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, YAML.dump(prune(@data))) + 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) + parsed.is_a?(Hash) ? parsed : {} + rescue Psych::SyntaxError => e + raise ConfigurationError, "Config file #{path} is not valid YAML: #{e.message}" + 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..f6e4556 --- /dev/null +++ b/lib/smily_cli/context.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +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 + + # @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. + # + # @return [String, nil] + def account_id + resolve("account_id", env: %w[SMILY_ACCOUNT_ID BOOKINGSYNC_ACCOUNT_ID]) + 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 + explicit = (options["output"] || ENV.fetch("SMILY_OUTPUT", nil))&.to_s + fmt = explicit && !explicit.empty? ? explicit : default_output_format + unless OUTPUT_FORMATS.include?(fmt) + raise UsageError, "Unknown output format #{fmt.inspect}. Valid: #{OUTPUT_FORMATS.join(', ')}." + end + + fmt + 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. + # + # @return [Client] + def client + Client.new( + token: token!, + base_url: base_url, + account_id: account_id, + verbose: verbose? + ) + end + + # An OAuth helper bound to this context's base URL. + # + # @return [OAuth] + def oauth + OAuth.new(base_url: base_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 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 + + 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..c032f40 --- /dev/null +++ b/lib/smily_cli/errors.rb @@ -0,0 +1,124 @@ +# 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 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..eea0c24 --- /dev/null +++ b/lib/smily_cli/formatters.rb @@ -0,0 +1,212 @@ +# 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? + + columns = fields || union_columns(records) + CSV.generate do |out| + out << columns + records.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 = 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)) + end + end + + # ---- table internals --------------------------------------------------- + + # @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) + count = result.count + parts = [] + parts << "#{count} #{count == 1 ? 'record' : 'records'}" + parts << "#{result.total_pages} pages total" if result.total_pages + hidden = hidden_field_note(result.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/oauth.rb b/lib/smily_cli/oauth.rb new file mode 100644 index 0000000..b3373cb --- /dev/null +++ b/lib/smily_cli/oauth.rb @@ -0,0 +1,140 @@ +# 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" + http.set_debug_output($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..291e4e7 --- /dev/null +++ b/lib/smily_cli/query_options.rb @@ -0,0 +1,63 @@ +# 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 + 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 + + # 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 + + # @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/registry.rb b/lib/smily_cli/registry.rb new file mode 100644 index 0000000..f927c37 --- /dev/null +++ b/lib/smily_cli/registry.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require_relative "resource" + +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", "Discount codes.", { path: "booking/discount_codes" }], + ["booking_discount_code_usages", "Bookings", "Discount code applications.", + { path: "booking/discount_code_usages" }], + + # --- 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..dffcbaf --- /dev/null +++ b/lib/smily_cli/resource_command.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require_relative "base_command" +require_relative "cli_options" + +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 (e.g. a booking under a rental) use `smily api`. + DESC + method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-", + desc: "Attributes as JSON, @file, or - for stdin" + def create + render_result(client.create(resource.collection_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..218290f --- /dev/null +++ b/lib/smily_cli/result.rb @@ -0,0 +1,138 @@ +# 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 + + # @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..1cf87f4 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,14 @@ 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" - # 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/smily_cli/cli_spec.rb b/spec/smily_cli/cli_spec.rb new file mode 100644 index 0000000..8200ead --- /dev/null +++ b/spec/smily_cli/cli_spec.rb @@ -0,0 +1,144 @@ +# 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 "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 "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 + 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..9f64485 --- /dev/null +++ b/spec/smily_cli/client_spec.rb @@ -0,0 +1,172 @@ +# 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 the limit without fetching further pages" 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 }, { "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 + 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 "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/config_spec.rb b/spec/smily_cli/config_spec.rb new file mode 100644 index 0000000..e222f5b --- /dev/null +++ b/spec/smily_cli/config_spec.rb @@ -0,0 +1,66 @@ +# 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 "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 + + 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..bc406e2 --- /dev/null +++ b/spec/smily_cli/context_spec.rb @@ -0,0 +1,71 @@ +# 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 + + 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..4ca547c --- /dev/null +++ b/spec/smily_cli/formatters_spec.rb @@ -0,0 +1,88 @@ +# 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 + 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 "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/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..282b74a --- /dev/null +++ b/spec/smily_cli/query_options_spec.rb @@ -0,0 +1,49 @@ +# 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 ".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/registry_spec.rb b/spec/smily_cli/registry_spec.rb new file mode 100644 index 0000000..fd9ffce --- /dev/null +++ b/spec/smily_cli/registry_spec.rb @@ -0,0 +1,39 @@ +# 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 "maps slash paths while keeping an underscore command" do + codes = described_class.find("booking_discount_codes") + expect(codes.path).to eq("booking/discount_codes") + expect(codes.resource_key).to eq("discount_codes") + expect(codes.member_path(7)).to eq("booking/discount_codes/7") + 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..ba8cb59 100644 --- a/spec/smily_cli_spec.rb +++ b/spec/smily_cli_spec.rb @@ -2,10 +2,11 @@ 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 end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f8a022d..efe4854 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,15 +1,76 @@ # frozen_string_literal: true require "smily_cli" +require "smily_cli/cli" +require "webmock/rspec" +require "tmpdir" +require "stringio" + +WebMock.disable_net_connect! + +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 From b707f2689a90cb4a1af0519db15609102be93188 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 10:33:03 +0200 Subject: [PATCH 02/18] Add MCP mode (smily mcp) for BookingSync MCP servers Speak the Streamable-HTTP JSON-RPC 2.0 transport to a BookingSync MCP server as a separate mode with its own credential (mcp_token) and endpoint (mcp_url, default /mcp). Adds `mcp info/tools/call`, `list/get/schema/resources` convenience wrappers over the generic dispatcher tools, and `mcp login`. Account scoping reuses --account-id as the account_id tool argument. Zero new dependencies; covered by WebMock specs. Renames the internal `info` helper to `notice` to avoid shadowing the `mcp info` command. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- CHANGELOG.md | 7 + README.md | 43 ++++- lib/smily_cli.rb | 1 + lib/smily_cli/base_command.rb | 6 +- lib/smily_cli/cli.rb | 3 + lib/smily_cli/cli_options.rb | 23 +++ lib/smily_cli/commands/auth_command.rb | 14 +- lib/smily_cli/commands/config_command.rb | 8 +- lib/smily_cli/commands/mcp_command.rb | 189 +++++++++++++++++++++ lib/smily_cli/config.rb | 2 +- lib/smily_cli/context.rb | 38 ++++- lib/smily_cli/errors.rb | 19 +++ lib/smily_cli/mcp/client.rb | 199 +++++++++++++++++++++++ lib/smily_cli/result.rb | 11 ++ spec/smily_cli/mcp/client_spec.rb | 106 ++++++++++++ spec/smily_cli/mcp/command_spec.rb | 77 +++++++++ 16 files changed, 729 insertions(+), 17 deletions(-) create mode 100644 lib/smily_cli/commands/mcp_command.rb create mode 100644 lib/smily_cli/mcp/client.rb create mode 100644 spec/smily_cli/mcp/client_spec.rb create mode 100644 spec/smily_cli/mcp/command_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f06266f..da7c2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## [Unreleased] +- 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. + ## [0.1.0] - 2026-07-06 Initial release: a command-line client for the BookingSync (Smily) API v3. diff --git a/README.md b/README.md index 4aeeeb8..0b0c5d7 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,45 @@ 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 +``` + +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 @@ -207,7 +246,9 @@ These work on every command (place them after the subcommand, e.g. | Flag | Env | Meaning | |------|-----|---------| | `--profile NAME` | `SMILY_PROFILE` | Config profile to use | -| `--token TOKEN` | `SMILY_TOKEN` | OAuth access token | +| `--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) | | `-o, --output` | `SMILY_OUTPUT` | Output format | diff --git a/lib/smily_cli.rb b/lib/smily_cli.rb index 508b44e..7aae259 100644 --- a/lib/smily_cli.rb +++ b/lib/smily_cli.rb @@ -11,6 +11,7 @@ require_relative "smily_cli/result" require_relative "smily_cli/client" require_relative "smily_cli/oauth" +require_relative "smily_cli/mcp/client" require_relative "smily_cli/formatters" # SmilyCli is a command-line client for the BookingSync (Smily) API v3. diff --git a/lib/smily_cli/base_command.rb b/lib/smily_cli/base_command.rb index 8886060..0b6429e 100644 --- a/lib/smily_cli/base_command.rb +++ b/lib/smily_cli/base_command.rb @@ -52,7 +52,7 @@ def emit(string) # # @param message [String] # @return [void] - def info(message) + def notice(message) warn(message) unless context.quiet? end @@ -61,7 +61,7 @@ def info(message) # @param message [String] # @return [void] def success(message) - info(Color.green(message)) + notice(Color.green(message)) end # Read and JSON-parse the `--data` option, which may be inline JSON, @@ -127,7 +127,7 @@ def warn_rate_limit(result) return unless context.verbose? remaining = result.rate_limit_remaining - info(Color.dim("rate limit remaining: #{remaining}")) if remaining + notice(Color.dim("rate limit remaining: #{remaining}")) if remaining end end end diff --git a/lib/smily_cli/cli.rb b/lib/smily_cli/cli.rb index 60eb8b6..50b6a95 100644 --- a/lib/smily_cli/cli.rb +++ b/lib/smily_cli/cli.rb @@ -10,6 +10,7 @@ require_relative "completion" require_relative "commands/auth_command" require_relative "commands/config_command" +require_relative "commands/mcp_command" module SmilyCli # The top-level `smily` command. It wires the reusable resource CRUD commands @@ -38,6 +39,8 @@ class CLI < BaseCommand "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 diff --git a/lib/smily_cli/cli_options.rb b/lib/smily_cli/cli_options.rb index 7700ff6..b4c442a 100644 --- a/lib/smily_cli/cli_options.rb +++ b/lib/smily_cli/cli_options.rb @@ -29,6 +29,29 @@ def connection(thor) 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] diff --git a/lib/smily_cli/commands/auth_command.rb b/lib/smily_cli/commands/auth_command.rb index ad974d5..df01752 100644 --- a/lib/smily_cli/commands/auth_command.rb +++ b/lib/smily_cli/commands/auth_command.rb @@ -69,7 +69,7 @@ def authorize_url account_id: options["account_id"], response_type: options["response_type"] ) - info("Open this URL, approve the app, then use `smily auth exchange` with the returned code:") + notice("Open this URL, approve the app, then use `smily auth exchange` with the returned code:") emit(url) end @@ -95,14 +95,14 @@ def exchange def status token = context.token if token.nil? - info(Color.yellow("No token configured for profile #{context.profile_name.inspect}.")) - info("Obtain one with `smily auth client-credentials` or configure it via `smily config set token ...`.") + 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 - info("Profile : #{context.profile_name}") - info("Base URL: #{context.base_url}") - info("Token : #{mask(token)}") + notice("Profile : #{context.profile_name}") + notice("Base URL: #{context.base_url}") + notice("Token : #{mask(token)}") return unless options["check"] me = client.me.record || {} @@ -123,7 +123,7 @@ def render_token(token, save_client_id: nil, save_client_secret: nil) emit(JSON.pretty_generate(token.raw)) else emit(token.access_token) - info(token_summary(token)) + notice(token_summary(token)) end save_token(token, save_client_id, save_client_secret) if options["save"] end diff --git a/lib/smily_cli/commands/config_command.rb b/lib/smily_cli/commands/config_command.rb index 37bcadf..c69641d 100644 --- a/lib/smily_cli/commands/config_command.rb +++ b/lib/smily_cli/commands/config_command.rb @@ -8,7 +8,7 @@ module Commands # 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].freeze + SECRET_KEYS = %w[token refresh_token client_secret mcp_token].freeze namespace "config" remove_command "tree" @@ -46,7 +46,7 @@ def init desc "list", "List profiles and their settings (secrets masked)" def list if config.profile_names.empty? - info("No profiles configured yet. Create one with `smily config init` or `smily config set`.") + notice("No profiles configured yet. Create one with `smily config init` or `smily config set`.") return end @@ -87,7 +87,7 @@ def unset(key) desc "use NAME", "Set the default profile" def use(name) unless config.profile?(name) - info(Color.yellow("Profile #{name.inspect} has no settings yet; selecting it anyway.")) + notice(Color.yellow("Profile #{name.inspect} has no settings yet; selecting it anyway.")) end config.current_profile_name = name config.save @@ -130,7 +130,7 @@ def context_output_json? def warn_unknown_key(key) return if Config::KNOWN_KEYS.include?(key) - info(Color.yellow("Note: #{key.inspect} isn't a standard setting (#{Config::KNOWN_KEYS.join(', ')}).")) + notice(Color.yellow("Note: #{key.inspect} isn't a standard setting (#{Config::KNOWN_KEYS.join(', ')}).")) end def coerce(value) diff --git a/lib/smily_cli/commands/mcp_command.rb b/lib/smily_cli/commands/mcp_command.rb new file mode 100644 index 0000000..3bc9731 --- /dev/null +++ b/lib/smily_cli/commands/mcp_command.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require_relative "../base_command" +require_relative "../cli_options" +require_relative "../mcp/client" + +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 + DESC + method_option :filter, type: :array, banner: "k=v", desc: "Exact-match attribute filters" + 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_pairs(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)) + 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 })) + 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. + def render_tool_result(result) + raise McpError, tool_text(result) if result["isError"] + + payload = result.key?("structuredContent") ? result["structuredContent"] : parse_text_content(result) + render_payload(payload) + 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. + def render_payload(payload) + case payload + when nil then nil + when String then emit(payload) + when Array then render_result(Result.new(records: payload, single: false)) + when Hash then render_hash_payload(payload) + else emit(JSON.pretty_generate(payload)) + end + end + + def render_hash_payload(payload) + key = Result.resource_key(payload) + if key && payload[key].is_a?(Array) + render_result(Result.from_body(payload)) + 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/config.rb b/lib/smily_cli/config.rb index d513768..1c2c307 100644 --- a/lib/smily_cli/config.rb +++ b/lib/smily_cli/config.rb @@ -20,7 +20,7 @@ class Config # 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 + scope account_id mcp_token mcp_url ].freeze DEFAULT_PROFILE = "default" diff --git a/lib/smily_cli/context.rb b/lib/smily_cli/context.rb index f6e4556..5f3af03 100644 --- a/lib/smily_cli/context.rb +++ b/lib/smily_cli/context.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "uri" + module SmilyCli # Resolves effective settings for a command from three layers, highest wins: # @@ -72,13 +74,40 @@ def 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. + # 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 + # Chosen output format. Defaults to `table` on a TTY, `json` otherwise, so # piping produces machine-readable output without a flag. # @@ -148,6 +177,13 @@ def oauth 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 + Mcp::Client.new(token: mcp_token!, url: mcp_url, verbose: verbose?) + end + private # flag ? env ? profile diff --git a/lib/smily_cli/errors.rb b/lib/smily_cli/errors.rb index c032f40..0095987 100644 --- a/lib/smily_cli/errors.rb +++ b/lib/smily_cli/errors.rb @@ -25,6 +25,25 @@ def exit_status 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 diff --git a/lib/smily_cli/mcp/client.rb b/lib/smily_cli/mcp/client.rb new file mode 100644 index 0000000..264aebe --- /dev/null +++ b/lib/smily_cli/mcp/client.rb @@ -0,0 +1,199 @@ +# 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" + http.set_debug_output($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/result.rb b/lib/smily_cli/result.rb index 218290f..32c2956 100644 --- a/lib/smily_cli/result.rb +++ b/lib/smily_cli/result.rb @@ -71,6 +71,17 @@ 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 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..c9173b8 --- /dev/null +++ b/spec/smily_cli/mcp/command_spec.rb @@ -0,0 +1,77 @@ +# 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 "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 From c080a35cfb0595e00c6696f7b95d7b200a326428 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:10:11 +0200 Subject: [PATCH 03/18] Redact credentials from --verbose output (P0 security) Verbose mode dumped `Authorization: Bearer ` (REST + MCP) and the `client_secret` / tokens (OAuth) to stderr in the clear. Route all three debug streams through a Redaction scrubber so secrets become [REDACTED]. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- CHANGELOG.md | 18 ++++---- lib/smily_cli.rb | 1 + lib/smily_cli/client.rb | 7 ++- lib/smily_cli/mcp/client.rb | 3 +- lib/smily_cli/oauth.rb | 3 +- lib/smily_cli/redaction.rb | 74 ++++++++++++++++++++++++++++++++ spec/smily_cli/redaction_spec.rb | 57 ++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 lib/smily_cli/redaction.rb create mode 100644 spec/smily_cli/redaction_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index da7c2bd..1d97185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,9 @@ ## [Unreleased] -- 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. - -## [0.1.0] - 2026-07-06 +## [0.1.0] -Initial release: a command-line client for the BookingSync (Smily) API v3. +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. @@ -24,5 +18,11 @@ Initial release: a command-line client for the BookingSync (Smily) API v3. - 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. diff --git a/lib/smily_cli.rb b/lib/smily_cli.rb index 7aae259..d02bc7e 100644 --- a/lib/smily_cli.rb +++ b/lib/smily_cli.rb @@ -2,6 +2,7 @@ require_relative "smily_cli/version" require_relative "smily_cli/errors" +require_relative "smily_cli/redaction" require_relative "smily_cli/color" require_relative "smily_cli/resource" require_relative "smily_cli/registry" diff --git a/lib/smily_cli/client.rb b/lib/smily_cli/client.rb index 07bbabd..1118b57 100644 --- a/lib/smily_cli/client.rb +++ b/lib/smily_cli/client.rb @@ -195,7 +195,12 @@ def build_logger(verbose) logger = Logger.new($stderr) logger.level = Logger::DEBUG - logger.formatter = ->(severity, _time, _prog, msg) { "[smily #{severity.downcase}] #{msg}\n" } + # 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 diff --git a/lib/smily_cli/mcp/client.rb b/lib/smily_cli/mcp/client.rb index 264aebe..0fb0534 100644 --- a/lib/smily_cli/mcp/client.rb +++ b/lib/smily_cli/mcp/client.rb @@ -140,7 +140,8 @@ def http_request(request) http = Net::HTTP.new(@uri.host, @uri.port) http.use_ssl = @uri.scheme == "https" - http.set_debug_output($stderr) if @verbose + # 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) diff --git a/lib/smily_cli/oauth.rb b/lib/smily_cli/oauth.rb index b3373cb..e7dbe86 100644 --- a/lib/smily_cli/oauth.rb +++ b/lib/smily_cli/oauth.rb @@ -101,7 +101,8 @@ def request_token(params) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" - http.set_debug_output($stderr) if @verbose + # 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" 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/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 From 9742d70e43b3cd0b1d69c78931f5505e2bbb09f5 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:11:32 +0200 Subject: [PATCH 04/18] Harden config file and directory permissions (P1 security) Create ~/.config/smily as 0700 (only when we create it, so a shared parent is left alone) and open the config file with 0600 from creation instead of chmod-after-write, closing the brief world-readable window. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/config.rb | 13 +++++++++++-- spec/smily_cli/config_spec.rb | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/smily_cli/config.rb b/lib/smily_cli/config.rb index 1c2c307..5b96776 100644 --- a/lib/smily_cli/config.rb +++ b/lib/smily_cli/config.rb @@ -120,8 +120,17 @@ def delete_profile(name) # # @return [String] the path written def save - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, YAML.dump(prune(@data))) + 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 diff --git a/spec/smily_cli/config_spec.rb b/spec/smily_cli/config_spec.rb index e222f5b..36fbbc0 100644 --- a/spec/smily_cli/config_spec.rb +++ b/spec/smily_cli/config_spec.rb @@ -28,6 +28,13 @@ 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" From c675e9a694b69ae16a53f232f78104213e2144dc Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:12:52 +0200 Subject: [PATCH 05/18] Warn when credentials would be sent over non-HTTPS (P2) Like the GitHub CLI, warn (to stderr) rather than hard-refuse when the resolved REST/OAuth/MCP endpoint is not HTTPS and a credential is about to be sent, so local test servers still work. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/context.rb | 18 ++++++++++++++++++ spec/smily_cli/context_spec.rb | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/smily_cli/context.rb b/lib/smily_cli/context.rb index 5f3af03..43ccb79 100644 --- a/lib/smily_cli/context.rb +++ b/lib/smily_cli/context.rb @@ -162,6 +162,7 @@ def refresh_token = resolve("refresh_token", env: %w[SMILY_REFRESH_TOKEN]) # # @return [Client] def client + warn_insecure_transport(base_url) Client.new( token: token!, base_url: base_url, @@ -174,6 +175,7 @@ def client # # @return [OAuth] def oauth + warn_insecure_transport(base_url) OAuth.new(base_url: base_url, verbose: verbose?) end @@ -181,6 +183,7 @@ def oauth # # @return [Mcp::Client] def mcp_client + warn_insecure_transport(mcp_url) Mcp::Client.new(token: mcp_token!, url: mcp_url, verbose: verbose?) end @@ -212,6 +215,21 @@ def apply_color_setting 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 diff --git a/spec/smily_cli/context_spec.rb b/spec/smily_cli/context_spec.rb index bc406e2..876d26f 100644 --- a/spec/smily_cli/context_spec.rb +++ b/spec/smily_cli/context_spec.rb @@ -62,6 +62,18 @@ def context(options = {}) 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") From 9c4ff7b0bf779296dba1b09761c5973031e3c900 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:16:33 +0200 Subject: [PATCH 06/18] Add a max-pages backstop to auto-pagination --all / --limit now stop after a configurable number of pages (--max-pages / SMILY_MAX_PAGES / profile max_pages, default 1000) and warn about possible truncation, so a server that keeps advertising a next link can't loop forever. Extract the follow-pages loop into a helper. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/cli_options.rb | 2 ++ lib/smily_cli/client.rb | 59 +++++++++++++++++++++++++---------- lib/smily_cli/context.rb | 12 +++++++ spec/smily_cli/client_spec.rb | 22 +++++++++++++ 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/lib/smily_cli/cli_options.rb b/lib/smily_cli/cli_options.rb index b4c442a..50c0912 100644 --- a/lib/smily_cli/cli_options.rb +++ b/lib/smily_cli/cli_options.rb @@ -21,6 +21,8 @@ def connection(thor) 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 :verbose, type: :boolean, aliases: "-v", desc: "Log HTTP requests/responses to stderr" thor.class_option :quiet, type: :boolean, aliases: "-q", diff --git a/lib/smily_cli/client.rb b/lib/smily_cli/client.rb index 1118b57..0e8d93b 100644 --- a/lib/smily_cli/client.rb +++ b/lib/smily_cli/client.rb @@ -19,9 +19,13 @@ class Client # 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 verbose [Boolean] log HTTP traffic to stderr - def initialize(token:, base_url: Context::DEFAULT_BASE_URL, account_id: nil, verbose: false) + def initialize(token:, base_url: Context::DEFAULT_BASE_URL, account_id: nil, + max_pages: Context::DEFAULT_MAX_PAGES, verbose: false) @account_id = account_id + @max_pages = max_pages @api = BookingSync::API.new(token, base_url: base_url, logger: build_logger(verbose)) end @@ -44,22 +48,7 @@ def list(path, query: {}, limit: nil, page: nil, per_page: nil, all: false) records = first.records.dup last = first - if all || (limit && page.nil?) - current = response - loop do - break if limit && records.length >= limit - - nxt = current&.relations&.[](:next) - break unless nxt - - current = nxt.call({}, method: :get) - page_result = Result.from_response(current) - break if page_result.records.empty? - - records.concat(page_result.records) - last = page_result - end - end + last = follow_pages(response, records, limit: limit) if all || (limit && page.nil?) records = records.first(limit) if limit Result.new( @@ -149,10 +138,46 @@ def call(method, path, query: {}, body: nil) raise ApiError.new("Network error talking to the API: #{e.message}") 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 + + if pages >= @max_pages + warn_truncated(pages) + break + end + + nxt = current&.relations&.[](:next) + break unless nxt + + current = nxt.call({}, method: :get) + 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? diff --git a/lib/smily_cli/context.rb b/lib/smily_cli/context.rb index 43ccb79..263e241 100644 --- a/lib/smily_cli/context.rb +++ b/lib/smily_cli/context.rb @@ -15,6 +15,7 @@ module SmilyCli 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 @@ -160,6 +161,16 @@ 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 + # @return [Client] def client warn_insecure_transport(base_url) @@ -167,6 +178,7 @@ def client token: token!, base_url: base_url, account_id: account_id, + max_pages: max_pages, verbose: verbose? ) end diff --git a/spec/smily_cli/client_spec.rb b/spec/smily_cli/client_spec.rb index 9f64485..0f9fea6 100644 --- a/spec/smily_cli/client_spec.rb +++ b/spec/smily_cli/client_spec.rb @@ -54,6 +54,28 @@ def body_for(hash) 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 "stops at the limit without fetching further pages" do stub_request(:get, "#{api}/rentals") .with(query: {}) From 9d51ec0bd98a80d2cc4b8818d0c4f9240b0e6451 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:17:26 +0200 Subject: [PATCH 07/18] Request per_page = min(limit, 100) when a limit is set without a page size Avoids paging a small --limit at the default 25/page: ask the API for exactly the number of rows needed (capped at the API max of 100). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/client.rb | 3 +++ spec/smily_cli/client_spec.rb | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/smily_cli/client.rb b/lib/smily_cli/client.rb index 0e8d93b..023855a 100644 --- a/lib/smily_cli/client.rb +++ b/lib/smily_cli/client.rb @@ -41,6 +41,9 @@ def initialize(token:, base_url: Context::DEFAULT_BASE_URL, account_id: nil, 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) diff --git a/spec/smily_cli/client_spec.rb b/spec/smily_cli/client_spec.rb index 0f9fea6..ee5ffe2 100644 --- a/spec/smily_cli/client_spec.rb +++ b/spec/smily_cli/client_spec.rb @@ -77,8 +77,9 @@ def body_for(hash) 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: {}) + .with(query: { "per_page" => "1" }) .to_return( status: 200, headers: json_headers.merge("Link" => %(<#{api}/rentals?page=2>; rel="next")), @@ -90,6 +91,18 @@ def body_for(hash) 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 From 31a84b05180f67d68a91afc865dc0c91f7cbb0be Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:21:03 +0200 Subject: [PATCH 08/18] Add optional 429 retry honoring rate-limit reset --retry N (SMILY_RETRY / profile) retries 429 Too Many Requests up to N times, waiting for Retry-After or X-RateLimit-Reset (capped). Also route pagination follow-up requests through the same error handling so an error on page 2+ maps to ApiError instead of escaping unhandled. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- .rubocop.yml | 3 ++ lib/smily_cli/cli_options.rb | 2 + lib/smily_cli/client.rb | 79 +++++++++++++++++++++++++++++------ lib/smily_cli/context.rb | 9 ++++ spec/smily_cli/client_spec.rb | 29 +++++++++++++ 5 files changed, 109 insertions(+), 13 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index c3f3849..a2d0132 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -37,6 +37,9 @@ 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. diff --git a/lib/smily_cli/cli_options.rb b/lib/smily_cli/cli_options.rb index 50c0912..0c1a287 100644 --- a/lib/smily_cli/cli_options.rb +++ b/lib/smily_cli/cli_options.rb @@ -23,6 +23,8 @@ def connection(thor) 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", diff --git a/lib/smily_cli/client.rb b/lib/smily_cli/client.rb index 023855a..96ef0f7 100644 --- a/lib/smily_cli/client.rb +++ b/lib/smily_cli/client.rb @@ -21,11 +21,19 @@ class Client # 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, verbose: false) + 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 @@ -127,18 +135,63 @@ def me # 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) - 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) + 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 - rescue BookingSync::API::Error => e - raise ApiError.from_api(e) - rescue Faraday::Error => e - raise ApiError.new("Network error talking to the API: #{e.message}") + 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 @@ -160,7 +213,7 @@ def follow_pages(response, records, limit:) nxt = current&.relations&.[](:next) break unless nxt - current = nxt.call({}, method: :get) + current = fetch_next(nxt) pages += 1 page_result = Result.from_response(current) break if page_result.records.empty? diff --git a/lib/smily_cli/context.rb b/lib/smily_cli/context.rb index 263e241..2255996 100644 --- a/lib/smily_cli/context.rb +++ b/lib/smily_cli/context.rb @@ -171,6 +171,14 @@ def max_pages 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) @@ -179,6 +187,7 @@ def client base_url: base_url, account_id: account_id, max_pages: max_pages, + retries: retries, verbose: verbose? ) end diff --git a/spec/smily_cli/client_spec.rb b/spec/smily_cli/client_spec.rb index ee5ffe2..7461ccb 100644 --- a/spec/smily_cli/client_spec.rb +++ b/spec/smily_cli/client_spec.rb @@ -182,6 +182,35 @@ def body_for(hash) 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( From ce5ee20712090edeb9fd7ec98cd4ad8eadc2ee44 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:22:57 +0200 Subject: [PATCH 09/18] Accept global flags before the subcommand Thor doesn't pass a parent's class options to registered subcommands when they appear before the subcommand word, so `smily -o json rentals list` dropped -o. Normalize argv in CLI.start by hoisting leading single-value and boolean global flags to the end, where every command re-declares them. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/cli.rb | 40 ++++++++++++++++++++++++++++++++++++++ spec/smily_cli/cli_spec.rb | 25 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/lib/smily_cli/cli.rb b/lib/smily_cli/cli.rb index 50b6a95..1b86aea 100644 --- a/lib/smily_cli/cli.rb +++ b/lib/smily_cli/cli.rb @@ -24,6 +24,46 @@ class CLI < BaseCommand 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 + + # 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.) + # + # @param argv [Array] + # @return [Array] + 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 + leading << rest.shift if !token.include?("=") && !rest.empty? + elsif HOISTABLE_BOOL_FLAGS.include?(token) + leading << rest.shift + else + break + end + end + leading.empty? || rest.empty? ? argv : rest + leading + 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" diff --git a/spec/smily_cli/cli_spec.rb b/spec/smily_cli/cli_spec.rb index 8200ead..71abdd6 100644 --- a/spec/smily_cli/cli_spec.rb +++ b/spec/smily_cli/cli_spec.rb @@ -4,6 +4,31 @@ 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 "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( From e12b180aadedf3deef8359b644987a1f3f413a8c Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:23:55 +0200 Subject: [PATCH 10/18] Support --path override on create for nested resources `smily bookings create --path rentals/42/bookings --data ...` posts to a nested collection while keeping the resource envelope key, so nested creates no longer force a drop to `smily api`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/resource_command.rb | 10 ++++++++-- spec/smily_cli/cli_spec.rb | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/smily_cli/resource_command.rb b/lib/smily_cli/resource_command.rb index dffcbaf..faf3562 100644 --- a/lib/smily_cli/resource_command.rb +++ b/lib/smily_cli/resource_command.rb @@ -93,12 +93,18 @@ def get(id) smily rentals create --data '{"name":"Villa"}' smily clients create --data @client.json - For nested creates (e.g. a booking under a rental) use `smily api`. + 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 - render_result(client.create(resource.collection_path, resource.resource_key, require_data)) + 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" diff --git a/spec/smily_cli/cli_spec.rb b/spec/smily_cli/cli_spec.rb index 71abdd6..ab1ad13 100644 --- a/spec/smily_cli/cli_spec.rb +++ b/spec/smily_cli/cli_spec.rb @@ -83,6 +83,17 @@ 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" }] } From c8da37200bfbc70da393b3b77e55e17c398c02a0 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:27:04 +0200 Subject: [PATCH 11/18] Backfill test coverage for auth, config and MCP wrappers Add specs for the previously untested auth command layer (client-credentials /refresh/exchange/authorize-url/status/token, incl. --save persistence), the config subcommands (path/get/set/unset/use/remove/list masking) and the MCP get/schema/resources convenience wrappers. Render MCP get/schema as a single member (single:) rather than a one-element list. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/commands/mcp_command.rb | 22 +++-- spec/smily_cli/commands/auth_command_spec.rb | 92 +++++++++++++++++++ .../smily_cli/commands/config_command_spec.rb | 44 +++++++++ spec/smily_cli/mcp/command_spec.rb | 36 ++++++++ 4 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 spec/smily_cli/commands/auth_command_spec.rb create mode 100644 spec/smily_cli/commands/config_command_spec.rb diff --git a/lib/smily_cli/commands/mcp_command.rb b/lib/smily_cli/commands/mcp_command.rb index 3bc9731..11d3361 100644 --- a/lib/smily_cli/commands/mcp_command.rb +++ b/lib/smily_cli/commands/mcp_command.rb @@ -89,12 +89,12 @@ 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)) + 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 })) + 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" @@ -138,11 +138,12 @@ def coerce_id(value) # Render an MCP tool result: raise on `isError`, else render the # structured output (or parsed text content) like any other payload. - def render_tool_result(result) + # `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) + render_payload(payload, single: single) end def tool_text(result) @@ -160,21 +161,22 @@ def parse_text_content(result) # 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. - def render_payload(payload) + # 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: false)) - when Hash then render_hash_payload(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) + 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)) + render_result(Result.from_body(payload, single: single)) else render_result(Result.new(records: [payload], single: true)) 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..5af4a53 --- /dev/null +++ b/spec/smily_cli/commands/auth_command_spec.rb @@ -0,0 +1,92 @@ +# 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 "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..8333a9b --- /dev/null +++ b/spec/smily_cli/commands/config_command_spec.rb @@ -0,0 +1,44 @@ +# 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 "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/mcp/command_spec.rb b/spec/smily_cli/mcp/command_spec.rb index c9173b8..de8e55f 100644 --- a/spec/smily_cli/mcp/command_spec.rb +++ b/spec/smily_cli/mcp/command_spec.rb @@ -53,6 +53,42 @@ def rpc_ok(result, session = nil) 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 "surfaces tool errors (isError) as a failure" do stub_method("tools/call").to_return(rpc_ok( "isError" => true, From d1d4609a281a069ae430c38b14aa53650d6eb837 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:28:12 +0200 Subject: [PATCH 12/18] Add bundler-audit dependency CVE scan to CI New CI job runs `bundler-audit check --update` against the ruby-advisory-db to catch known CVEs in transitive dependencies. Currently: no vulnerabilities. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- .github/workflows/ci.yml | 13 +++++++++++++ Gemfile | 1 + 2 files changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1e62c3..7d84411 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,16 @@ jobs: # -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/Gemfile b/Gemfile index e868b86..1fd0e86 100644 --- a/Gemfile +++ b/Gemfile @@ -12,4 +12,5 @@ gem "rspec", "~> 3.0" gem "webmock", "~> 3.0" gem "brakeman", "~> 8.0", require: false +gem "bundler-audit", "~> 0.9", require: false gem "rubocop", "~> 1.80", require: false From 324262acabf7fb783dc6a8d8402e67b085c24cc6 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 11:29:47 +0200 Subject: [PATCH 13/18] Document new flags and security posture in README/CHANGELOG Cover --max-pages, --retry, create --path, global-flags-before-subcommand, and the verbose-redaction / HTTPS-warning / 0600 security notes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- CHANGELOG.md | 9 +++++++++ README.md | 30 ++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d97185..18a8795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,3 +26,12 @@ API v3. (`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/README.md b/README.md index 0b0c5d7..e687c0c 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,14 @@ smily rentals list --include photos # sideload associations - `--filter k=v` / `--query k=v` — API query parameters (repeat or space-separate). - `--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). +- `--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 @@ -158,10 +163,11 @@ smily clients create --data @client.json # from a file echo '{"sleeps":8}' | smily rentals update 42 --data - # from stdin ``` -For nested creates (e.g. a booking under a rental) use the raw `api` command: +For nested creates (e.g. a booking under a rental), point `--path` at the +parent collection (the envelope key stays the resource's): ```bash -smily api post rentals/42/bookings --data @booking.json +smily bookings create --path rentals/42/bookings --data @booking.json ``` ## The raw `api` command @@ -240,8 +246,9 @@ smily bookings list --all -o ndjson | jq 'select(.status=="booked")' ## Global options -These work on every command (place them after the subcommand, e.g. -`smily rentals list --token …`): +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 | |------|-----|---------| @@ -251,11 +258,22 @@ These work on every command (place them after the subcommand, e.g. | `--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 | +| `-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 From e53d345a77d8815f0635f827d25709afffd2cf49 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 13:24:33 +0200 Subject: [PATCH 14/18] Honor -o / SMILY_OUTPUT for resources and api resources only special-cased -o json (ignoring -o yaml/csv/ndjson and SMILY_OUTPUT), and api read options["output"] directly (ignoring the env var). Route both through the shared output resolver: resources renders the grouped human view for table and Formatters for any other format; api uses the explicitly-requested format (flag or env), defaulting to the raw JSON envelope. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/cli.rb | 39 ++++++++++++++++++++------------------ lib/smily_cli/context.rb | 27 +++++++++++++++++++------- spec/smily_cli/cli_spec.rb | 23 ++++++++++++++++++++++ 3 files changed, 64 insertions(+), 25 deletions(-) diff --git a/lib/smily_cli/cli.rb b/lib/smily_cli/cli.rb index 1b86aea..fb7a457 100644 --- a/lib/smily_cli/cli.rb +++ b/lib/smily_cli/cli.rb @@ -118,26 +118,16 @@ def api(method, path) desc "resources [FILTER]", "List the API v3 resources this CLI knows about" def resources(filter = nil) - if options["output"] == "json" - payload = Registry.all.map do |r| + 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 - emit(JSON.pretty_generate(payload)) - return - end - - Registry.grouped.each do |group, list| - matched = filter ? list.select { |r| r.command.include?(filter) } : list - next if matched.empty? - - emit(Color.bold(group)) - width = matched.map { |r| r.command.length }.max - matched.each do |r| - flag = r.readonly ? Color.dim(" (read-only)") : "" - emit(" #{r.command.ljust(width)} #{Color.dim(r.description)}#{flag}") - end - emit("") + render_result(Result.new(records: payload, single: false)) end end @@ -161,7 +151,7 @@ def version # 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 = options["output"] + fmt = context.requested_output_format if fmt && fmt != "json" render_result(result, format: fmt) elsif records @@ -171,6 +161,19 @@ def render_api(result, records:) end 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/context.rb b/lib/smily_cli/context.rb index 2255996..d1abc52 100644 --- a/lib/smily_cli/context.rb +++ b/lib/smily_cli/context.rb @@ -109,18 +109,25 @@ def mcp_url 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 - explicit = (options["output"] || ENV.fetch("SMILY_OUTPUT", nil))&.to_s - fmt = explicit && !explicit.empty? ? explicit : default_output_format - unless OUTPUT_FORMATS.include?(fmt) - raise UsageError, "Unknown output format #{fmt.inspect}. Valid: #{OUTPUT_FORMATS.join(', ')}." - end - - fmt + requested_output_format || default_output_format end # Field selection shared between sparse fieldsets (sent to the API) and @@ -224,6 +231,12 @@ def resolve(key, env: []) 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 diff --git a/spec/smily_cli/cli_spec.rb b/spec/smily_cli/cli_spec.rb index ab1ad13..a4d52e9 100644 --- a/spec/smily_cli/cli_spec.rb +++ b/spec/smily_cli/cli_spec.rb @@ -158,6 +158,29 @@ 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 From 06f37a9756e6770114fa872e5723f21f598bbace Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 13:25:35 +0200 Subject: [PATCH 15/18] Validate config shape on load A well-formed-YAML-but-wrong-shape config (e.g. a hand edit leaving `profiles: []`) previously blew up with a raw NoMethodError deep in a later command, which the executable doesn't rescue as a CLI error. Validate that `profiles` (and each profile) is a mapping on load, raising ConfigurationError so it surfaces as a clean, exit-1 message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/config.rb | 24 ++++++++++++++++++- .../smily_cli/commands/config_command_spec.rb | 7 ++++++ spec/smily_cli/config_spec.rb | 14 +++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/smily_cli/config.rb b/lib/smily_cli/config.rb index 5b96776..3f94ed9 100644 --- a/lib/smily_cli/config.rb +++ b/lib/smily_cli/config.rb @@ -146,11 +146,33 @@ def read return {} unless File.exist?(path) parsed = YAML.safe_load_file(path, permitted_classes: [], aliases: false) - parsed.is_a?(Hash) ? parsed : {} + 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}" 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 diff --git a/spec/smily_cli/commands/config_command_spec.rb b/spec/smily_cli/commands/config_command_spec.rb index 8333a9b..51e5da2 100644 --- a/spec/smily_cli/commands/config_command_spec.rb +++ b/spec/smily_cli/commands/config_command_spec.rb @@ -35,6 +35,13 @@ 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") diff --git a/spec/smily_cli/config_spec.rb b/spec/smily_cli/config_spec.rb index 36fbbc0..6b54f11 100644 --- a/spec/smily_cli/config_spec.rb +++ b/spec/smily_cli/config_spec.rb @@ -57,6 +57,20 @@ 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 + describe ".default_path" do it "honors SMILY_CONFIG" do expect(described_class.default_path).to eq(ENV.fetch("SMILY_CONFIG")) From ab990344684e62daf7a87f11c1f9c175d516930a Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Tue, 7 Jul 2026 13:27:35 +0200 Subject: [PATCH 16/18] Harden table/CSV formatting against non-hash records An arbitrary MCP tool (`smily mcp call`) can return a scalar/array payload; table and CSV output assumed each record was a Hash and crashed with a raw NoMethodError. Coerce non-hash records into a single `value` column so any caller is safe. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1zLtLLaFLKErBdmh18feY --- lib/smily_cli/formatters.rb | 24 +++++++++++++++++------- spec/smily_cli/formatters_spec.rb | 10 ++++++++++ spec/smily_cli/mcp/command_spec.rb | 7 +++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/smily_cli/formatters.rb b/lib/smily_cli/formatters.rb index eea0c24..7f31119 100644 --- a/lib/smily_cli/formatters.rb +++ b/lib/smily_cli/formatters.rb @@ -58,10 +58,11 @@ def ndjson(records) def csv(records, fields: nil) return "" if records.empty? - columns = fields || union_columns(records) + rows = rowify(records) + columns = fields || union_columns(rows) CSV.generate do |out| out << columns - records.each do |record| + rows.each do |record| out << columns.map { |c| scalarize(record[c]) } end end @@ -74,18 +75,27 @@ def csv(records, fields: nil) # @param fields [Array, nil] # @return [String] def table(result, fields: nil) - records = result.records + 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)) + 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 @@ -118,12 +128,12 @@ def grid_table(records, fields:, meta: nil) end # @api private - def table_footer(result) - count = result.count + 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(result.records) + hidden = hidden_field_note(records) parts << hidden if hidden Color.dim("(#{parts.join(', ')})") end diff --git a/spec/smily_cli/formatters_spec.rb b/spec/smily_cli/formatters_spec.rb index 4ca547c..8ab0569 100644 --- a/spec/smily_cli/formatters_spec.rb +++ b/spec/smily_cli/formatters_spec.rb @@ -58,6 +58,11 @@ def member(record) 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 @@ -79,6 +84,11 @@ def member(record) 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") diff --git a/spec/smily_cli/mcp/command_spec.rb b/spec/smily_cli/mcp/command_spec.rb index de8e55f..76d2344 100644 --- a/spec/smily_cli/mcp/command_spec.rb +++ b/spec/smily_cli/mcp/command_spec.rb @@ -89,6 +89,13 @@ def rpc_ok(result, session = nil) 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, From 1cef3e1ec0497d9b73b727590c2f6de77ab4e223 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Wed, 8 Jul 2026 16:34:51 +0200 Subject: [PATCH 17/18] Review hardening: MCP operator/range filters, Zeitwerk, VCR acceptance tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Advanced filtering: `smily mcp list` now speaks the API v3 filter DSL — `attribute[op]=value` operators (eq/not_eq/gt/gteq/lt/lteq/in/matches/ does_not_match), with a repeated attribute combining conditions as AND (numeric/date ranges). Values coerce to numbers/booleans; ISO 8601 stays a string. Reachable structurally over the real api_v3_list tool. - Compatibility fix: booking_discount_codes / booking_discount_code_usages now use their flat top-level v3 paths (matching the API and MCP), not a nested `booking/discount_codes` path (which was also the wrong write-envelope key). - Zeitwerk autoloading replaces the hand-maintained require_relative chains (errors.rb/version.rb required eagerly; CLI/OAuth/CLIOptions inflections). - VCR-cassette acceptance tests drive the CLI end-to-end: REST (list, Link pagination, 404 -> exit 4) and MCP (full JSON-RPC handshake, api_v3_-prefixed tool-name resolution, operator/range filter). - Avoid a double rate-limit warning in `smily api` verbose output. - README: dedicated first-run Setup section + advanced-filtering docs; CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++ Gemfile | 1 + README.md | 68 +++++++++++++++ lib/smily_cli.rb | 36 ++++---- lib/smily_cli/cli.rb | 22 ++--- lib/smily_cli/commands/auth_command.rb | 3 - lib/smily_cli/commands/config_command.rb | 2 - lib/smily_cli/commands/mcp_command.rb | 16 ++-- lib/smily_cli/query_options.rb | 101 +++++++++++++++++++++++ lib/smily_cli/registry.rb | 7 +- lib/smily_cli/resource_command.rb | 3 - smily_cli.gemspec | 2 + spec/acceptance/cli_acceptance_spec.rb | 55 ++++++++++++ spec/cassettes/mcp_list_filtered.yml | 93 +++++++++++++++++++++ spec/cassettes/rest_rental_not_found.yml | 25 ++++++ spec/cassettes/rest_rentals_list.yml | 31 +++++++ spec/cassettes/rest_rentals_paginate.yml | 47 +++++++++++ spec/smily_cli/mcp/command_spec.rb | 24 ++++++ spec/smily_cli/query_options_spec.rb | 47 +++++++++++ spec/smily_cli/registry_spec.rb | 14 +++- spec/smily_cli_spec.rb | 5 ++ spec/spec_helper.rb | 16 +++- 22 files changed, 575 insertions(+), 56 deletions(-) create mode 100644 spec/acceptance/cli_acceptance_spec.rb create mode 100644 spec/cassettes/mcp_list_filtered.yml create mode 100644 spec/cassettes/rest_rental_not_found.yml create mode 100644 spec/cassettes/rest_rentals_list.yml create mode 100644 spec/cassettes/rest_rentals_paginate.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a8795..f0ab74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## [Unreleased] +- 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). + ## [0.1.0] Initial (unreleased) version: a command-line client for the BookingSync (Smily) diff --git a/Gemfile b/Gemfile index 1fd0e86..859f527 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem "irb" gem "rake", "~> 13.0" gem "rspec", "~> 3.0" +gem "vcr", "~> 6.0" gem "webmock", "~> 3.0" gem "brakeman", "~> 8.0", require: false diff --git a/README.md b/README.md index e687c0c..28110ef 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,42 @@ gem "smily_cli" This installs the `smily` executable. +## Setup (first run) + +Once the gem is installed, get from zero to your first authenticated call in +four steps: + +```bash +# 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 +``` + +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 +smily config set account_id +``` + +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. + ## Quick start ```bash @@ -141,6 +177,11 @@ 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 @@ -215,6 +256,33 @@ 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 diff --git a/lib/smily_cli.rb b/lib/smily_cli.rb index d02bc7e..fbd600c 100644 --- a/lib/smily_cli.rb +++ b/lib/smily_cli.rb @@ -1,33 +1,37 @@ # frozen_string_literal: true +require "zeitwerk" + require_relative "smily_cli/version" require_relative "smily_cli/errors" -require_relative "smily_cli/redaction" -require_relative "smily_cli/color" -require_relative "smily_cli/resource" -require_relative "smily_cli/registry" -require_relative "smily_cli/config" -require_relative "smily_cli/context" -require_relative "smily_cli/query_options" -require_relative "smily_cli/result" -require_relative "smily_cli/client" -require_relative "smily_cli/oauth" -require_relative "smily_cli/mcp/client" -require_relative "smily_cli/formatters" # 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 (loaded lazily from `smily_cli/cli`) is a thin shell over it. +# 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 - # Lazily load the Thor-powered command-line interface. Keeping this out of the - # default require keeps the library usable without pulling in Thor. + # 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 - require_relative "smily_cli/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/cli.rb b/lib/smily_cli/cli.rb index fb7a457..935c6b5 100644 --- a/lib/smily_cli/cli.rb +++ b/lib/smily_cli/cli.rb @@ -2,15 +2,7 @@ require "thor" require "json" - -require_relative "../smily_cli" -require_relative "base_command" -require_relative "cli_options" -require_relative "resource_command" -require_relative "completion" -require_relative "commands/auth_command" -require_relative "commands/config_command" -require_relative "commands/mcp_command" +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 @@ -152,13 +144,11 @@ def version # print raw JSON (records array when paginating, else the full envelope). def render_api(result, records:) fmt = context.requested_output_format - if fmt && fmt != "json" - render_result(result, format: fmt) - elsif records - emit(JSON.pretty_generate(result.records)) - else - emit(JSON.pretty_generate(result.envelope || {})) - end + # 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 diff --git a/lib/smily_cli/commands/auth_command.rb b/lib/smily_cli/commands/auth_command.rb index df01752..1a5a928 100644 --- a/lib/smily_cli/commands/auth_command.rb +++ b/lib/smily_cli/commands/auth_command.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "../base_command" -require_relative "../cli_options" - module SmilyCli module Commands # OAuth helpers: obtain, refresh, inspect and (optionally) persist access diff --git a/lib/smily_cli/commands/config_command.rb b/lib/smily_cli/commands/config_command.rb index c69641d..473c631 100644 --- a/lib/smily_cli/commands/config_command.rb +++ b/lib/smily_cli/commands/config_command.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "../base_command" - module SmilyCli module Commands # Manage the on-disk config file and its profiles. Secrets are masked in any diff --git a/lib/smily_cli/commands/mcp_command.rb b/lib/smily_cli/commands/mcp_command.rb index 11d3361..49d42b7 100644 --- a/lib/smily_cli/commands/mcp_command.rb +++ b/lib/smily_cli/commands/mcp_command.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative "../base_command" -require_relative "../cli_options" -require_relative "../mcp/client" - module SmilyCli module Commands # `smily mcp` — talk to a BookingSync MCP server (Model Context Protocol) @@ -64,8 +60,16 @@ def resources 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=v", desc: "Exact-match attribute filters" + 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" @@ -73,7 +77,7 @@ def resources method_option :updated_since, type: :string, desc: "ISO8601 timestamp filter" def list(resource) args = { "resource" => resource } - args["filter"] = QueryOptions.parse_pairs(options["filter"]) if options["filter"] + 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"] diff --git a/lib/smily_cli/query_options.rb b/lib/smily_cli/query_options.rb index 291e4e7..6b3e83a 100644 --- a/lib/smily_cli/query_options.rb +++ b/lib/smily_cli/query_options.rb @@ -6,6 +6,11 @@ module SmilyCli # 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 @@ -25,6 +30,37 @@ def parse_pairs(pairs) 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 @@ -52,6 +88,71 @@ def split_pair(pair) [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? diff --git a/lib/smily_cli/registry.rb b/lib/smily_cli/registry.rb index f927c37..bd3ff31 100644 --- a/lib/smily_cli/registry.rb +++ b/lib/smily_cli/registry.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "resource" - module SmilyCli # The catalog of BookingSync API v3 resources the CLI exposes as first-class # commands. Sourced from the public API reference @@ -19,9 +17,8 @@ module Registry ["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", "Discount codes.", { path: "booking/discount_codes" }], - ["booking_discount_code_usages", "Bookings", "Discount code applications.", - { path: "booking/discount_code_usages" }], + ["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."], diff --git a/lib/smily_cli/resource_command.rb b/lib/smily_cli/resource_command.rb index faf3562..7eaf186 100644 --- a/lib/smily_cli/resource_command.rb +++ b/lib/smily_cli/resource_command.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "base_command" -require_relative "cli_options" - module SmilyCli # The set of subcommands every registered resource gets: `list`, `get`, and # (unless the resource is read-only) `create`, `update`, `delete`. One bound diff --git a/smily_cli.gemspec b/smily_cli.gemspec index 1cf87f4..c49baac 100644 --- a/smily_cli.gemspec +++ b/smily_cli.gemspec @@ -41,6 +41,8 @@ Gem::Specification.new do |spec| # 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" # Formerly-default gems that are only bundled from Ruby 3.4 onward. `csv` is # used directly for CSV output; `base64` is required transitively by 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/mcp/command_spec.rb b/spec/smily_cli/mcp/command_spec.rb index 76d2344..8a68a47 100644 --- a/spec/smily_cli/mcp/command_spec.rb +++ b/spec/smily_cli/mcp/command_spec.rb @@ -53,6 +53,30 @@ def rpc_ok(result, session = nil) 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" => [ diff --git a/spec/smily_cli/query_options_spec.rb b/spec/smily_cli/query_options_spec.rb index 282b74a..c712dd7 100644 --- a/spec/smily_cli/query_options_spec.rb +++ b/spec/smily_cli/query_options_spec.rb @@ -22,6 +22,53 @@ 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( diff --git a/spec/smily_cli/registry_spec.rb b/spec/smily_cli/registry_spec.rb index fd9ffce..8eb366e 100644 --- a/spec/smily_cli/registry_spec.rb +++ b/spec/smily_cli/registry_spec.rb @@ -9,11 +9,17 @@ expect(rentals).to be_writable end - it "maps slash paths while keeping an underscore command" do + 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("discount_codes") - expect(codes.member_path(7)).to eq("booking/discount_codes/7") + 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 diff --git a/spec/smily_cli_spec.rb b/spec/smily_cli_spec.rb index ba8cb59..2114a64 100644 --- a/spec/smily_cli_spec.rb +++ b/spec/smily_cli_spec.rb @@ -9,4 +9,9 @@ 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 efe4854..b80abef 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,13 +1,27 @@ # frozen_string_literal: true require "smily_cli" -require "smily_cli/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 From 3192405d97bb055483872ce307e80578df0df2d9 Mon Sep 17 00:00:00 2001 From: Karol Galanciak Date: Thu, 9 Jul 2026 19:26:06 +0200 Subject: [PATCH 18/18] Harden token output, config parsing, pagination warning and argv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth: only dump the full token payload (incl. refresh_token) on an explicitly requested `-o json` / SMILY_OUTPUT=json, not the resolved format — non-TTY defaults to json, which leaked the refresh token when piped/captured. Default stays access-token-only. - client: warn "results may be truncated" only when a further page is actually being dropped, not when a collection ends exactly on --max-pages. - config: rescue any Psych::Exception (e.g. a bare date -> DisallowedClass) as a clean ConfigurationError instead of an uncaught YAML backtrace. - cli: a forgotten value for a leading global flag (`smily --token rentals list`) now raises a clear "Missing value" error instead of swallowing the subcommand; use --flag=value when a value collides with a command name. - Regression specs for all four; CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 ++++++++ lib/smily_cli/cli.rb | 38 +++++++++++++++++++- lib/smily_cli/client.rb | 9 +++-- lib/smily_cli/commands/auth_command.rb | 9 ++++- lib/smily_cli/config.rb | 7 ++++ spec/smily_cli/cli_spec.rb | 19 ++++++++++ spec/smily_cli/client_spec.rb | 17 +++++++++ spec/smily_cli/commands/auth_command_spec.rb | 10 ++++++ spec/smily_cli/config_spec.rb | 10 ++++++ 9 files changed, 129 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ab74a..4605084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ `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. ## [0.1.0] diff --git a/lib/smily_cli/cli.rb b/lib/smily_cli/cli.rb index 935c6b5..54b030b 100644 --- a/lib/smily_cli/cli.rb +++ b/lib/smily_cli/cli.rb @@ -24,14 +24,33 @@ class CLI < BaseCommand # 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 @@ -42,7 +61,15 @@ def self.normalize_argv(argv) name = token.split("=", 2).first if HOISTABLE_VALUE_FLAGS.include?(name) leading << rest.shift - leading << rest.shift if !token.include?("=") && !rest.empty? + 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 @@ -52,6 +79,15 @@ def self.normalize_argv(argv) 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 diff --git a/lib/smily_cli/client.rb b/lib/smily_cli/client.rb index 96ef0f7..fe44b0e 100644 --- a/lib/smily_cli/client.rb +++ b/lib/smily_cli/client.rb @@ -205,14 +205,17 @@ def follow_pages(response, records, limit:) 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 - nxt = current&.relations&.[](:next) - break unless nxt - current = fetch_next(nxt) pages += 1 page_result = Result.from_response(current) diff --git a/lib/smily_cli/commands/auth_command.rb b/lib/smily_cli/commands/auth_command.rb index 1a5a928..b5c9550 100644 --- a/lib/smily_cli/commands/auth_command.rb +++ b/lib/smily_cli/commands/auth_command.rb @@ -115,8 +115,15 @@ def token 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.output_format == "json" + if context.requested_output_format == "json" emit(JSON.pretty_generate(token.raw)) else emit(token.access_token) diff --git a/lib/smily_cli/config.rb b/lib/smily_cli/config.rb index 3f94ed9..e9ac4a7 100644 --- a/lib/smily_cli/config.rb +++ b/lib/smily_cli/config.rb @@ -151,6 +151,13 @@ def read 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 diff --git a/spec/smily_cli/cli_spec.rb b/spec/smily_cli/cli_spec.rb index a4d52e9..e3cba9e 100644 --- a/spec/smily_cli/cli_spec.rb +++ b/spec/smily_cli/cli_spec.rb @@ -19,6 +19,25 @@ 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 }]) diff --git a/spec/smily_cli/client_spec.rb b/spec/smily_cli/client_spec.rb index 7461ccb..9f760d3 100644 --- a/spec/smily_cli/client_spec.rb +++ b/spec/smily_cli/client_spec.rb @@ -76,6 +76,23 @@ def body_for(hash) 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") diff --git a/spec/smily_cli/commands/auth_command_spec.rb b/spec/smily_cli/commands/auth_command_spec.rb index 5af4a53..25f5502 100644 --- a/spec/smily_cli/commands/auth_command_spec.rb +++ b/spec/smily_cli/commands/auth_command_spec.rb @@ -34,6 +34,16 @@ def token_body(overrides = {}) 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) diff --git a/spec/smily_cli/config_spec.rb b/spec/smily_cli/config_spec.rb index 6b54f11..6ad166d 100644 --- a/spec/smily_cli/config_spec.rb +++ b/spec/smily_cli/config_spec.rb @@ -71,6 +71,16 @@ .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"))