fantasy-cli 1.2.10 → 1.2.11

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.
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webrick'
4
+ require 'json'
5
+ require_relative 'routes/health'
6
+ require_relative 'routes/state'
7
+ require_relative 'routes/phases'
8
+ require_relative 'routes/roadmap'
9
+ require_relative '../ai/chat'
10
+
11
+ module Gsd
12
+ module API
13
+ # Custom HTTP servlet that supports PATCH and all REST methods
14
+ class APIServlet < WEBrick::HTTPServlet::AbstractServlet
15
+ def initialize(server, *options)
16
+ super(server)
17
+ @cwd = options.is_a?(Array) && options[0].is_a?(Hash) ? options[0][:cwd] : Dir.pwd
18
+ end
19
+
20
+ def do_GET(req, res)
21
+ route(req, res)
22
+ end
23
+
24
+ def do_POST(req, res)
25
+ route(req, res)
26
+ end
27
+
28
+ def do_PUT(req, res)
29
+ route(req, res)
30
+ end
31
+
32
+ def do_PATCH(req, res)
33
+ route(req, res)
34
+ end
35
+
36
+ def do_OPTIONS(req, res)
37
+ set_cors_headers(res)
38
+ res.status = 204
39
+ res.body = ''
40
+ end
41
+
42
+ def do_DELETE(req, res)
43
+ set_cors_headers(res)
44
+ res.status = 405
45
+ res.body = JSON.generate({ error: 'Method not allowed' })
46
+ end
47
+
48
+ private
49
+
50
+ def route(req, res)
51
+ set_cors_headers(res)
52
+
53
+ path = req.path_info
54
+ method = req.request_method
55
+
56
+ case path
57
+ when '/health', '/health/'
58
+ handle(req, res) { Routes::Health.check(cwd: @cwd) }
59
+
60
+ when '/state', '/state/'
61
+ route_state(method, req, res)
62
+
63
+ when '/phases', '/phases/'
64
+ route_phases(method, req, res)
65
+
66
+ when '/roadmap', '/roadmap/'
67
+ handle(req, res) do
68
+ if Gsd::Roadmap.exists?(cwd: @cwd)
69
+ Routes::Roadmap.analyze(@cwd)
70
+ else
71
+ JSON.pretty_generate({
72
+ success: true,
73
+ data: {
74
+ message: 'No ROADMAP.md found in this directory',
75
+ phases: [],
76
+ total_phases: 0
77
+ }
78
+ })
79
+ end
80
+ end
81
+
82
+ when '/ai/chat', '/ai/chat/'
83
+ route_ai_chat(method, req, res)
84
+
85
+ when '/ai/models', '/ai/models/'
86
+ route_ai_models(method, req, res)
87
+
88
+ when '/ai/cost', '/ai/cost/'
89
+ route_ai_cost(method, req, res)
90
+
91
+ else
92
+ if path.start_with?('/state/')
93
+ section = path.sub('/state/', '')
94
+ handle(req, res) { Routes::State.get_section(section, @cwd) }
95
+ elsif path.start_with?('/phases/')
96
+ num = path.sub('/phases/', '')
97
+ handle(req, res) { Routes::Phases.find(num, @cwd) }
98
+ else
99
+ res.status = 404
100
+ res.body = JSON.generate({
101
+ error: 'Not found',
102
+ path: req.path,
103
+ available_endpoints: [
104
+ 'GET /api/health',
105
+ 'GET /api/state',
106
+ 'GET /api/state/:section',
107
+ 'PATCH /api/state',
108
+ 'PUT /api/state',
109
+ 'GET /api/phases',
110
+ 'GET /api/phases/:num',
111
+ 'POST /api/phases',
112
+ 'GET /api/roadmap',
113
+ 'POST /api/ai/chat',
114
+ 'GET /api/ai/models',
115
+ 'GET /api/ai/cost'
116
+ ]
117
+ })
118
+ end
119
+ end
120
+ end
121
+
122
+ def route_state(method, req, res)
123
+ case method
124
+ when 'GET'
125
+ handle(req, res) { Routes::State.json(@cwd) }
126
+ when 'PATCH'
127
+ handle(req, res) do
128
+ body = parse_json_body(req)
129
+ Routes::State.patch(body['fields'], @cwd)
130
+ end
131
+ when 'PUT'
132
+ handle(req, res) do
133
+ body = parse_json_body(req)
134
+ Routes::State.update(body['field'], body['value'], @cwd)
135
+ end
136
+ else
137
+ res.status = 405
138
+ res.body = JSON.generate({ error: 'Method not allowed for /api/state' })
139
+ end
140
+ end
141
+
142
+ def route_phases(method, req, res)
143
+ case method
144
+ when 'GET'
145
+ handle(req, res) { Routes::Phases.list(@cwd) }
146
+ when 'POST'
147
+ handle(req, res) do
148
+ body = parse_json_body(req)
149
+ Routes::Phases.add(body['description'], @cwd)
150
+ end
151
+ else
152
+ res.status = 405
153
+ res.body = JSON.generate({ error: 'Method not allowed for /api/phases' })
154
+ end
155
+ end
156
+
157
+ def route_ai_chat(method, req, res)
158
+ if method == 'POST'
159
+ handle(req, res) do
160
+ body = parse_json_body(req)
161
+ message = body['message']
162
+ raise ArgumentError, "message is required" unless message && !message.to_s.strip.empty?
163
+
164
+ provider = (body['provider'] || 'anthropic').to_sym
165
+ model = body['model']
166
+
167
+ chat = Gsd::AI::Chat.new(provider: provider, model: model, cwd: @cwd)
168
+ response = chat.send(message, stream: false)
169
+
170
+ JSON.pretty_generate({
171
+ success: true,
172
+ data: { response: response }
173
+ })
174
+ end
175
+ else
176
+ res.status = 405
177
+ res.body = JSON.generate({ error: 'Method not allowed for /api/ai/chat' })
178
+ end
179
+ end
180
+
181
+ def route_ai_models(method, req, res)
182
+ if method == 'GET'
183
+ handle(req, res) do
184
+ JSON.pretty_generate({
185
+ success: true,
186
+ data: {
187
+ providers: ['anthropic', 'openai', 'openrouter', 'ollama', 'lmstudio']
188
+ }
189
+ })
190
+ end
191
+ else
192
+ res.status = 405
193
+ res.body = JSON.generate({ error: 'Method not allowed for /api/ai/models' })
194
+ end
195
+ end
196
+
197
+ def route_ai_cost(method, req, res)
198
+ if method == 'GET'
199
+ handle(req, res) do
200
+ persist_dir = File.join(@cwd, '.gsd', 'ai')
201
+ tracker = Gsd::AI::CostTracker.new(budget: 100.0, persist_dir: persist_dir)
202
+ JSON.pretty_generate({
203
+ success: true,
204
+ data: tracker.stats
205
+ })
206
+ end
207
+ else
208
+ res.status = 405
209
+ res.body = JSON.generate({ error: 'Method not allowed for /api/ai/cost' })
210
+ end
211
+ end
212
+
213
+ def handle(req, res)
214
+ body = yield
215
+ res.status = 200
216
+ res.body = body
217
+ rescue Gsd::State::StateError, Gsd::Roadmap::RoadmapError, Gsd::Phase::PhaseError => e
218
+ res.status = 404
219
+ res.body = JSON.generate({ error: e.message })
220
+ rescue ArgumentError => e
221
+ res.status = 400
222
+ res.body = JSON.generate({ error: e.message })
223
+ rescue => e
224
+ res.status = 500
225
+ res.body = JSON.generate({ error: "Internal server error: #{e.message}" })
226
+ end
227
+
228
+ def set_cors_headers(res)
229
+ res['Content-Type'] = 'application/json'
230
+ res['Access-Control-Allow-Origin'] = '*'
231
+ res['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
232
+ res['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-HTTP-Method-Override'
233
+ res['Access-Control-Max-Age'] = '86400'
234
+ end
235
+
236
+ def parse_json_body(req)
237
+ return {} unless req.body && !req.body.empty?
238
+
239
+ JSON.parse(req.body)
240
+ rescue JSON::ParserError => e
241
+ raise ArgumentError, "Invalid JSON body: #{e.message}"
242
+ end
243
+ end
244
+
245
+ # API Server - WEBrick HTTP server exposing CLI functionality via REST API
246
+ class Server
247
+ def initialize(cwd: nil)
248
+ @cwd = cwd || Dir.pwd
249
+ end
250
+
251
+ # Start the server with given host and port
252
+ #
253
+ # @param host [String] Host to bind to
254
+ # @param port [Integer] Port to listen on
255
+ def start(host: '127.0.0.1', port: 3000)
256
+ puts "🚀 Fantasy API Server starting..."
257
+ puts "📍 Listening on http://#{host}:#{port}"
258
+ puts "📂 Working directory: #{@cwd}"
259
+ puts "🌐 Dashboard available at: http://#{host}:#{port}/"
260
+ puts "🔗 Available endpoints:"
261
+ puts " GET /api/health"
262
+ puts " GET /api/state"
263
+ puts " GET /api/state/:section"
264
+ puts " PATCH /api/state"
265
+ puts " PUT /api/state"
266
+ puts " GET /api/phases"
267
+ puts " GET /api/phases/:num"
268
+ puts " POST /api/phases"
269
+ puts " GET /api/roadmap"
270
+ puts " POST /api/ai/chat"
271
+ puts " GET /api/ai/models"
272
+ puts " GET /api/ai/cost"
273
+ puts ""
274
+ puts "Press Ctrl+C to stop"
275
+
276
+ server = WEBrick::HTTPServer.new(
277
+ Port: port,
278
+ BindAddress: host,
279
+ AccessLog: [],
280
+ Logger: WEBrick::Log.new($stdout, WEBrick::Log::WARN)
281
+ )
282
+
283
+ # Mount public directory for static files (Web Dashboard)
284
+ public_dir = File.expand_path('public', __dir__)
285
+ server.mount('/', WEBrick::HTTPServlet::FileHandler, public_dir)
286
+
287
+ # Mount custom servlet to handle all API routes with full HTTP method support
288
+ server.mount('/api', APIServlet, cwd: @cwd)
289
+
290
+ # Handle graceful shutdown
291
+ trap('INT') do
292
+ puts "\n👋 Shutting down API server..."
293
+ server.shutdown
294
+ end
295
+
296
+ server.start
297
+ end
298
+ end
299
+ end
300
+ end
data/lib/gsd/api.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'api/server'
4
+ require_relative 'api/client'
5
+
6
+ module Gsd
7
+ # API module - Exposes CLI functionality via HTTP REST API
8
+ module API
9
+ class << self
10
+ # Returns an API client instance
11
+ #
12
+ # @param host [String] API server host (default: '127.0.0.1')
13
+ # @param port [Integer] API server port (default: 3000)
14
+ # @return [Gsd::API::Client]
15
+ def client(host: '127.0.0.1', port: 3000)
16
+ Gsd::API::Client.new(host: host, port: port)
17
+ end
18
+
19
+ # Starts the API server
20
+ #
21
+ # @param host [String] Host to bind to (default: '127.0.0.1')
22
+ # @param port [Integer] Port to listen on (default: 3000)
23
+ # @param cwd [String] Working directory for API operations
24
+ # @return [void]
25
+ def serve(host: '127.0.0.1', port: 3000, cwd: nil)
26
+ cwd ||= Dir.pwd
27
+ server = Gsd::API::Server.new(cwd: cwd)
28
+ server.start(host: host, port: port)
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/gsd/cli.rb CHANGED
@@ -7,13 +7,29 @@ require_relative 'phase'
7
7
  require_relative 'roadmap'
8
8
  require_relative 'ai/cli'
9
9
  require_relative 'tui/app'
10
+ require_relative 'api'
10
11
 
11
12
  module Gsd
12
13
  # CLI parser e dispatcher principal
13
14
  class CLI
14
15
  def initialize(args = [])
15
- @args = args
16
- @command = args.first
16
+ @args = normalize_args(args)
17
+ @command = @args.first
18
+ end
19
+
20
+ def normalize_args(args)
21
+ normalized = []
22
+ i = 0
23
+ while i < args.length
24
+ if %w[--port --host --body].include?(args[i]) && i + 1 < args.length && !args[i+1].start_with?('--')
25
+ normalized << "#{args[i]}=#{args[i+1]}"
26
+ i += 2
27
+ else
28
+ normalized << args[i]
29
+ i += 1
30
+ end
31
+ end
32
+ normalized
17
33
  end
18
34
 
19
35
  def run
@@ -36,6 +52,8 @@ module Gsd
36
52
  run_phase(@args[1..])
37
53
  when 'roadmap'
38
54
  run_roadmap(@args[1..])
55
+ when 'api'
56
+ run_api(@args[1..]) || 0
39
57
  else
40
58
  warn "Unknown command: #{@command}"
41
59
  print_help
@@ -321,6 +339,97 @@ module Gsd
321
339
  1
322
340
  end
323
341
 
342
+ # ========================================================================
343
+ # API Commands
344
+ # ========================================================================
345
+
346
+ def run_api(args)
347
+ subcommand = args.shift
348
+
349
+ case subcommand
350
+ when 'serve', nil
351
+ cmd_api_serve(args)
352
+ when 'status'
353
+ cmd_api_status(args)
354
+ when 'call'
355
+ cmd_api_call(args)
356
+ else
357
+ warn "Unknown api subcommand: #{subcommand}"
358
+ warn "Available: serve, status, call"
359
+ 1
360
+ end
361
+ rescue => e
362
+ warn "Error: #{e.message}"
363
+ 1
364
+ end
365
+
366
+ def cmd_api_serve(args)
367
+ host = '127.0.0.1'
368
+ port = 3000
369
+
370
+ args.each do |arg|
371
+ case arg
372
+ when /^--host=(.+)$/
373
+ host = Regexp.last_match(1)
374
+ when /^--port=(\d+)$/
375
+ port = Regexp.last_match(1).to_i
376
+ end
377
+ end
378
+
379
+ Gsd::API.serve(host: host, port: port, cwd: Dir.pwd)
380
+ end
381
+
382
+ def cmd_api_status(args)
383
+ host = parse_named_arg(args, 'host') || '127.0.0.1'
384
+ port = (parse_named_arg(args, 'port') || 3000).to_i
385
+
386
+ client = Gsd::API.client(host: host, port: port)
387
+ result = client.status
388
+
389
+ puts "✅ API Server is running on http://#{host}:#{port}"
390
+ if result.is_a?(Hash)
391
+ result.each do |k, v|
392
+ puts " #{k}: #{v}" unless v.is_a?(Hash) || v.is_a?(Array)
393
+ end
394
+ end
395
+ 0
396
+ rescue => e
397
+ puts "❌ API Server is not reachable: #{e.message}"
398
+ 1
399
+ end
400
+
401
+ def cmd_api_call(args)
402
+ method = args.shift
403
+ path = args.shift
404
+
405
+ unless method && path
406
+ output_error("method and path are required. Usage: gsd api call <METHOD> <PATH>")
407
+ return 1
408
+ end
409
+
410
+ host = parse_named_arg(args, 'host') || '127.0.0.1'
411
+ port = (parse_named_arg(args, 'port') || 3000).to_i
412
+ body_str = parse_named_arg(args, 'body')
413
+
414
+ body = nil
415
+ if body_str
416
+ begin
417
+ body = JSON.parse(body_str)
418
+ rescue JSON::ParserError
419
+ body = body_str
420
+ end
421
+ end
422
+
423
+ client = Gsd::API.client(host: host, port: port)
424
+ result = client.call(method, path, body: body)
425
+
426
+ output_json(result)
427
+ 0
428
+ rescue => e
429
+ output_error("API call failed: #{e.message}")
430
+ 1
431
+ end
432
+
324
433
  # ========================================================================
325
434
  # Helpers
326
435
  # ========================================================================
@@ -338,7 +447,7 @@ module Gsd
338
447
  def parse_named_args(args)
339
448
  result = {}
340
449
  return result if args.nil? || args.empty?
341
-
450
+
342
451
  args.each do |arg|
343
452
  next unless arg.is_a?(String) && arg.start_with?('--')
344
453
  key, value = arg[2..].split('=', 2)
@@ -390,6 +499,7 @@ module Gsd
390
499
  hello Print greeting from Go
391
500
  ai AI Chat Interface (REPL + one-shot)
392
501
  tui Terminal User Interface (TUI)
502
+ api API Server (HTTP REST interface)
393
503
  version Print version information
394
504
  state State operations
395
505
  phase Phase operations
@@ -427,6 +537,13 @@ module Gsd
427
537
  get-phase <phase> Extract phase section from ROADMAP.md
428
538
  analyze Full roadmap analysis
429
539
 
540
+ API Subcommands:
541
+ serve Start API HTTP server
542
+ serve --port 3000 Custom port (default: 3000)
543
+ serve --host 0.0.0.0 Bind address (default: 127.0.0.1)
544
+ status Check API server status
545
+ call <METH> <PATH> Call API endpoint (e.g., call GET /api/state)
546
+
430
547
  Options:
431
548
  --cwd=<path> Working directory (default: current)
432
549
  --field=<name> Field name (for state update)
@@ -442,6 +559,10 @@ module Gsd
442
559
  gsd phase next-decimal 1
443
560
  gsd roadmap get-phase 1
444
561
  gsd roadmap analyze
562
+ gsd api serve
563
+ gsd api serve --port 8080
564
+ gsd api status
565
+ gsd api call GET /api/state
445
566
 
446
567
  For more information, see README.md
447
568
  HELP
data/lib/gsd/tui/app.rb CHANGED
@@ -33,6 +33,9 @@ module Gsd
33
33
  @output = []
34
34
  @frame_count = 0
35
35
  @render_count = 0
36
+
37
+ # Initialize status bar with current agent mode
38
+ update_status_mode
36
39
  end
37
40
 
38
41
  def run
@@ -349,6 +352,27 @@ module Gsd
349
352
  end
350
353
  else
351
354
  @selected_agent = (@selected_agent + 1) % @agents.length
355
+ update_status_mode
356
+ end
357
+ end
358
+
359
+ # Map selected agent to mode and update status bar
360
+ def update_status_mode
361
+ mode = agent_to_mode(@agents[@selected_agent])
362
+ @status_bar.update_mode(mode)
363
+ end
364
+
365
+ # Map agent name to mode
366
+ def agent_to_mode(agent)
367
+ case agent
368
+ when 'Code'
369
+ 'CODE'
370
+ when 'Kilo Auto Free'
371
+ 'PLAN'
372
+ when 'Kilo Gateway'
373
+ 'DEBUG'
374
+ else
375
+ 'NORMAL'
352
376
  end
353
377
  end
354
378
 
data/lib/gsd/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gsd
4
- VERSION = '1.2.10'
4
+ VERSION = '1.2.11'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fantasy-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.10
4
+ version: 1.2.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fantasy Team
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: webrick
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.9'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.9'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: rake
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -129,6 +143,17 @@ files:
129
143
  - lib/gsd/ai/repl.rb
130
144
  - lib/gsd/ai/streaming.rb
131
145
  - lib/gsd/ai/ui.rb
146
+ - lib/gsd/api.rb
147
+ - lib/gsd/api/client.rb
148
+ - lib/gsd/api/middleware/cors.rb
149
+ - lib/gsd/api/public/app.js
150
+ - lib/gsd/api/public/index.html
151
+ - lib/gsd/api/public/style.css
152
+ - lib/gsd/api/routes/health.rb
153
+ - lib/gsd/api/routes/phases.rb
154
+ - lib/gsd/api/routes/roadmap.rb
155
+ - lib/gsd/api/routes/state.rb
156
+ - lib/gsd/api/server.rb
132
157
  - lib/gsd/buddy.rb
133
158
  - lib/gsd/buddy/cli.rb
134
159
  - lib/gsd/buddy/gacha.rb