Module: HTM::MCP::CLI

Defined in:
lib/htm/mcp/cli.rb

Overview

CLI commands for htm_mcp executable

Class Method Summary collapse

Class Method Details

.apply_config(config) ⇒ Object



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/htm/mcp/cli.rb', line 310

def apply_config(config)
  HTM.configure do |c|
    # Apply nested sections
    apply_section(c, :database, config[:database])
    apply_section(c, :service, config[:service])
    apply_section(c, :embedding, config[:embedding])
    apply_section(c, :tag, config[:tag])
    apply_section(c, :proposition, config[:proposition])
    apply_section(c, :chunking, config[:chunking])
    apply_section(c, :circuit_breaker, config[:circuit_breaker])
    apply_section(c, :relevance, config[:relevance])
    apply_section(c, :job, config[:job])
    apply_section(c, :providers, config[:providers])

    # Apply top-level scalars
    c.week_start = config[:week_start] if config[:week_start]
    c.connection_timeout = config[:connection_timeout] if config[:connection_timeout]
    c.telemetry_enabled = config[:telemetry_enabled] unless config[:telemetry_enabled].nil?
    c.log_level = config[:log_level] if config[:log_level]
  end
end

.apply_section(config, section_name, values) ⇒ Object



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/htm/mcp/cli.rb', line 332

def apply_section(config, section_name, values)
  return unless values.is_a?(Hash)

  section = config.send(section_name)
  values.each do |key, value|
    next if value.nil?

    if value.is_a?(Hash)
      # Handle nested sections (like providers.openai)
      subsection = section.send(key)
      value.each do |subkey, subvalue|
        subsection.send("#{subkey}=", subvalue) unless subvalue.nil?
      end
    else
      section.send("#{key}=", value)
    end
  end
end

.check_database_config!Object



140
141
142
143
144
145
146
# File 'lib/htm/mcp/cli.rb', line 140

def check_database_config!
  return if ENV['HTM_DATABASE__URL'] || ENV['HTM_DATABASE__NAME']
  warn "Error: Database not configured."
  warn "Set HTM_DATABASE__URL or HTM_DATABASE__NAME environment variable."
  warn "Run 'htm_mcp help' for details."
  exit 1
end

.check_migration_statusObject



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/htm/mcp/cli.rb', line 214

def check_migration_status
  migrations_path = File.expand_path('../../../db/migrate', __dir__)

  # Get available migrations from files
  available_migrations = Dir.glob(File.join(migrations_path, '*.rb')).map do |file|
    {
      version: File.basename(file).split('_').first,
      name: File.basename(file, '.rb')
    }
  end
  available_migrations = available_migrations.sort_by { |m| m[:version] }

  # Ensure Sequel connection for migration check
  HTM::SequelConfig.establish_connection!

  # Get applied migrations from database
  applied_versions = begin
    HTM.db[:schema_migrations].select_order_map(:version)
  rescue Sequel::DatabaseError
    []
  end

  puts "Migration Status"
  puts "-" * 80

  if available_migrations.empty?
    puts "  No migration files found"
    return 0
  end

  available_migrations.each do |migration|
    applied = applied_versions.include?(migration[:version])
    status_mark = applied ? "+" : "-"
    puts "  #{status_mark} #{migration[:name]}"
  end

  applied_count = applied_versions.length
  pending_count = available_migrations.length - applied_count

  puts "-" * 80
  puts "  #{applied_count} applied, #{pending_count} pending"

  pending_count
end

.command?(arg) ⇒ Boolean

Returns:

  • (Boolean)


449
450
451
# File 'lib/htm/mcp/cli.rb', line 449

def command?(arg)
  %w[help version setup init verify stats config server stdio rake].include?(arg.downcase)
end

.deep_merge(base, override) ⇒ Object



300
301
302
303
304
305
306
307
308
# File 'lib/htm/mcp/cli.rb', line 300

def deep_merge(base, override)
  base.merge(override) do |_key, old_val, new_val|
    if old_val.is_a?(Hash) && new_val.is_a?(Hash)
      deep_merge(old_val, new_val)
    else
      new_val.nil? ? old_val : new_val
    end
  end
end

.extract_dbname(url_or_name) ⇒ Object



153
154
155
156
157
158
159
160
161
162
# File 'lib/htm/mcp/cli.rb', line 153

def extract_dbname(url_or_name)
  return url_or_name unless url_or_name&.include?("://")

  # Extract database name from URL like postgresql://user@host:port/dbname
  if url_or_name =~ %r{/([^/?]+)(?:\?|$)}
    ::Regexp.last_match(1)
  else
    "htm_development"
  end
end

.handle_config_option(args) ⇒ Object

Handle -c / –config option, modifying args in place Returns true if config was loaded, nil otherwise



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# File 'lib/htm/mcp/cli.rb', line 427

def handle_config_option(args)
  config_idx = args.index('-c') || args.index('--config')
  return nil unless config_idx

  # Remove the -c/--config flag
  args.delete_at(config_idx)

  # Check if next arg is a path (not another flag or command)
  next_arg = args[config_idx]

  if next_arg.nil? || next_arg.start_with?('-') || command?(next_arg)
    # No path provided - output default config and exit
    output_default_config
    exit 0
  else
    # Path provided - load config file
    config_path = args.delete_at(config_idx)
    load_config_file(config_path)
    true
  end
end

.list_rake_tasks(pattern: nil) ⇒ Object



503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
# File 'lib/htm/mcp/cli.rb', line 503

def list_rake_tasks(pattern: nil)
  load_htm_rake_tasks

  # Collect tasks with descriptions, sorted by name
  tasks = Rake.application.tasks
              .select { |t| t.comment && t.name.start_with?('htm:') }
              .sort_by(&:name)

  # Filter by pattern if provided (matches task name)
  if pattern
    tasks = tasks.select { |t| t.name.include?(pattern) }
  end

  if tasks.empty?
    if pattern
      puts "No HTM rake tasks matching '#{pattern}'"
    else
      puts "No HTM rake tasks found"
    end
    return
  end

  if pattern
    puts "HTM rake tasks matching '#{pattern}':"
  else
    puts "Available HTM rake tasks:"
  end
  puts

  # Find max task name length for alignment
  max_len = tasks.map { |t| t.name.length }.max || 0

  tasks.each do |task|
    printf "  %-#{max_len}s  # %s\n", task.name, task.comment
  end

  puts
  puts "Run with: htm_mcp rake <task_name>"
  puts "Example:  htm_mcp rake htm:db:stats"
end

.load_config_file(path) ⇒ Object



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/htm/mcp/cli.rb', line 269

def load_config_file(path)
  unless File.exist?(path)
    warn "Error: Config file not found: #{path}"
    exit 1
  end

  begin
    require 'yaml'
    config_data = YAML.safe_load_file(path, permitted_classes: [Symbol],
      symbolize_names: true,
      aliases: true) || {}

    # Determine which section to use based on environment
    env = HTM::Config.env.to_sym
    base = config_data[:defaults] || {}
    env_overrides = config_data[env] || {}

    # Merge base with environment-specific overrides
    merged = deep_merge(base, env_overrides)

    apply_config(merged)

    warn "Loaded configuration from: #{path}"
    warn "Environment: #{env}"
  rescue => e
    warn "Error loading config file: #{e.message}"
    warn e.backtrace.first(5).join("\n") if ENV['DEBUG']
    exit 1
  end
end

.load_htm_rake_tasksObject



490
491
492
493
494
495
496
497
498
499
500
501
# File 'lib/htm/mcp/cli.rb', line 490

def load_htm_rake_tasks
  # Clear any existing tasks to avoid conflicts
  Rake::TaskManager. = true
  Rake.application = Rake::Application.new
  Rake.application.init('htm_mcp')

  # Load all HTM task files
  tasks_dir = File.expand_path('../../tasks', __dir__)
  Dir.glob(File.join(tasks_dir, '*.rake')).each do |rake_file|
    load rake_file
  end
end

.output_default_configObject



259
260
261
262
263
264
265
266
267
# File 'lib/htm/mcp/cli.rb', line 259

def output_default_config
  defaults_path = File.expand_path('../config/defaults.yml', __dir__)
  if File.exist?(defaults_path)
    puts File.read(defaults_path)
  else
    warn "Error: defaults.yml not found at #{defaults_path}"
    exit 1
  end
end


148
149
150
151
# File 'lib/htm/mcp/cli.rb', line 148

def print_error_suggestion(error_message)
  warn ""
  suggestion_lines_for(error_message.to_s.downcase).each { |line| warn line }
end


9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/htm/mcp/cli.rb', line 9

def print_help
  puts <<~HELP
    HTM MCP Server v#{HTM::VERSION} - Memory management for AI assistants

    USAGE:
      htm_mcp [COMMAND]

    COMMANDS:
      server    Start the MCP server (default if no command given)
      stdio     Alias for server (for MCP client compatibility)
      setup     Initialize the database schema
      init      Alias for setup
      verify    Verify database connection and extensions
      stats     Show memory statistics
      config    Output default configuration to STDOUT
      rake      Run HTM rake tasks (use -T [pattern] to list)
      version   Show HTM version
      help      Show this help message

    ENVIRONMENT VARIABLES:

      Note: Nested config uses double underscores (e.g., HTM_EMBEDDING__PROVIDER)

      Environment:
        HTM_ENV                       Environment name: development, test, production
                                      (priority: HTM_ENV > RAILS_ENV > RACK_ENV > 'development')

      Database:
        HTM_DATABASE__URL             PostgreSQL connection URL (preferred)
                                      Example: postgresql://user:pass@localhost:5432/htm_development
        HTM_DATABASE__HOST            Database host (default: localhost)
        HTM_DATABASE__PORT            Database port (default: 5432)
        HTM_DATABASE__NAME            Database name
        HTM_DATABASE__USER            Database username
        HTM_DATABASE__PASSWORD        Database password
        HTM_DATABASE__SSLMODE         SSL mode (default: prefer)
        HTM_DATABASE__POOL_SIZE       Connection pool size (default: 10)

      Embedding:
        HTM_EMBEDDING__PROVIDER       Provider (default: ollama)
        HTM_EMBEDDING__MODEL          Model (default: nomic-embed-text:latest)
        HTM_EMBEDDING__DIMENSIONS     Dimensions (default: 768)
        HTM_EMBEDDING__TIMEOUT        Timeout seconds (default: 120)
        HTM_EMBEDDING__MAX_DIMENSION  Max dimensions (default: 2000)

      Tag Extraction:
        HTM_TAG__PROVIDER             Provider (default: ollama)
        HTM_TAG__MODEL                Model (default: gemma3:latest)
        HTM_TAG__TIMEOUT              Timeout seconds (default: 180)
        HTM_TAG__MAX_DEPTH            Max hierarchy depth (default: 4)

      Proposition Extraction:
        HTM_PROPOSITION__PROVIDER     Provider (default: ollama)
        HTM_PROPOSITION__MODEL        Model (default: gemma3:latest)
        HTM_PROPOSITION__TIMEOUT      Timeout seconds (default: 180)
        HTM_PROPOSITION__ENABLED      Enable extraction (default: false)

      Chunking:
        HTM_CHUNKING__SIZE            Max chars per chunk (default: 1024)
        HTM_CHUNKING__OVERLAP         Chunk overlap chars (default: 64)

      Job Backend:
        HTM_JOB__BACKEND              Backend: inline, thread, active_job, sidekiq

      Provider API Keys:
        HTM_PROVIDERS__OLLAMA__URL           Ollama URL (default: http://localhost:11434)
        HTM_PROVIDERS__OPENAI__API_KEY       OpenAI API key
        HTM_PROVIDERS__ANTHROPIC__API_KEY    Anthropic API key
        HTM_PROVIDERS__GEMINI__API_KEY       Google Gemini API key
        HTM_PROVIDERS__AZURE__API_KEY        Azure OpenAI API key
        HTM_PROVIDERS__AZURE__ENDPOINT       Azure OpenAI endpoint

      Other:
        HTM_LOG_LEVEL                 Log level (default: info)
        HTM_CONNECTION_TIMEOUT        Connection timeout seconds (default: 30)
        HTM_TELEMETRY_ENABLED         Enable OpenTelemetry (default: false)

    OPTIONS:
      -c, --config [PATH]   Without PATH: output default config to STDOUT
                            With PATH: load config from YAML file

    EXAMPLES:
      # Generate a config file template
      htm_mcp --config > my_config.yml

      # Start server with custom config
      htm_mcp --config my_config.yml

      # First-time setup
      export HTM_DATABASE__URL="postgresql://postgres@localhost:5432/htm"
      htm_mcp setup

      # Verify connection
      htm_mcp verify

      # Use test database
      HTM_ENV=test htm_mcp setup
      HTM_ENV=test htm_mcp stats

      # Start MCP server (for Claude Desktop)
      htm_mcp

      # List available rake tasks
      htm_mcp rake -T
      htm_mcp rake --tasks

      # List tasks matching a pattern
      htm_mcp rake -T htm:jobs
      htm_mcp rake -T db

      # Run rake tasks
      htm_mcp rake htm:db:stats
      htm_mcp rake htm:tags:tree
      htm_mcp rake 'htm:tags:tree[database]'

    CLAUDE DESKTOP CONFIGURATION:
      Add to ~/.config/claude/claude_desktop_config.json:

      {
        "mcpServers": {
          "htm-memory": {
            "command": "/path/to/htm_mcp",
            "env": {
              "HTM_DATABASE__URL": "postgresql://postgres@localhost:5432/htm_development"
            }
          }
        }
      }
  HELP
end

.run(args) ⇒ Object



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'lib/htm/mcp/cli.rb', line 387

def run(args)
  args = args.dup

  # Handle -c / --config option first (can be combined with other commands)
  handle_config_option(args)

  # Process remaining command
  case args[0]&.downcase
  when 'help', '-h', '--help'
    print_help
  when 'version', '-v', '--version'
    puts "HTM #{HTM::VERSION}"
  when 'setup', 'init'
    run_setup
  when 'verify'
    run_verify
  when 'stats'
    run_stats
  when 'config'
    output_default_config
  when 'rake'
    run_rake(args[1..] || [])
  when 'server', 'stdio', nil
    # Return false to indicate server should start
    # 'stdio' is accepted for compatibility with MCP clients that pass it as an argument
    return false
  when /^-/
    warn "Unknown option: #{args[0]}"
    warn "Run 'htm_mcp help' for usage."
    exit 1
  else
    warn "Unknown command: #{args[0]}"
    warn "Run 'htm_mcp help' for usage."
    exit 1
  end
  true
end

.run_rake(args) ⇒ Object



453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
# File 'lib/htm/mcp/cli.rb', line 453

def run_rake(args)
  require 'rake'

  # Handle --tasks / -T to list available tasks (with optional pattern)
  if args.empty? || args.first == '--tasks' || args.first == '-T'
    # Check for optional pattern after -T/--tasks
    pattern = nil
    if ['--tasks', '-T'].include?(args.first)
      pattern = args[1] # May be nil if no pattern provided
    end
    list_rake_tasks(pattern: pattern)
    return
  end

  task_name = args.shift

  # Load HTM rake tasks
  load_htm_rake_tasks

  # Check if task exists
  unless Rake::Task.task_defined?(task_name)
    warn "Unknown rake task: #{task_name}"
    warn "Run 'htm_mcp rake --tasks' to see available tasks."
    exit 1
  end

  # Set remaining args as task arguments if any
  # Rake tasks use ARGV for arguments in brackets like task[arg1,arg2]
  begin
    Rake::Task[task_name].invoke
  rescue => e
    warn "Rake task failed: #{e.message}"
    warn e.backtrace.first(5).join("\n") if ENV['DEBUG']
    exit 1
  end
end

.run_setupObject



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/htm/mcp/cli.rb', line 164

def run_setup
  puts "HTM Database Setup"
  puts "=================="
  puts

  check_database_config!

  begin
    HTM::Database.setup
    puts
    puts "Database initialized successfully!"
    puts "You can now start the MCP server with: htm_mcp"
  rescue => e
    warn "Setup failed: #{e.message}"
    print_error_suggestion(e.message)
    warn e.backtrace.first(5).join("\n") if ENV['DEBUG']
    exit 1
  end
end

.run_statsObject



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/htm/mcp/cli.rb', line 351

def run_stats
  puts "HTM Memory Statistics"
  puts "====================="
  puts

  check_database_config!

  begin
    HTM::SequelConfig.establish_connection!

    total_nodes     = HTM::Models::Node.count
    deleted_nodes   = HTM::Models::Node.deleted.count
    with_embeddings = HTM::Models::Node.with_embeddings.count
    total_tags      = HTM::Models::Tag.count
    total_robots    = HTM::Models::Robot.count
    total_files     = HTM::Models::FileSource.count

    # Get database size
    db_size = HTM.db.fetch(
      "SELECT pg_size_pretty(pg_database_size(current_database())) AS size"
    ).first[:size]

    puts "Nodes:   #{total_nodes} active, #{deleted_nodes} deleted, #{with_embeddings} with embeddings"
    puts "Tags:    #{total_tags}"
    puts "Robots:  #{total_robots}"
    puts "Files:   #{total_files}"
    puts
    puts "Database size: #{db_size}"
  rescue => e
    warn "Stats failed: #{e.message}"
    print_error_suggestion(e.message)
    warn e.backtrace.first(5).join("\n") if ENV['DEBUG']
    exit 1
  end
end

.run_verifyObject



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/htm/mcp/cli.rb', line 184

def run_verify
  puts "HTM Database Verification"
  puts "========================="
  puts

  check_database_config!

  begin
    HTM::Database.info
    puts

    # Check migration status
    pending = check_migration_status
    puts

    if pending.positive?
      warn "Warning: #{pending} pending migration(s) detected."
      warn "  Run 'htm_mcp setup' to apply pending migrations."
      puts
    end

    puts "Database connection verified!"
  rescue => e
    warn "Verification failed: #{e.message}"
    print_error_suggestion(e.message)
    warn e.backtrace.first(5).join("\n") if ENV['DEBUG']
    exit 1
  end
end

.suggestion_lines_for(msg) ⇒ Object



546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
# File 'lib/htm/mcp/cli.rb', line 546

def suggestion_lines_for(msg)
  if msg.include?("does not exist") && !msg.include?("role")
    dbname = extract_dbname(ENV['HTM_DATABASE__URL'] || ENV.fetch('HTM_DATABASE__NAME', nil))
    ["Suggestion: The database does not exist. Create it with:",
     "  createdb #{dbname}",
     "Then initialize the schema with:", "  htm_mcp setup"]
  elsif msg.include?("password authentication failed") || msg.include?("no password supplied")
    ["Suggestion: Check your database credentials.",
     "Verify HTM_DATABASE__URL has correct username and password:",
     "  postgresql://USER:PASSWORD@localhost:5432/DATABASE"]
  elsif msg.include?("connection refused") || msg.include?("could not connect")
    ["Suggestion: PostgreSQL server is not running or not accepting connections.",
     "Start PostgreSQL with:",
     "  brew services start postgresql@17  # macOS with Homebrew",
     "  sudo systemctl start postgresql    # Linux"]
  elsif msg.include?("role") && msg.include?("does not exist")
    ["Suggestion: The database user does not exist. Create it with:",
     "  createuser -s YOUR_USERNAME"]
  elsif msg.include?("permission denied")
    ["Suggestion: The user lacks permission to access this database.",
     "Grant access or use a different user with appropriate privileges."]
  elsif msg.include?("timeout") || msg.include?("timed out")
    ["Suggestion: Connection timed out. Check:",
     "  - PostgreSQL is running",
     "  - Firewall allows connections on port 5432",
     "  - Host address is correct"]
  elsif msg.include?("extension") && msg.include?("vector")
    ["Suggestion: pgvector extension is not installed. Install it with:",
     "  brew install pgvector  # macOS",
     "Then enable it in your database:",
     "  psql -d DATABASE -c 'CREATE EXTENSION vector;'"]
  else
    ["Suggestion: Run 'htm_mcp help' for configuration details."]
  end
end