whoosh 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3029bc3666ce5b81096d8248de52ac25f31e6cc11e271ad954058d9241bf48fb
4
- data.tar.gz: 4a62476bf115c24635c2833ddf0814ec49de5b7e036a138e37f197ef8fb51905
3
+ metadata.gz: 7276060a2fd0b8ef3edc620c087767b92de6684e6ef54a130f5c3b45f5468a52
4
+ data.tar.gz: 98f9f219bbc2e01af9ba63803a525d100fcd70adf397248065db796b28319ae4
5
5
  SHA512:
6
- metadata.gz: 1152664925e99cce8c668a87aa0e9e131bb48a59b4cb967569fa76224f4ae50ab00aa9658231eb9312ea640993cfb46f904bf9248200bf40a5f65df282a7ac71
7
- data.tar.gz: 8c32d13d07cdfe9fdf8ff5ab201ba72632c054ee9ef60540a06f00dd4d0303755ddb7c22b0e6fad702003e0c3b088c69c062d606e1dee947f08f2330faf74285
6
+ metadata.gz: a12f5a8f431ff694382192e046072596beeaced996cf47e19633ea3974ebd7bae17bc079118b28cc08b019282219d0ff4cf263291eed418a38d2f32a4021d38a
7
+ data.tar.gz: 76c4e804ea925a037ecd60fcc02834800fb0d2ec000d9fdfb48a7a142625c32e37dd3f8bdbf56bae934f1a6ee46a3731a1f0db06cddac660630c4f14bdccd579
data/README.md CHANGED
@@ -5,27 +5,38 @@
5
5
  <h1 align="center">Whoosh</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>AI-first Ruby API framework inspired by FastAPI</strong><br>
9
- Schema validation, MCP, streaming, background jobs, and OpenAPI docs out of the box.
8
+ <strong>The fastest way to ship a production MCP server in Ruby.</strong><br>
9
+ A FastAPI-style framework with MCP, schema validation, auth, streaming, and OpenAPI — built in.
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
13
  <img src="https://img.shields.io/badge/ruby-%3E%3D%203.4.0-red" alt="Ruby">
14
14
  <img src="https://img.shields.io/badge/rack-3.0-blue" alt="Rack">
15
15
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
16
- <img src="https://img.shields.io/badge/tests-564%20passing-brightgreen" alt="Tests">
17
- <img src="https://img.shields.io/badge/overhead-2.5%C2%B5s-orange" alt="Performance">
16
+ <img src="https://img.shields.io/badge/tests-659%20passing-brightgreen" alt="Tests">
17
+ <img src="https://img.shields.io/badge/stability-evolving-yellow" alt="Stability">
18
18
  </p>
19
19
 
20
20
  ---
21
21
 
22
22
  ## Why Whoosh?
23
23
 
24
- - **AI-first** — Every app is an MCP server automatically. LLM wrapper with structured output, caching, and streaming. Vector search built-in. 18+ AI gem auto-discovery.
25
- - **Fast** — 2.5µs framework overhead, 87K req/s with Falcon. Beats Fastify on multi-worker PostgreSQL benchmarks.
26
- - **Batteries included** — Auth, rate limiting, caching, background jobs, file uploads, vector search, pagination, metrics, CI pipeline.
27
- - **Zero config to start** — `whoosh new myapp && cd myapp && whoosh s` everything works.
28
- - **AI-agent friendly** — `whoosh describe` dumps your app as JSON for AI tools. Generated `CLAUDE.md` in every project. `whoosh check` catches mistakes before runtime.
24
+ - **MCP-native** — opt routes into MCP with `mcp: true` and they become typed tools over stdio/SSE. No glue code, no separate server.
25
+ - **FastAPI-style DSL in Ruby** — declarative schemas, typed request/response, auto-generated OpenAPI + Swagger UI, dependency injection.
26
+ - **Batteries included** — auth (JWT, API key, OAuth), rate limiting, caching, background jobs, file uploads, vector search, streaming, pagination.
27
+ - **Agent-friendly** — `whoosh describe` emits a JSON snapshot of your app; generated `CLAUDE.md` so coding agents understand it; `whoosh check` validates config before runtime.
28
+ - **Competitive Ruby performance** — YJIT + Falcon fibers + Oj, ~2.5µs framework overhead. See [Performance](#performance) for honest, per-core comparisons.
29
+
30
+ ## When NOT to use Whoosh
31
+
32
+ Whoosh is on 1.x but still evolving — solo-maintained, without a production track record yet, and breaking changes ship occasionally (always called out in `CHANGELOG.md`). Reach for something else when:
33
+
34
+ - **You need a managed backend.** Supabase, PocketBase, or Firebase give you DB + auth + realtime without hosting a framework. Whoosh is the app layer — use it *with* a managed DB if that fits.
35
+ - **You want maximum ecosystem depth.** Rails has more gems; FastAPI has the Python ML/AI library ecosystem (PyTorch, transformers, LangChain). If your core workload lives in those libraries, stay where they are.
36
+ - **You need a frozen API surface.** Being on 1.x doesn't mean the API is locked — breaking changes still ship when the design calls for it. If you need strict stability contracts today, wait a few releases.
37
+ - **Your team has no Ruby experience** and the project isn't specifically about AI/MCP. Hiring and ecosystem gravity usually beat framework features.
38
+
39
+ Whoosh's sweet spot: Ruby shops (or Ruby-curious teams) building AI / LLM / MCP-backed APIs who want typed schemas, OpenAPI, and MCP without wiring three libraries together.
29
40
 
30
41
  ## Install
31
42
 
@@ -192,21 +203,24 @@ end
192
203
 
193
204
  ### MCP (Model Context Protocol)
194
205
 
195
- **Every route is automatically an MCP tool.** No `mcp: true` needed.
206
+ **Routes are exposed as MCP tools only when you opt in with `mcp: true`.** This prevents internal or admin endpoints from being callable as tools by accident.
196
207
 
197
208
  ```ruby
198
- # These are all MCP tools automatically:
199
- app.post "/summarize", request: SummarizeRequest do |req|
209
+ # Opt in per route:
210
+ app.post "/summarize", request: SummarizeRequest, mcp: true do |req|
200
211
  { summary: llm.summarize(req.body[:text]) }
201
212
  end
202
213
 
203
- app.post "/translate" do |req|
204
- { result: translate(req.body["text"]) }
214
+ # Or opt in a whole group:
215
+ app.group "/tools", mcp: true do
216
+ post "/translate" do |req|
217
+ { result: translate(req.body["text"]) }
218
+ end
205
219
  end
206
220
 
207
- # Opt OUT with mcp: false for internal routes:
208
- app.get "/internal", mcp: false do
209
- { debug: "not exposed as MCP tool" }
221
+ # Default: not exposed as an MCP tool.
222
+ app.get "/internal" do
223
+ { debug: "not exposed" }
210
224
  end
211
225
  ```
212
226
 
@@ -346,6 +360,43 @@ app.docs enabled: true, redoc: true
346
360
  - `/redoc` — ReDoc
347
361
  - `/openapi.json` — Machine-readable spec
348
362
 
363
+ ### Client Generator
364
+
365
+ Generate complete, typed, ready-to-run client apps from your Whoosh API — one command.
366
+
367
+ ```sh
368
+ whoosh generate client react_spa # React + Vite + TypeScript
369
+ whoosh generate client expo # Expo + React Native
370
+ whoosh generate client ios # SwiftUI + MVVM
371
+ whoosh generate client flutter # Dart + Riverpod + GoRouter
372
+ whoosh generate client htmx # Plain HTML + htmx, no build step
373
+ whoosh generate client telegram_bot # Ruby Telegram bot
374
+ whoosh generate client telegram_mini_app # React + Telegram WebApp SDK
375
+
376
+ whoosh generate client react_spa --oauth # Add Google/GitHub/Apple login
377
+ ```
378
+
379
+ The generator **introspects your Whoosh app** via OpenAPI — it reads your routes, schemas, and auth config, then produces a typed client with:
380
+
381
+ - API client with auth headers and automatic token refresh
382
+ - Model types matching your schemas
383
+ - Auth screens (login, register, logout)
384
+ - CRUD screens for every resource
385
+ - Navigation and routing
386
+ - Starter tests
387
+
388
+ If no Whoosh app exists yet, it scaffolds a standard backend (JWT auth + tasks CRUD) alongside the client.
389
+
390
+ | Client | Stack | Token Storage |
391
+ |--------|-------|---------------|
392
+ | `react_spa` | React 19, Vite, TypeScript, React Router | localStorage |
393
+ | `expo` | Expo SDK 52, Expo Router, TypeScript | SecureStore |
394
+ | `ios` | SwiftUI, async/await, MVVM | Keychain |
395
+ | `flutter` | Dart, Dio, Riverpod, GoRouter | flutter_secure_storage |
396
+ | `htmx` | HTML, htmx 2.x, vanilla JS | localStorage |
397
+ | `telegram_bot` | Ruby, telegram-bot-ruby | In-memory session |
398
+ | `telegram_mini_app` | React, Telegram WebApp SDK | Telegram initData |
399
+
349
400
  ### Health Checks
350
401
 
351
402
  ```ruby
@@ -377,6 +428,8 @@ whoosh generate model User name:string email:string
377
428
  whoosh generate migration add_email_to_users
378
429
  whoosh generate plugin my_tool # plugin boilerplate
379
430
  whoosh generate proto ChatRequest # .proto file
431
+ whoosh generate client react_spa # full client app (7 types)
432
+ whoosh generate client expo --oauth # with OAuth2 social login
380
433
 
381
434
  whoosh db migrate # run migrations
382
435
  whoosh db rollback # rollback
@@ -401,55 +454,56 @@ whoosh check # validates config, auth, dependencies
401
454
 
402
455
  ## Performance
403
456
 
404
- ### HTTP Benchmark: `GET /health {"status":"ok"}`
457
+ > Apple Silicon arm64, 12 cores. Ruby 3.4 + YJIT. [Full benchmark suite & reproduction steps](benchmarks/comparison/)
458
+ >
459
+ > **How to read these numbers.** Benchmarks are selective by nature. A `GET /health` returning `{"status":"ok"}` tests the router + serializer, not your real app. A Postgres read tests one query pattern. We show single-process (per-core) numbers first because that's the fair cross-language comparison. Multi-worker numbers are included for deployment sizing, but scaling strategies differ per runtime (Node uses `cluster`, Python uses multiple workers, Ruby uses workers × threads or fibers) and mixing them isn't apples-to-apples.
405
460
 
406
- > Apple Silicon arm64, 12 cores. [Full benchmark suite](benchmarks/comparison/)
461
+ ### HTTP micro-benchmark `GET /health`
407
462
 
408
- **Single process** (fair 1:1 comparison):
463
+ **Single process (per-core, fair comparison):**
409
464
 
410
465
  | Framework | Language | Server | Req/sec |
411
466
  |-----------|----------|--------|---------|
412
467
  | Fastify | Node.js 22 | built-in | 69,200 |
413
- | **Whoosh** | Ruby 3.4 +YJIT | **Falcon** | **24,400** |
468
+ | **Whoosh** | Ruby 3.4 +YJIT | Falcon | **24,400** |
414
469
  | **Whoosh** | Ruby 3.4 +YJIT | Puma (5 threads) | **15,500** |
415
470
  | FastAPI | Python 3.13 | uvicorn | 8,900 |
416
471
  | Sinatra | Ruby 3.4 | Puma (5 threads) | 7,100 |
417
- | PHP (raw) | PHP 8.5 | built-in | 2,000 |
418
472
 
419
- > Whoosh + Falcon is **2.7x faster** than FastAPI single-core. Whoosh + Puma is **1.7x faster** than FastAPI. Use Falcon (recommended) for best performance.
473
+ On this microbenchmark, Fastify is ~2. Whoosh+Falcon per-core; that's the honest picture for trivial JSON. Against other Ruby frameworks and against FastAPI on CPython, Whoosh is competitive.
420
474
 
421
- **Multi-worker** (production deployment):
475
+ **Multi-worker (sizing reference, not apples-to-apples):**
422
476
 
423
- | Framework | Language | Server | Req/sec |
424
- |-----------|----------|--------|---------|
425
- | **Whoosh** | Ruby 3.4 +YJIT | **Falcon (4 workers)** | **87,400** |
426
- | Fastify | Node.js 22 | built-in (single thread) | 69,200 |
427
- | **Whoosh** | Ruby 3.4 +YJIT | Puma (4w×4t) | **52,500** |
428
- | Roda | Ruby 3.4 | Puma (4w×4t) | 14,700 |
477
+ | Framework | Server | Req/sec |
478
+ |-----------|--------|---------|
479
+ | Whoosh | Falcon (4 workers) | 87,400 |
480
+ | Fastify | built-in (single thread, no cluster) | 69,200 |
481
+ | Whoosh | Puma (4w × 4t) | 52,500 |
482
+ | Roda | Puma (4w × 4t) | 14,700 |
429
483
 
430
- > **Note:** Fastify is single-threaded by design (Node.js event loop). It can scale via `cluster` module but was not tested in that mode. Whoosh + Falcon with 4 workers uses 4 cores.
484
+ Fastify was not run under `cluster`; don't read this table as "Whoosh beats Fastify." Read it as "Whoosh on 4 cores handles ~87K req/s on trivial JSON."
431
485
 
432
- ### Real-World Benchmark: `GET /users/:id` from PostgreSQL (1000 rows)
486
+ ### Real-world benchmark `GET /users/:id` from PostgreSQL (1000-row table)
433
487
 
434
- **Single process:**
488
+ **Single process (per-core):**
435
489
 
436
- | Framework | Language | Req/sec |
437
- |-----------|----------|---------|
438
- | Fastify + pg | Node.js 22 | 36,900 |
439
- | **Whoosh + Falcon (fiber PG pool)** | Ruby 3.4 +YJIT | **13,400** |
440
- | **Whoosh + Puma (Sequel)** | Ruby 3.4 +YJIT | **8,600** |
441
- | Roda + Puma | Ruby 3.4 | 6,700 |
442
- | Sinatra + Puma | Ruby 3.4 | 4,400 |
443
- | FastAPI + uvicorn | Python 3.13 | 2,400 |
490
+ | Framework | Req/sec |
491
+ |-----------|---------|
492
+ | Fastify + pg | 36,900 |
493
+ | **Whoosh + Falcon (fiber PG pool)** | **13,400** |
494
+ | Whoosh + Puma (Sequel) | 8,600 |
495
+ | Roda + Puma | 6,700 |
496
+ | Sinatra + Puma | 4,400 |
497
+ | FastAPI + uvicorn | 2,400 |
444
498
 
445
- **Multi-worker (PostgreSQL):**
499
+ On realistic DB-bound work, Whoosh's fiber-aware PG pool closes a lot of the gap vs Fastify (~2.75×) and has a wide lead over FastAPI on CPython and over other Ruby frameworks.
446
500
 
447
- | Framework | Language | Req/sec |
448
- |-----------|----------|---------|
449
- | **Whoosh + Falcon (4 workers, fiber PG pool)** | Ruby 3.4 +YJIT | **45,900** |
450
- | Fastify (single thread) | Node.js 22 | 36,900 |
501
+ **Multi-worker (sizing reference):**
451
502
 
452
- > Whoosh + Falcon with fiber-aware PG pool is **5.6x faster** than FastAPI. Multi-worker Falcon **beats Fastify by 24%** on real PostgreSQL workloads.
503
+ | Framework | Req/sec |
504
+ |-----------|---------|
505
+ | Whoosh + Falcon (4 workers, fiber PG pool) | 45,900 |
506
+ | Fastify (single thread) | 36,900 |
453
507
 
454
508
  ### Micro-benchmarks
455
509
 
data/lib/whoosh/app.rb CHANGED
@@ -493,8 +493,9 @@ module Whoosh
493
493
  internal_paths = %w[/openapi.json /docs /redoc /metrics /healthz]
494
494
 
495
495
  @router.routes.each do |route|
496
- # Auto-expose all routes as MCP tools (opt-out with mcp: false)
497
- next if route[:metadata] && route[:metadata][:mcp] == false
496
+ # Routes are exposed as MCP tools only when opted in with mcp: true.
497
+ # Why: default-expose leaks internal/admin endpoints as callable tools.
498
+ next unless route[:metadata] && route[:metadata][:mcp] == true
498
499
  next if internal_paths.include?(route[:path])
499
500
 
500
501
  tool_name = "#{route[:method]} #{route[:path]}"
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "whoosh/client_gen/ir"
4
+ require "whoosh/client_gen/introspector"
5
+ require "whoosh/client_gen/base_generator"
6
+ require "whoosh/client_gen/dependency_checker"
7
+ require "whoosh/client_gen/fallback_backend"
8
+
9
+ module Whoosh
10
+ module ClientGen
11
+ class Error < StandardError; end
12
+ end
13
+
14
+ module CLI
15
+ class ClientGenerator
16
+ CLIENT_TYPES = %i[react_spa expo ios flutter htmx telegram_bot telegram_mini_app].freeze
17
+
18
+ attr_reader :type, :oauth, :output_dir
19
+
20
+ def self.client_types
21
+ CLIENT_TYPES
22
+ end
23
+
24
+ def initialize(type:, oauth:, dir:, root: Dir.pwd)
25
+ @type = type.to_sym
26
+ @oauth = oauth
27
+ @output_dir = dir || default_output_dir
28
+ @root = root
29
+ end
30
+
31
+ def run
32
+ validate!
33
+ check_dependencies!
34
+ result = introspect_or_fallback
35
+
36
+ case result[:mode]
37
+ when :introspected
38
+ display_found(result[:ir])
39
+ ir = confirm_selection(result[:ir])
40
+ generate_client(ir)
41
+ when :fallback
42
+ display_fallback_prompt
43
+ generate_fallback_backend
44
+ ir = build_fallback_ir
45
+ generate_client(ir)
46
+ end
47
+
48
+ display_success
49
+ end
50
+
51
+ def validate!
52
+ unless CLIENT_TYPES.include?(@type)
53
+ raise ClientGen::Error, "Unknown client type: #{@type}. Supported: #{CLIENT_TYPES.join(", ")}"
54
+ end
55
+ end
56
+
57
+ def default_output_dir
58
+ "clients/#{@type}"
59
+ end
60
+
61
+ def check_dependencies!
62
+ result = ClientGen::DependencyChecker.check(@type)
63
+ return if result[:ok]
64
+
65
+ puts "\n⚠️ Missing dependencies for #{@type}:"
66
+ result[:missing].each do |dep|
67
+ msg = " - #{dep[:cmd]} (check: #{dep[:check]})"
68
+ msg += " — found v#{dep[:found_version]}, need v#{dep[:min_version]}+" if dep[:found_version]
69
+ puts msg
70
+ end
71
+ puts "\nInstall the missing dependencies and try again."
72
+ exit 1
73
+ end
74
+
75
+ def introspect_or_fallback
76
+ app = load_app
77
+ if app
78
+ introspector = ClientGen::Introspector.new(app, base_url: detect_base_url(app))
79
+ ir = introspector.introspect
80
+ if ir.has_resources? || ir.has_auth?
81
+ { mode: :introspected, ir: ir }
82
+ else
83
+ { mode: :fallback }
84
+ end
85
+ else
86
+ { mode: :fallback }
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def load_app
93
+ app_file = File.join(@root, "app.rb")
94
+ return nil unless File.exist?(app_file)
95
+
96
+ require app_file
97
+ ObjectSpace.each_object(Whoosh::App).first
98
+ rescue => e
99
+ puts "⚠️ Failed to load app: #{e.message}"
100
+ puts "Run `whoosh check` to debug."
101
+ nil
102
+ end
103
+
104
+ def detect_base_url(app)
105
+ config = app.instance_variable_get(:@config)
106
+ port = config&.respond_to?(:port) ? config.port : 9292
107
+ host = config&.respond_to?(:host) ? config.host : "localhost"
108
+ "http://#{host}:#{port}"
109
+ end
110
+
111
+ def display_found(ir)
112
+ puts "\n🔍 Inspecting Whoosh app...\n\n"
113
+ puts "Found:"
114
+ puts " Auth: #{ir.auth&.type || "none"}"
115
+ ir.resources.each do |r|
116
+ puts " Resource: #{r.name} (#{r.endpoints.length} endpoints)"
117
+ end
118
+ ir.streaming.each do |s|
119
+ puts " Streaming: #{s[:type]} on #{s[:path]}"
120
+ end
121
+ puts
122
+ end
123
+
124
+ def confirm_selection(ir)
125
+ ir
126
+ end
127
+
128
+ def display_fallback_prompt
129
+ puts "\n⚠️ No Whoosh app found (or no routes defined).\n\n"
130
+ puts "Generating standard starter with:"
131
+ puts " - JWT auth (email/password login + register)"
132
+ puts " - Tasks CRUD (title, description, status, due_date)"
133
+ puts " - Matching backend endpoints"
134
+ if @oauth
135
+ puts " - OAuth2 (Google, GitHub, Apple)"
136
+ end
137
+ puts
138
+ end
139
+
140
+ def generate_fallback_backend
141
+ ClientGen::FallbackBackend.generate(root: @root, oauth: @oauth)
142
+ puts "✅ Backend endpoints generated"
143
+ end
144
+
145
+ def build_fallback_ir
146
+ ClientGen::IR::AppSpec.new(
147
+ auth: ClientGen::IR::Auth.new(
148
+ type: :jwt,
149
+ endpoints: {
150
+ login: { method: :post, path: "/auth/login" },
151
+ register: { method: :post, path: "/auth/register" },
152
+ refresh: { method: :post, path: "/auth/refresh" },
153
+ logout: { method: :delete, path: "/auth/logout" },
154
+ me: { method: :get, path: "/auth/me" }
155
+ },
156
+ oauth_providers: @oauth ? %i[google github apple] : []
157
+ ),
158
+ resources: [
159
+ ClientGen::IR::Resource.new(
160
+ name: :tasks,
161
+ endpoints: [
162
+ ClientGen::IR::Endpoint.new(method: :get, path: "/tasks", action: :index, pagination: true),
163
+ ClientGen::IR::Endpoint.new(method: :get, path: "/tasks/:id", action: :show),
164
+ ClientGen::IR::Endpoint.new(method: :post, path: "/tasks", action: :create),
165
+ ClientGen::IR::Endpoint.new(method: :put, path: "/tasks/:id", action: :update),
166
+ ClientGen::IR::Endpoint.new(method: :delete, path: "/tasks/:id", action: :destroy)
167
+ ],
168
+ fields: [
169
+ { name: :title, type: :string, required: true },
170
+ { name: :description, type: :string, required: false },
171
+ { name: :status, type: :string, required: false, enum: %w[pending in_progress done], default: "pending" },
172
+ { name: :due_date, type: :string, required: false }
173
+ ]
174
+ )
175
+ ],
176
+ streaming: [],
177
+ base_url: "http://localhost:9292"
178
+ )
179
+ end
180
+
181
+ def generate_client(ir)
182
+ generator_class = load_generator_class
183
+ output = File.join(@root, @output_dir)
184
+
185
+ if Dir.exist?(output) && !Dir.empty?(output)
186
+ puts "⚠️ #{@output_dir}/ already exists."
187
+ print "Overwrite? [y/N] "
188
+ answer = $stdin.gets&.strip&.downcase
189
+ unless answer == "y"
190
+ puts "Aborted."
191
+ exit 0
192
+ end
193
+ FileUtils.rm_rf(output)
194
+ end
195
+
196
+ generator_class.new(ir: ir, output_dir: output, platform: platform_for_type).generate
197
+ end
198
+
199
+ def load_generator_class
200
+ require "whoosh/client_gen/generators/#{@type}"
201
+ Whoosh::ClientGen::Generators.const_get(camelize(@type.to_s))
202
+ end
203
+
204
+ def platform_for_type
205
+ case @type
206
+ when :react_spa, :expo, :telegram_mini_app then :typescript
207
+ when :ios then :swift
208
+ when :flutter then :dart
209
+ when :htmx then :html
210
+ when :telegram_bot then :ruby
211
+ end
212
+ end
213
+
214
+ def camelize(str)
215
+ str.split("_").map(&:capitalize).join
216
+ end
217
+
218
+ def display_success
219
+ puts "\n✅ Generated #{@type} client in #{@output_dir}/"
220
+ case @type
221
+ when :react_spa, :telegram_mini_app
222
+ puts " Run: cd #{@output_dir} && npm install && npm run dev"
223
+ when :expo
224
+ puts " Run: cd #{@output_dir} && npm install && npx expo start"
225
+ when :ios
226
+ puts " Run: open #{@output_dir}/WhooshApp.xcodeproj"
227
+ when :flutter
228
+ puts " Run: cd #{@output_dir} && flutter pub get && flutter run"
229
+ when :htmx
230
+ puts " Run: cd #{@output_dir} && open index.html (or any static server)"
231
+ when :telegram_bot
232
+ puts " Run: cd #{@output_dir} && bundle install && ruby bot.rb"
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -600,6 +600,16 @@ module Whoosh
600
600
  require "whoosh/cli/generators"
601
601
  Whoosh::CLI::Generators.proto(name)
602
602
  end
603
+
604
+ desc "client TYPE", "Generate a client app (react_spa, expo, ios, flutter, htmx, telegram_bot, telegram_mini_app)"
605
+ option :oauth, type: :boolean, default: false, desc: "Include OAuth2 social login"
606
+ option :dir, type: :string, desc: "Output directory (default: clients/<type>)"
607
+ def client(type)
608
+ require "whoosh/cli/client_generator"
609
+ Whoosh::CLI::ClientGenerator.new(
610
+ type: type, oauth: options[:oauth], dir: options[:dir]
611
+ ).run
612
+ end
603
613
  }
604
614
  end
605
615
  end
@@ -0,0 +1,84 @@
1
+ # lib/whoosh/client_gen/base_generator.rb
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "whoosh/client_gen/ir"
6
+
7
+ module Whoosh
8
+ module ClientGen
9
+ class BaseGenerator
10
+ TYPE_MAPS = {
11
+ typescript: {
12
+ string: "string", integer: "number", number: "number",
13
+ boolean: "boolean", array: "any[]", object: "Record<string, any>"
14
+ },
15
+ swift: {
16
+ string: "String", integer: "Int", number: "Double",
17
+ boolean: "Bool", array: "[Any]", object: "[String: Any]"
18
+ },
19
+ dart: {
20
+ string: "String", integer: "int", number: "double",
21
+ boolean: "bool", array: "List<dynamic>", object: "Map<String, dynamic>"
22
+ },
23
+ ruby: {
24
+ string: "String", integer: "Integer", number: "Float",
25
+ boolean: "Boolean", array: "Array", object: "Hash"
26
+ },
27
+ html: {
28
+ string: "text", integer: "number", number: "number",
29
+ boolean: "checkbox", array: "text", object: "text"
30
+ }
31
+ }.freeze
32
+
33
+ attr_reader :ir, :output_dir, :platform
34
+
35
+ def initialize(ir:, output_dir:, platform:)
36
+ @ir = ir
37
+ @output_dir = output_dir
38
+ @platform = platform
39
+ end
40
+
41
+ def generate
42
+ raise NotImplementedError, "Subclasses must implement #generate"
43
+ end
44
+
45
+ def type_for(ir_type)
46
+ TYPE_MAPS.dig(@platform, ir_type.to_sym) || "string"
47
+ end
48
+
49
+ def write_file(relative_path, content)
50
+ full_path = File.join(@output_dir, relative_path)
51
+ FileUtils.mkdir_p(File.dirname(full_path))
52
+ File.write(full_path, content)
53
+ end
54
+
55
+ def classify(name)
56
+ singular = singularize(name.to_s)
57
+ singular.split(/[-_]/).map(&:capitalize).join
58
+ end
59
+
60
+ def singularize(word)
61
+ w = word.to_s
62
+ if w.end_with?("ies")
63
+ w[0..-4] + "y"
64
+ elsif w.end_with?("ses") || w.end_with?("xes") || w.end_with?("zes") || w.end_with?("ches") || w.end_with?("shes")
65
+ w[0..-3]
66
+ elsif w.end_with?("sses")
67
+ w[0..-3]
68
+ elsif w.end_with?("s") && !w.end_with?("ss") && !w.end_with?("us")
69
+ w[0..-2]
70
+ else
71
+ w
72
+ end
73
+ end
74
+
75
+ def camelize(name)
76
+ name.to_s.split(/[-_]/).map(&:capitalize).join
77
+ end
78
+
79
+ def snake_case(name)
80
+ name.to_s.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,49 @@
1
+ # lib/whoosh/client_gen/dependency_checker.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Whoosh
5
+ module ClientGen
6
+ class DependencyChecker
7
+ DEPENDENCIES = {
8
+ react_spa: [{ cmd: "node", check: "node --version", min_version: "18" }],
9
+ expo: [
10
+ { cmd: "node", check: "node --version", min_version: "18" },
11
+ { cmd: "npx", check: "npx expo --version", min_version: nil }
12
+ ],
13
+ ios: [{ cmd: "xcodebuild", check: "xcodebuild -version", min_version: "15" }],
14
+ flutter: [{ cmd: "flutter", check: "flutter --version", min_version: "3" }],
15
+ htmx: [],
16
+ telegram_bot: [{ cmd: "ruby", check: "ruby --version", min_version: "3.2" }],
17
+ telegram_mini_app: [{ cmd: "node", check: "node --version", min_version: "18" }]
18
+ }.freeze
19
+
20
+ def self.check(client_type)
21
+ deps = DEPENDENCIES[client_type.to_sym] || []
22
+ return { ok: true, dependencies: [], missing: [] } if deps.empty?
23
+
24
+ missing = []
25
+ deps.each do |dep|
26
+ output = `#{dep[:check]} 2>/dev/null`.strip
27
+ if output.empty?
28
+ missing << dep
29
+ elsif dep[:min_version]
30
+ version = output.scan(/(\d+)\./)[0]&.first
31
+ if version && version.to_i < dep[:min_version].to_i
32
+ missing << dep.merge(found_version: version)
33
+ end
34
+ end
35
+ end
36
+
37
+ {
38
+ ok: missing.empty?,
39
+ dependencies: deps.map { |d| d[:cmd] },
40
+ missing: missing
41
+ }
42
+ end
43
+
44
+ def self.dependency_for(client_type)
45
+ DEPENDENCIES[client_type.to_sym] || []
46
+ end
47
+ end
48
+ end
49
+ end