Class: RubynCode::Skills::RegistryClient

Inherits:
Object
  • Object
show all
Defined in:
lib/rubyn_code/skills/registry_client.rb

Overview

HTTP client for the rubyn.ai skill packs registry API. Supports ETag-based conditional requests for efficient cache validation and offline resilience.

Constant Summary collapse

LEADING_SLASHES_REGEX =
%r{\A/+}
DEFAULT_BASE_URL =
'https://rubyn.ai'
TIMEOUT_SECONDS =
10
USER_ACCEPT_HEADER =
'Rubyn Code'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_url: nil) ⇒ RegistryClient

Returns a new instance of RegistryClient.



20
21
22
# File 'lib/rubyn_code/skills/registry_client.rb', line 20

def initialize(base_url: nil)
  @base_url = base_url || ENV.fetch('RUBYN_REGISTRY_URL', DEFAULT_BASE_URL)
end

Instance Attribute Details

#base_urlObject (readonly)

Returns the value of attribute base_url.



18
19
20
# File 'lib/rubyn_code/skills/registry_client.rb', line 18

def base_url
  @base_url
end

Instance Method Details

#fetch_catalog(etag: nil) ⇒ Hash

Fetch the full catalog of available skill packs.

Parameters:

  • etag (String, nil) (defaults to: nil)

    cached ETag for conditional request

Returns:

  • (Hash)

    { data: Array<Hash>, etag: String|nil, not_modified: Boolean }

Raises:



38
39
40
41
42
43
44
45
46
47
# File 'lib/rubyn_code/skills/registry_client.rb', line 38

def fetch_catalog(etag: nil)
  response = conditional_get('/api/v1/skills/packs.json', etag: etag)
  return not_modified_result if response.status == 304

  data = validate_and_parse(response)
  packs = normalize_packs(data)
  { data: packs, etag: response.headers['etag'], not_modified: false }
rescue Faraday::Error => e
  raise RegistryError, "Failed to fetch skill catalog: #{e.message}"
end

#fetch_file(pack_name, file_path, etag: nil) ⇒ Hash

Fetch a single skill file’s markdown content.

Parameters:

  • pack_name (String)
  • file_path (String)
  • etag (String, nil) (defaults to: nil)

    cached ETag for conditional request

Returns:

  • (Hash)

    { content: String, etag: String|nil, not_modified: Boolean }

Raises:



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/rubyn_code/skills/registry_client.rb', line 118

def fetch_file(pack_name, file_path, etag: nil)
  validate_pack_name!(pack_name)
  safe_path = file_path.to_s.gsub('..', '').gsub(LEADING_SLASHES_REGEX, '')
  response = connection.get(
    "/api/v1/skills/packs/#{encode_name(pack_name)}/files/#{ERB::Util.url_encode(safe_path)}"
  ) do |req|
    req.headers['If-None-Match'] = etag if etag
  end

  return { content: nil, etag: nil, not_modified: true } if response.status == 304
  return { content: response.body, etag: response.headers['etag'], not_modified: false } if response.success?

  raise RegistryError, "Failed to fetch file '#{file_path}' from pack '#{pack_name}'"
rescue Faraday::Error => e
  raise RegistryError, "Failed to fetch file '#{file_path}': #{e.message}"
end

#fetch_pack(name, etag: nil) ⇒ Hash

Fetch a single pack’s full content for installation. Fetches pack metadata and all skill file contents.

Parameters:

  • name (String)

    pack name (validated for safe characters)

  • etag (String, nil) (defaults to: nil)

    cached ETag for conditional request

Returns:

  • (Hash)

    { data: Hash, etag: String|nil, not_modified: Boolean }

Raises:

  • (RegistryError)

    on not found, validation, or network failure



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/rubyn_code/skills/registry_client.rb', line 92

def fetch_pack(name, etag: nil)
  validate_pack_name!(name)
  response = conditional_get("/api/v1/skills/packs/#{encode_name(name)}.json", etag: etag)
  return not_modified_result if response.status == 304

  data = validate_and_parse(response)
  validate_pack_response!(data, name)

  # Fetch individual file contents. Manifests may list files under
  # :files (as { path: ... } hashes) or :skills (as filename strings).
  files = fetch_key(data, :files) || fetch_key(data, :skills) || []
  files = files.map { |f| f.is_a?(String) ? { path: f } : f }
  data[:files] = fetch_files_with_content(name, files)

  { data: data, etag: response.headers['etag'], not_modified: false }
rescue Faraday::Error => e
  raise RegistryError, "Failed to fetch pack '#{name}': #{e.message}"
end

#fetch_suggestions(gems) ⇒ Array<Hash>

Fetch pack suggestions based on detected gems.

Parameters:

  • gems (Array<String>)

    list of gem names detected in the project

Returns:

  • (Array<Hash>)

    array of { name: String, reason: String }

Raises:



71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/rubyn_code/skills/registry_client.rb', line 71

def fetch_suggestions(gems)
  return [] if gems.empty?

  gems_param = gems.join(',')
  response = connection.get('/api/v1/skills/packs/suggest', { gems: gems_param })
  return [] if response.status == 404

  data = validate_and_parse(response)
  suggestions = data[:suggestions] || data['suggestions'] || []
  suggestions.is_a?(Array) ? suggestions : []
rescue Faraday::Error => e
  raise RegistryError, "Failed to fetch suggestions: #{e.message}"
end

#list_packs(etag: nil) ⇒ Array<Hash>

List all available packs (returns flat array for CLI commands).

Parameters:

  • etag (String, nil) (defaults to: nil)

    cached ETag for conditional request

Returns:

  • (Array<Hash>)

    array of pack metadata

Raises:



29
30
31
# File 'lib/rubyn_code/skills/registry_client.rb', line 29

def list_packs(etag: nil)
  fetch_catalog(etag: etag)[:data] || []
end

#search_packs(query, etag: nil) ⇒ Hash

Search packs by keyword. Note: The registry API does not support server-side search. This method fetches the catalog and filters locally.

Parameters:

  • query (String)
  • etag (String, nil) (defaults to: nil)

    cached ETag for conditional request

Returns:

  • (Hash)

    { data: Array<Hash>, etag: String|nil, not_modified: Boolean }

Raises:



57
58
59
60
61
62
63
64
# File 'lib/rubyn_code/skills/registry_client.rb', line 57

def search_packs(query, etag: nil)
  catalog = fetch_catalog(etag: etag)
  return catalog if catalog[:not_modified]

  q = query.to_s.downcase
  filtered = catalog[:data].select { |pack| matches_query?(pack, q) }
  { data: filtered, etag: catalog[:etag], not_modified: false }
end