fantasy-cli 1.2.6

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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +456 -0
  4. data/bin/gsd +8 -0
  5. data/bin/gsd-core-darwin-amd64 +0 -0
  6. data/bin/gsd-core-darwin-arm64 +0 -0
  7. data/bin/gsd-core-linux-amd64 +0 -0
  8. data/bin/gsd-core-linux-arm64 +0 -0
  9. data/bin/gsd-core-windows-amd64.exe +0 -0
  10. data/bin/gsd-core-windows-arm64.exe +0 -0
  11. data/bin/gsd-core.exe +0 -0
  12. data/lib/gsd/agents/coordinator.rb +195 -0
  13. data/lib/gsd/agents/task_manager.rb +158 -0
  14. data/lib/gsd/agents/worker.rb +162 -0
  15. data/lib/gsd/agents.rb +30 -0
  16. data/lib/gsd/ai/chat.rb +486 -0
  17. data/lib/gsd/ai/cli.rb +248 -0
  18. data/lib/gsd/ai/command_parser.rb +97 -0
  19. data/lib/gsd/ai/commands/base.rb +42 -0
  20. data/lib/gsd/ai/commands/clear.rb +20 -0
  21. data/lib/gsd/ai/commands/context.rb +30 -0
  22. data/lib/gsd/ai/commands/cost.rb +30 -0
  23. data/lib/gsd/ai/commands/export.rb +42 -0
  24. data/lib/gsd/ai/commands/help.rb +61 -0
  25. data/lib/gsd/ai/commands/model.rb +67 -0
  26. data/lib/gsd/ai/commands/reset.rb +22 -0
  27. data/lib/gsd/ai/config.rb +256 -0
  28. data/lib/gsd/ai/context.rb +324 -0
  29. data/lib/gsd/ai/cost_tracker.rb +361 -0
  30. data/lib/gsd/ai/git_context.rb +169 -0
  31. data/lib/gsd/ai/history.rb +384 -0
  32. data/lib/gsd/ai/providers/anthropic.rb +429 -0
  33. data/lib/gsd/ai/providers/base.rb +282 -0
  34. data/lib/gsd/ai/providers/lmstudio.rb +279 -0
  35. data/lib/gsd/ai/providers/ollama.rb +336 -0
  36. data/lib/gsd/ai/providers/openai.rb +396 -0
  37. data/lib/gsd/ai/providers/openrouter.rb +429 -0
  38. data/lib/gsd/ai/reference_resolver.rb +225 -0
  39. data/lib/gsd/ai/repl.rb +349 -0
  40. data/lib/gsd/ai/streaming.rb +438 -0
  41. data/lib/gsd/ai/ui.rb +429 -0
  42. data/lib/gsd/buddy/cli.rb +284 -0
  43. data/lib/gsd/buddy/gacha.rb +148 -0
  44. data/lib/gsd/buddy/renderer.rb +108 -0
  45. data/lib/gsd/buddy/species.rb +190 -0
  46. data/lib/gsd/buddy/stats.rb +156 -0
  47. data/lib/gsd/buddy.rb +28 -0
  48. data/lib/gsd/cli.rb +455 -0
  49. data/lib/gsd/commands.rb +198 -0
  50. data/lib/gsd/config.rb +183 -0
  51. data/lib/gsd/error.rb +188 -0
  52. data/lib/gsd/frontmatter.rb +123 -0
  53. data/lib/gsd/go/bridge.rb +173 -0
  54. data/lib/gsd/history.rb +76 -0
  55. data/lib/gsd/milestone.rb +75 -0
  56. data/lib/gsd/output.rb +184 -0
  57. data/lib/gsd/phase.rb +102 -0
  58. data/lib/gsd/plugins/base.rb +92 -0
  59. data/lib/gsd/plugins/cli.rb +330 -0
  60. data/lib/gsd/plugins/config.rb +164 -0
  61. data/lib/gsd/plugins/hooks.rb +132 -0
  62. data/lib/gsd/plugins/installer.rb +158 -0
  63. data/lib/gsd/plugins/loader.rb +122 -0
  64. data/lib/gsd/plugins/manager.rb +187 -0
  65. data/lib/gsd/plugins/marketplace.rb +142 -0
  66. data/lib/gsd/plugins/sandbox.rb +114 -0
  67. data/lib/gsd/plugins/search.rb +131 -0
  68. data/lib/gsd/plugins/validator.rb +157 -0
  69. data/lib/gsd/plugins.rb +48 -0
  70. data/lib/gsd/profile.rb +127 -0
  71. data/lib/gsd/research.rb +85 -0
  72. data/lib/gsd/roadmap.rb +90 -0
  73. data/lib/gsd/skills/bundled/commit.md +58 -0
  74. data/lib/gsd/skills/bundled/debug.md +28 -0
  75. data/lib/gsd/skills/bundled/explain.md +41 -0
  76. data/lib/gsd/skills/bundled/plan.md +42 -0
  77. data/lib/gsd/skills/bundled/verify.md +26 -0
  78. data/lib/gsd/skills/loader.rb +189 -0
  79. data/lib/gsd/state.rb +102 -0
  80. data/lib/gsd/template.rb +106 -0
  81. data/lib/gsd/tools/ask_user_question.rb +179 -0
  82. data/lib/gsd/tools/base.rb +204 -0
  83. data/lib/gsd/tools/bash.rb +246 -0
  84. data/lib/gsd/tools/file_edit.rb +297 -0
  85. data/lib/gsd/tools/file_read.rb +199 -0
  86. data/lib/gsd/tools/file_write.rb +153 -0
  87. data/lib/gsd/tools/glob.rb +202 -0
  88. data/lib/gsd/tools/grep.rb +227 -0
  89. data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
  90. data/lib/gsd/tools/gsd_phase.rb +140 -0
  91. data/lib/gsd/tools/gsd_roadmap.rb +108 -0
  92. data/lib/gsd/tools/gsd_state.rb +143 -0
  93. data/lib/gsd/tools/gsd_template.rb +157 -0
  94. data/lib/gsd/tools/gsd_verify.rb +159 -0
  95. data/lib/gsd/tools/registry.rb +103 -0
  96. data/lib/gsd/tools/task.rb +235 -0
  97. data/lib/gsd/tools/todo_write.rb +290 -0
  98. data/lib/gsd/tools/web.rb +260 -0
  99. data/lib/gsd/tui/app.rb +366 -0
  100. data/lib/gsd/tui/auto_complete.rb +79 -0
  101. data/lib/gsd/tui/colors.rb +111 -0
  102. data/lib/gsd/tui/command_palette.rb +126 -0
  103. data/lib/gsd/tui/header.rb +38 -0
  104. data/lib/gsd/tui/input_box.rb +199 -0
  105. data/lib/gsd/tui/spinner.rb +40 -0
  106. data/lib/gsd/tui/status_bar.rb +51 -0
  107. data/lib/gsd/tui.rb +17 -0
  108. data/lib/gsd/validator.rb +216 -0
  109. data/lib/gsd/verify.rb +175 -0
  110. data/lib/gsd/version.rb +5 -0
  111. data/lib/gsd/workstream.rb +91 -0
  112. metadata +231 -0
data/lib/gsd/cli.rb ADDED
@@ -0,0 +1,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'go/bridge'
5
+ require_relative 'state'
6
+ require_relative 'phase'
7
+ require_relative 'roadmap'
8
+ require_relative 'ai/cli'
9
+ require_relative 'tui/app'
10
+
11
+ module Gsd
12
+ # CLI parser e dispatcher principal
13
+ class CLI
14
+ def initialize(args = [])
15
+ @args = args
16
+ @command = args.first
17
+ end
18
+
19
+ def run
20
+ case @command
21
+ when nil, 'help', '--help', '-h'
22
+ print_help
23
+ 0
24
+ when 'version', '--version', '-v'
25
+ print_version
26
+ 0
27
+ when 'hello'
28
+ run_hello
29
+ when 'ai'
30
+ run_ai(@args[1..])
31
+ when 'tui'
32
+ run_tui(@args[1..])
33
+ when 'state'
34
+ run_state(@args[1..])
35
+ when 'phase'
36
+ run_phase(@args[1..])
37
+ when 'roadmap'
38
+ run_roadmap(@args[1..])
39
+ else
40
+ warn "Unknown command: #{@command}"
41
+ print_help
42
+ 1
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # ========================================================================
49
+ # Hello Command
50
+ # ========================================================================
51
+
52
+ def run_hello
53
+ result = Go::Bridge.call('hello', parse_named_args(@args[1..]))
54
+ output_json(result)
55
+ 0
56
+ rescue => e
57
+ warn "Error: #{e.message}"
58
+ 1
59
+ end
60
+
61
+ # ========================================================================
62
+ # AI Command
63
+ # ========================================================================
64
+
65
+ def run_ai(args)
66
+ ai_cli = Gsd::AI::CLI.new(args, cwd: parse_cwd(args))
67
+ ai_cli.run
68
+ 0
69
+ rescue => e
70
+ warn "Error: #{e.message}"
71
+ 1
72
+ end
73
+
74
+ # ========================================================================
75
+ # TUI Command
76
+ # ========================================================================
77
+
78
+ def run_tui(args)
79
+ theme = :fantasy
80
+ header_style = :pixel
81
+
82
+ args.each do |arg|
83
+ case arg
84
+ when '--theme=kilo'
85
+ theme = :kilo
86
+ when '--theme=dark'
87
+ theme = :dark
88
+ when '--theme=light'
89
+ theme = :light
90
+ when '--no-header'
91
+ header_style = :simple
92
+ end
93
+ end
94
+
95
+ tui_app = Gsd::TUI::App.new(theme: theme, header_style: header_style)
96
+ tui_app.run
97
+ 0
98
+ rescue => e
99
+ warn "Error: #{e.message}"
100
+ 1
101
+ end
102
+
103
+ # ========================================================================
104
+ # State Commands
105
+ # ========================================================================
106
+
107
+ def run_state(args)
108
+ args = args.compact.reject { |a| a.nil? }
109
+ subcommand = args.shift
110
+ cwd = parse_cwd(args)
111
+
112
+ case subcommand
113
+ when 'load'
114
+ cmd_state_load(cwd)
115
+ when 'json'
116
+ cmd_state_json(cwd)
117
+ when 'update'
118
+ cmd_state_update(args, cwd)
119
+ when 'patch'
120
+ cmd_state_patch(args, cwd)
121
+ when 'get'
122
+ cmd_state_get(args, cwd)
123
+ when nil
124
+ warn "Usage: gsd state <subcommand> [options]"
125
+ warn "Subcommands: load, json, update, patch, get"
126
+ 1
127
+ else
128
+ warn "Unknown state subcommand: #{subcommand}"
129
+ warn "Available: load, json, update, patch, get"
130
+ 1
131
+ end
132
+ end
133
+
134
+ def cmd_state_load(cwd)
135
+ result = Gsd::State.load(cwd: cwd)
136
+ output_json({ 'success' => true, 'data' => result })
137
+ 0
138
+ rescue => e
139
+ output_error("Failed to load state: #{e.message}")
140
+ 1
141
+ end
142
+
143
+ def cmd_state_json(cwd)
144
+ result = Gsd::State.json(cwd: cwd)
145
+ output_json({ 'success' => true, 'data' => result })
146
+ 0
147
+ rescue => e
148
+ output_error("Failed to get state JSON: #{e.message}")
149
+ 1
150
+ end
151
+
152
+ def cmd_state_update(args, cwd)
153
+ field = parse_named_arg(args, 'field')
154
+ value = parse_named_arg(args, 'value')
155
+
156
+ unless field && value
157
+ output_error("field and value are required. Usage: gsd state update --field X --value Y")
158
+ return 1
159
+ end
160
+
161
+ result = Gsd::State.update(field: field, value: value, cwd: cwd)
162
+ output_json({ 'success' => true, 'data' => result })
163
+ 0
164
+ rescue => e
165
+ output_error("Failed to update state: #{e.message}")
166
+ 1
167
+ end
168
+
169
+ def cmd_state_patch(args, cwd)
170
+ fields = parse_patch_args(args)
171
+
172
+ if fields.empty?
173
+ output_error("At least one field is required. Usage: gsd state patch --field1 val1 --field2 val2")
174
+ return 1
175
+ end
176
+
177
+ result = Gsd::State.patch(fields: fields, cwd: cwd)
178
+ output_json({ 'success' => true, 'data' => result })
179
+ 0
180
+ rescue => e
181
+ output_error("Failed to patch state: #{e.message}")
182
+ 1
183
+ end
184
+
185
+ def cmd_state_get(args, cwd)
186
+ section = args.first
187
+ result = Gsd::State.get(section: section, cwd: cwd)
188
+ output_json({ 'success' => true, 'data' => result })
189
+ 0
190
+ rescue => e
191
+ output_error("Failed to get state: #{e.message}")
192
+ 1
193
+ end
194
+
195
+ # ========================================================================
196
+ # Phase Commands
197
+ # ========================================================================
198
+
199
+ def run_phase(args)
200
+ subcommand = args.shift
201
+ cwd = parse_cwd(args)
202
+
203
+ case subcommand
204
+ when 'find'
205
+ cmd_phase_find(args.first, cwd)
206
+ when 'list'
207
+ cmd_phase_list(cwd)
208
+ when 'next-decimal'
209
+ cmd_phase_next_decimal(args.first, cwd)
210
+ when 'add'
211
+ cmd_phase_add(args, cwd)
212
+ when nil
213
+ warn "Usage: gsd phase <subcommand> [options]"
214
+ warn "Subcommands: find, list, next-decimal, add"
215
+ 1
216
+ else
217
+ warn "Unknown phase subcommand: #{subcommand}"
218
+ warn "Available: find, list, next-decimal, add"
219
+ 1
220
+ end
221
+ end
222
+
223
+ def cmd_phase_find(phase_num, cwd)
224
+ unless phase_num
225
+ output_error("phase number is required. Usage: gsd phase find <phase>")
226
+ return 1
227
+ end
228
+
229
+ result = Gsd::Phase.find(phase_num, cwd: cwd)
230
+ output_json({ 'success' => true, 'data' => result })
231
+ 0
232
+ rescue => e
233
+ output_error("Failed to find phase: #{e.message}")
234
+ 1
235
+ end
236
+
237
+ def cmd_phase_list(cwd)
238
+ result = Gsd::Phase.list(cwd: cwd)
239
+ output_json({ 'success' => true, 'data' => result })
240
+ 0
241
+ rescue => e
242
+ output_error("Failed to list phases: #{e.message}")
243
+ 1
244
+ end
245
+
246
+ def cmd_phase_next_decimal(phase_num, cwd)
247
+ unless phase_num
248
+ output_error("phase number is required. Usage: gsd phase next-decimal <phase>")
249
+ return 1
250
+ end
251
+
252
+ result = Gsd::Phase.next_decimal(phase_num, cwd: cwd)
253
+ output_json({ 'success' => true, 'data' => result })
254
+ 0
255
+ rescue => e
256
+ output_error("Failed to calculate next decimal: #{e.message}")
257
+ 1
258
+ end
259
+
260
+ def cmd_phase_add(args, cwd)
261
+ # Join all remaining args as description (handles multi-word descriptions)
262
+ description = args.join(' ').strip
263
+
264
+ unless description && !description.empty?
265
+ output_error("description is required. Usage: gsd phase add <description>")
266
+ return 1
267
+ end
268
+
269
+ result = Gsd::Phase.add(description, cwd: cwd)
270
+ output_json({ 'success' => true, 'data' => result })
271
+ 0
272
+ rescue => e
273
+ output_error("Failed to add phase: #{e.message}")
274
+ 1
275
+ end
276
+
277
+ # ========================================================================
278
+ # Roadmap Commands
279
+ # ========================================================================
280
+
281
+ def run_roadmap(args)
282
+ subcommand = args.shift
283
+ cwd = parse_cwd(args)
284
+
285
+ case subcommand
286
+ when 'get-phase'
287
+ cmd_roadmap_get_phase(args.first, cwd)
288
+ when 'analyze'
289
+ cmd_roadmap_analyze(cwd)
290
+ when nil
291
+ warn "Usage: gsd roadmap <subcommand> [options]"
292
+ warn "Subcommands: get-phase, analyze"
293
+ 1
294
+ else
295
+ warn "Unknown roadmap subcommand: #{subcommand}"
296
+ warn "Available: get-phase, analyze"
297
+ 1
298
+ end
299
+ end
300
+
301
+ def cmd_roadmap_get_phase(phase_num, cwd)
302
+ unless phase_num
303
+ output_error("phase number is required. Usage: gsd roadmap get-phase <phase>")
304
+ return 1
305
+ end
306
+
307
+ result = Gsd::Roadmap.get_phase(phase_num, cwd: cwd)
308
+ output_json({ 'success' => true, 'data' => result })
309
+ 0
310
+ rescue => e
311
+ output_error("Failed to get phase from roadmap: #{e.message}")
312
+ 1
313
+ end
314
+
315
+ def cmd_roadmap_analyze(cwd)
316
+ result = Gsd::Roadmap.analyze(cwd: cwd)
317
+ output_json({ 'success' => true, 'data' => result })
318
+ 0
319
+ rescue => e
320
+ output_error("Failed to analyze roadmap: #{e.message}")
321
+ 1
322
+ end
323
+
324
+ # ========================================================================
325
+ # Helpers
326
+ # ========================================================================
327
+
328
+ def parse_named_arg(args, name)
329
+ prefix = "--#{name}="
330
+ args.each do |arg|
331
+ if arg.start_with?(prefix)
332
+ return arg[prefix.length..]
333
+ end
334
+ end
335
+ nil
336
+ end
337
+
338
+ def parse_named_args(args)
339
+ result = {}
340
+ return result if args.nil? || args.empty?
341
+
342
+ args.each do |arg|
343
+ next unless arg.is_a?(String) && arg.start_with?('--')
344
+ key, value = arg[2..].split('=', 2)
345
+ result[key] = value || true
346
+ end
347
+ result
348
+ end
349
+
350
+ def parse_patch_args(args)
351
+ fields = {}
352
+ i = 0
353
+ while i < args.length
354
+ if args[i].start_with?('--') && i + 1 < args.length && !args[i + 1].start_with?('--')
355
+ key = args[i][2..]
356
+ value = args[i + 1]
357
+ fields[key] = value
358
+ i += 2
359
+ else
360
+ i += 1
361
+ end
362
+ end
363
+ fields
364
+ end
365
+
366
+ def parse_cwd(args)
367
+ cwd = parse_named_arg(args, 'cwd')
368
+ cwd || Dir.pwd
369
+ end
370
+
371
+ def output_json(data)
372
+ puts JSON.pretty_generate(data)
373
+ end
374
+
375
+ def output_error(message)
376
+ puts JSON.pretty_generate({
377
+ 'success' => false,
378
+ 'error' => message
379
+ })
380
+ end
381
+
382
+ def print_help
383
+ puts <<~HELP
384
+ fantasy-cli v#{VERSION} - Fantasy CLI (Ruby + Go)
385
+
386
+ Usage:
387
+ gsd <command> [options]
388
+
389
+ Commands:
390
+ hello Print greeting from Go
391
+ ai AI Chat Interface (REPL + one-shot)
392
+ tui Terminal User Interface (TUI)
393
+ version Print version information
394
+ state State operations
395
+ phase Phase operations
396
+ roadmap Roadmap operations
397
+ help Show this help message
398
+
399
+ AI Subcommands:
400
+ gsd ai # Inicia REPL interativo
401
+ gsd ai "mensagem" # One-shot command
402
+ gsd ai --provider <name> Provider: anthropic, openai, ollama
403
+ gsd ai --model <name> Modelo do LLM
404
+ gsd ai --debug Habilita modo debug
405
+
406
+ TUI Options:
407
+ gsd tui # Launch TUI
408
+ gsd tui --theme=kilo # Kilo theme (yellow/black)
409
+ gsd tui --theme=dark # Dark theme
410
+ gsd tui --theme=light # Light theme
411
+ gsd tui --no-header # Without header
412
+
413
+ State Subcommands:
414
+ load Load STATE.md
415
+ json Output STATE.md frontmatter as JSON
416
+ update Update a STATE.md field (--field, --value)
417
+ patch Batch update STATE.md fields
418
+ get [section] Get STATE.md content or section
419
+
420
+ Phase Subcommands:
421
+ find <phase> Find phase directory
422
+ list List all phases
423
+ next-decimal <n> Calculate next decimal phase
424
+ add <description> Add new phase
425
+
426
+ Roadmap Subcommands:
427
+ get-phase <phase> Extract phase section from ROADMAP.md
428
+ analyze Full roadmap analysis
429
+
430
+ Options:
431
+ --cwd=<path> Working directory (default: current)
432
+ --field=<name> Field name (for state update)
433
+ --value=<val> Field value (for state update)
434
+
435
+ Examples:
436
+ gsd hello
437
+ gsd hello --name=World
438
+ gsd version
439
+ gsd state json --cwd /path/to/project
440
+ gsd state update --field current_phase --value 2
441
+ gsd phase find 1
442
+ gsd phase next-decimal 1
443
+ gsd roadmap get-phase 1
444
+ gsd roadmap analyze
445
+
446
+ For more information, see README.md
447
+ HELP
448
+ end
449
+
450
+ def print_version
451
+ puts "gsd v#{VERSION} (Ruby + Go)"
452
+ puts "gsd-core v#{Go::Bridge.core_version}"
453
+ end
454
+ end
455
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'date'
5
+
6
+ module Gsd
7
+ # Commands atômicos do GSD
8
+ module Commands
9
+ class << self
10
+ # Git commit wrapper
11
+ #
12
+ # @param message [String] Commit message
13
+ # @param files [Array<String>] Files to commit
14
+ # @param cwd [String] Working directory
15
+ # @param no_verify [Boolean] Skip pre-commit hooks
16
+ # @return [Hash] Result with success status and message/error
17
+ def commit(message, files: [], cwd: nil, no_verify: false)
18
+ cwd ||= Dir.pwd
19
+ cmd = ['git', 'commit', '-m', message]
20
+ cmd << '--no-verify' if no_verify
21
+ cmd << '--'
22
+ cmd += files unless files.empty?
23
+
24
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: cwd)
25
+
26
+ if status.success?
27
+ { success: true, message: stdout.strip }
28
+ else
29
+ { success: false, error: stderr.strip }
30
+ end
31
+ end
32
+
33
+ # Generate URL-safe slug from text
34
+ #
35
+ # @param text [String] Text to convert to slug
36
+ # @return [String] URL-safe slug
37
+ def generate_slug(text)
38
+ text
39
+ .downcase
40
+ .gsub(/[^\w\s-]/, '')
41
+ .gsub(/\s+/, '-')
42
+ .gsub(/-+/, '-')
43
+ .strip('-')
44
+ end
45
+
46
+ # Get current timestamp in various formats
47
+ #
48
+ # @param format [String] Format type: 'full', 'date', 'filename'
49
+ # @return [String] Timestamp string
50
+ def current_timestamp(format = 'full')
51
+ case format
52
+ when 'date'
53
+ Date.today.to_s
54
+ when 'filename'
55
+ Time.now.strftime('%Y%m%d%H%M%S')
56
+ else
57
+ Time.now.iso8601
58
+ end
59
+ end
60
+
61
+ # Verify if a path exists
62
+ #
63
+ # @param path [String] Path to verify
64
+ # @return [Hash] Result with exists status and path
65
+ def verify_path_exists(path)
66
+ if File.exist?(path)
67
+ { exists: true, path: path }
68
+ else
69
+ { exists: false, path: path, error: "Path not found: #{path}" }
70
+ end
71
+ end
72
+
73
+ # List pending todos
74
+ #
75
+ # @param area [String] Optional area filter
76
+ # @param cwd [String] Working directory
77
+ # @return [Hash] Result with count and todo list
78
+ def list_todos(area = nil, cwd: nil)
79
+ cwd ||= Dir.pwd
80
+ todos_dir = File.join(cwd, '.planning', 'todos')
81
+ return { count: 0, todos: [] } unless File.directory?(todos_dir)
82
+
83
+ pending_dir = File.join(todos_dir, 'pending')
84
+ return { count: 0, todos: [] } unless File.directory?(pending_dir)
85
+
86
+ files = Dir.glob('*.md', base: pending_dir)
87
+ files = files.select { |f| f.start_with?(area) } if area
88
+
89
+ {
90
+ count: files.length,
91
+ todos: files.map { |f| f.gsub('.md', '') }
92
+ }
93
+ end
94
+
95
+ # Complete a todo (move from pending to completed)
96
+ #
97
+ # @param filename [String] Todo filename (without .md)
98
+ # @param cwd [String] Working directory
99
+ # @return [Hash] Result with success status
100
+ def todo_complete(filename, cwd: nil)
101
+ cwd ||= Dir.pwd
102
+ todos_dir = File.join(cwd, '.planning', 'todos')
103
+ pending_dir = File.join(todos_dir, 'pending')
104
+ completed_dir = File.join(todos_dir, 'completed')
105
+
106
+ pending_file = File.join(pending_dir, "#{filename}.md")
107
+ completed_file = File.join(completed_dir, "#{filename}.md")
108
+
109
+ unless File.exist?(pending_file)
110
+ return { success: false, error: "Todo not found: #{filename}" }
111
+ end
112
+
113
+ FileUtils.mkdir_p(completed_dir) unless File.directory?(completed_dir)
114
+ FileUtils.mv(pending_file, completed_file)
115
+
116
+ { success: true, message: "Todo completed: #{filename}" }
117
+ end
118
+
119
+ # Generate a unique slug with timestamp
120
+ #
121
+ # @param text [String] Base text for slug
122
+ # @return [String] Unique slug with timestamp
123
+ def generate_unique_slug(text)
124
+ base_slug = generate_slug(text)
125
+ timestamp = Time.now.strftime('%Y%m%d%H%M%S')
126
+ "#{base_slug}-#{timestamp}"
127
+ end
128
+
129
+ # Verify if a file has valid frontmatter
130
+ #
131
+ # @param path [String] File path to check
132
+ # @return [Hash] Result with valid status and frontmatter if present
133
+ def has_frontmatter?(path)
134
+ return { valid: false, error: 'File not found' } unless File.exist?(path)
135
+
136
+ content = File.read(path)
137
+ return { valid: false, error: 'Empty file' } if content.empty?
138
+
139
+ unless content.start_with?('---')
140
+ return { valid: false, frontmatter: nil }
141
+ end
142
+
143
+ lines = content.split("\n")
144
+ end_index = lines[1..-1]&.index { |line| line.strip == '---' }
145
+
146
+ if end_index
147
+ frontmatter_lines = lines[1..end_index]
148
+ frontmatter = {}
149
+ frontmatter_lines.each do |line|
150
+ key, value = line.split(':', 2)
151
+ if key && value
152
+ frontmatter[key.strip] = value.strip.gsub(/["']/, '')
153
+ end
154
+ end
155
+ { valid: true, frontmatter: frontmatter }
156
+ else
157
+ { valid: false, error: 'Unclosed frontmatter' }
158
+ end
159
+ end
160
+
161
+ # Extract text from markdown file (skip frontmatter)
162
+ #
163
+ # @param path [String] File path
164
+ # @return [Hash] Result with content or error
165
+ def extract_markdown_content(path)
166
+ return { error: 'File not found' } unless File.exist?(path)
167
+
168
+ content = File.read(path)
169
+
170
+ # Skip frontmatter if present
171
+ if content.start_with?('---')
172
+ lines = content.split("\n")
173
+ end_index = lines[1..-1]&.index { |line| line.strip == '---' }
174
+ if end_index
175
+ content = lines[end_index + 2..-1]&.join("\n") || ''
176
+ end
177
+ end
178
+
179
+ { content: content.strip }
180
+ end
181
+
182
+ # Count lines in file
183
+ #
184
+ # @param path [String] File path
185
+ # @return [Hash] Result with line count
186
+ def count_lines(path)
187
+ return { error: 'File not found' } unless File.exist?(path)
188
+
189
+ lines = File.readlines(path)
190
+ {
191
+ total: lines.length,
192
+ non_empty: lines.count { |l| !l.strip.empty? },
193
+ code: lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
194
+ }
195
+ end
196
+ end
197
+ end
198
+ end