shadwire 0.2.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.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Lists the registry catalog: one entry (name/type/title/description) per
8
+ # published item. Read-only — needs a registry but no Rails app. With
9
+ # json: true it emits the catalog array; otherwise a "name — title" line each.
10
+ class List
11
+ def initialize(root:, registry: nil, json: false, ui: UI.new)
12
+ @root = root.to_s
13
+ @registry_override = registry
14
+ @json = json
15
+ @ui = ui
16
+ end
17
+
18
+ def call
19
+ client = RegistryClient.new(@registry_override || Config.load(@root).registry)
20
+ items = client.index.fetch("items", [])
21
+ emit(items)
22
+ items
23
+ end
24
+
25
+ private
26
+
27
+ def emit(items)
28
+ if @json
29
+ @ui.say(JSON.generate(items))
30
+ else
31
+ items.each { |item| @ui.say("#{item["name"]} — #{item["title"]}") }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Uninstalls one or more components. It deletes only files unique to the
8
+ # removed items — never the shared base (ui_component/ui_helper/shadwire.css)
9
+ # and never a file still listed by another installed entry — then prunes any
10
+ # emptied directory under the components root and drops the items from
11
+ # shadwire.json. Importmap pins that no remaining component uses are reported
12
+ # (not auto-removed — dep cleanup is left to the user). Confirms first unless
13
+ # --yes.
14
+ class Remove
15
+ def initialize(root:, names:, yes: false, registry: nil, json: false, ui: UI.new(yes:))
16
+ @root = root.to_s
17
+ @names = names
18
+ @yes = yes
19
+ @registry_override = registry
20
+ @json = json
21
+ @ui = ui
22
+ end
23
+
24
+ def call
25
+ raise Shadwire::Error, "remove requires at least one component name" if @names.empty?
26
+
27
+ Project.new(@root).raise_unless_rails!
28
+ config = Config.load(@root)
29
+ client = RegistryClient.new(@registry_override || config.registry)
30
+ @names.each do |name|
31
+ raise Shadwire::Error, "#{name} is not installed" unless config.installed.key?(name)
32
+ end
33
+
34
+ base_targets = client.index.fetch("base").fetch("files").map { |file| file["target"] }
35
+ removed_targets = targets_for(config, @names)
36
+ remaining = config.installed.keys - @names
37
+ other_targets = targets_for(config, remaining)
38
+
39
+ deletable = (removed_targets - base_targets - other_targets).select { |target| on_disk?(target) }
40
+ kept_shared = base_targets & removed_targets
41
+
42
+ unless @yes || @ui.confirm?("Remove #{deletable.size} files for #{@names.join(", ")}?")
43
+ @ui.say("Aborted; nothing removed.")
44
+ return { removed: [], deleted: [], keptShared: kept_shared, unusedPins: [] }
45
+ end
46
+
47
+ deletable.each { |target| File.delete(File.join(@root, target)) }
48
+ prune_empty_dirs(deletable, config)
49
+
50
+ @names.each { |name| config.forget(name) }
51
+ config.save
52
+
53
+ result = {
54
+ removed: @names,
55
+ deleted: deletable,
56
+ keptShared: kept_shared,
57
+ unusedPins: unused_pins(client, remaining)
58
+ }
59
+ emit(result)
60
+ result
61
+ end
62
+
63
+ private
64
+
65
+ def targets_for(config, names)
66
+ names.flat_map { |name| config.installed[name]["files"] }.uniq
67
+ end
68
+
69
+ def on_disk?(target)
70
+ File.exist?(File.join(@root, target))
71
+ end
72
+
73
+ # Removes now-empty directories from each deleted file's dir upward, bounded
74
+ # strictly inside the components root (never the root itself or above).
75
+ def prune_empty_dirs(deleted, config)
76
+ components_root = File.expand_path(File.join(@root, config.aliases.fetch("components", "app/components")))
77
+ deleted.each do |target|
78
+ dir = File.dirname(File.expand_path(File.join(@root, target)))
79
+ while dir.start_with?("#{components_root}#{File::SEPARATOR}") && File.directory?(dir) && Dir.empty?(dir)
80
+ Dir.rmdir(dir)
81
+ dir = File.dirname(dir)
82
+ end
83
+ end
84
+ end
85
+
86
+ # Pins of the removed items that no still-installed item pins.
87
+ def unused_pins(client, remaining)
88
+ remaining_names = remaining.map { |name| pins_for(client, name).map { |pin| pin["name"] } }.flatten
89
+ @names.flat_map { |name| pins_for(client, name) }
90
+ .reject { |pin| remaining_names.include?(pin["name"]) }
91
+ .uniq
92
+ end
93
+
94
+ def pins_for(client, name)
95
+ Array(client.item(name)["importmap"])
96
+ end
97
+
98
+ def emit(result)
99
+ return @ui.say(JSON.generate(result)) if @json
100
+
101
+ human(result)
102
+ end
103
+
104
+ def human(result)
105
+ return if result[:removed].empty?
106
+
107
+ @ui.say("Removed #{result[:removed].join(", ")}.")
108
+ result[:deleted].each { |target| @ui.say(" delete #{target}") }
109
+ result[:keptShared].each { |target| @ui.say(" keep #{target} (shared)") }
110
+ result[:unusedPins].each do |pin|
111
+ @ui.say(%( note importmap pin "#{pin["name"]}" is now unused; review config/importmap.rb))
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Filters the registry catalog by a case-insensitive substring match on an
8
+ # item's name, title, description, or whenToUse. Read-only. With json: true
9
+ # it emits the matching catalog entries; otherwise a "name — title" line each.
10
+ #
11
+ # whenToUse is searched because it is where the disambiguating vocabulary
12
+ # lives ("form", "modal", "overlay"); without it a search for "form" misses
13
+ # most of the form controls.
14
+ class Search
15
+ SEARCHABLE = %w[name title description whenToUse].freeze
16
+
17
+ def initialize(root:, query:, registry: nil, json: false, ui: UI.new)
18
+ @root = root.to_s
19
+ @query = query.to_s
20
+ @registry_override = registry
21
+ @json = json
22
+ @ui = ui
23
+ end
24
+
25
+ def call
26
+ client = RegistryClient.new(@registry_override || Config.load(@root).registry)
27
+ matches = filter(client.index.fetch("items", []))
28
+ emit(matches)
29
+ matches
30
+ end
31
+
32
+ private
33
+
34
+ def filter(items)
35
+ needle = @query.downcase
36
+ items.select do |item|
37
+ SEARCHABLE.any? { |key| item[key].to_s.downcase.include?(needle) }
38
+ end
39
+ end
40
+
41
+ def emit(matches)
42
+ if @json
43
+ @ui.say(JSON.generate(matches))
44
+ elsif matches.empty?
45
+ @ui.say(%(No matches for "#{@query}".))
46
+ else
47
+ matches.each { |item| @ui.say("#{item["name"]} — #{item["title"]}") }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Reports the consuming app's Shadwire context: stack detection, the
8
+ # components installed with the helper methods they actually define, and
9
+ # per-component drift.
10
+ #
11
+ # This is the payload the agent skill injects, so it must always succeed. A
12
+ # directory that is not a Rails app, a missing shadwire.json, or an
13
+ # unreachable registry are all reported as data — never raised — because an
14
+ # exception here would leave an agent starting from a backtrace instead of
15
+ # its project context.
16
+ class Status
17
+ def initialize(root:, registry: nil, json: false, ui: UI.new)
18
+ @root = root.to_s
19
+ @registry_override = registry
20
+ @json = json
21
+ @ui = ui
22
+ end
23
+
24
+ def call
25
+ project = Project.new(@root)
26
+ config = Config.load(@root)
27
+ result = context(project, config)
28
+
29
+ begin
30
+ client = RegistryClient.new(@registry_override || config.registry)
31
+ result["registryVersion"] = client.index["version"]
32
+ result["availableCount"] = Array(client.index["items"]).size
33
+ result["installed"] = installed(config, client)
34
+ rescue Shadwire::Error => e
35
+ result["registryError"] = e.message
36
+ end
37
+
38
+ emit(result)
39
+ result
40
+ end
41
+
42
+ private
43
+
44
+ def context(project, config)
45
+ {
46
+ "rails" => project.rails?,
47
+ "configPresent" => File.exist?(File.join(@root, Config::CONFIG_FILE)),
48
+ "registry" => @registry_override || config.registry,
49
+ "registryVersion" => nil,
50
+ "availableCount" => nil,
51
+ "stack" => {
52
+ "importmap" => project.importmap?,
53
+ "stimulus" => project.stimulus?,
54
+ "tailwindcssRails" => project.tailwindcss_rails?
55
+ },
56
+ "gems" => {
57
+ "view_component" => project.gem?("view_component"),
58
+ "lucide-rails" => project.gem?("lucide-rails")
59
+ },
60
+ "tailwind" => {
61
+ "css" => config.tailwind_css,
62
+ "importPresent" => tailwind_import?(config)
63
+ },
64
+ "helpers" => {
65
+ "includeAllHelpers" => project.include_all_helpers?,
66
+ "legacyHelperPresent" => project.legacy_helper?
67
+ },
68
+ "installed" => []
69
+ }
70
+ end
71
+
72
+ def tailwind_import?(config)
73
+ path = File.join(@root, config.tailwind_css)
74
+ File.exist?(path) && File.read(path).include?("shadwire.css")
75
+ end
76
+
77
+ def installed(config, client)
78
+ config.installed.map do |name, entry|
79
+ item = client.item(name)
80
+ components = Array(item.dig("api", "components"))
81
+
82
+ {
83
+ "name" => name,
84
+ "version" => entry["version"],
85
+ "drift" => drift(item),
86
+ "helpers" => components.filter_map { |component| component["helper"] },
87
+ "classes" => components.filter_map { |component| component["class"] }
88
+ }
89
+ rescue Shadwire::Error
90
+ # The item vanished from the registry; report it rather than failing.
91
+ { "name" => name, "version" => entry["version"], "drift" => "unknown",
92
+ "helpers" => [], "classes" => [] }
93
+ end
94
+ end
95
+
96
+ # Worst status across the item's files: missing beats modified beats
97
+ # unchanged, so a single number tells an agent whether to look closer.
98
+ def drift(item)
99
+ statuses = Array(item["files"]).map do |file|
100
+ path = File.join(@root, file["target"])
101
+ next "missing" unless File.exist?(path)
102
+
103
+ File.read(path) == file["content"] ? "unchanged" : "modified"
104
+ end
105
+
106
+ return "missing" if statuses.include?("missing")
107
+
108
+ statuses.include?("modified") ? "modified" : "unchanged"
109
+ end
110
+
111
+ def emit(result)
112
+ return @ui.say(JSON.generate(result)) if @json
113
+
114
+ human(result)
115
+ end
116
+
117
+ def human(result)
118
+ @ui.say("Rails app: #{yes_no(result["rails"])} shadwire.json: #{yes_no(result["configPresent"])}")
119
+ @ui.say("Registry: #{result["registry"]} (#{result["registryVersion"] || "unavailable"})")
120
+ @ui.say("Stack: importmap=#{result.dig("stack", "importmap")} " \
121
+ "stimulus=#{result.dig("stack", "stimulus")} " \
122
+ "tailwindcss-rails=#{result.dig("stack", "tailwindcssRails")}")
123
+ @ui.say("Installed (#{result["installed"].size} of #{result["availableCount"] || "?"}):")
124
+ result["installed"].each do |item|
125
+ @ui.say(" #{item["drift"].ljust(9)} #{item["name"]} #{item["helpers"].join(", ")}")
126
+ end
127
+
128
+ @ui.warn("Registry unavailable: #{result["registryError"]}") if result["registryError"]
129
+ return unless result.dig("helpers", "legacyHelperPresent")
130
+
131
+ @ui.warn("Note: app/helpers/ui_helper.rb is left over from an older install; " \
132
+ "helpers now live in app/helpers/ui/ and it can be deleted.")
133
+ end
134
+
135
+ def yes_no(value)
136
+ value ? "yes" : "no"
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Re-applies the registry version of installed components. With no names it
8
+ # updates every installed item. It surfaces local drift (via Differ) before
9
+ # writing so edits are never clobbered silently, then reuses add's overwrite
10
+ # policy, dependency handling, and installed bookkeeping. --yes/--overwrite
11
+ # skip the per-file prompt; --no-deps limits the update to the named items.
12
+ class Update
13
+ def initialize(root:, names: [], yes: false, overwrite: false, no_deps: false,
14
+ registry: nil, json: false, ui: UI.new(yes:), runner: Dependencies::DEFAULT_RUNNER)
15
+ @root = root.to_s
16
+ @names = names
17
+ @yes = yes
18
+ @overwrite = overwrite
19
+ @no_deps = no_deps
20
+ @registry_override = registry
21
+ @json = json
22
+ @ui = ui
23
+ @runner = runner
24
+ end
25
+
26
+ def call
27
+ project = Project.new(@root)
28
+ project.raise_unless_rails!
29
+
30
+ config = Config.load(@root)
31
+ client = RegistryClient.new(@registry_override || config.registry)
32
+ targets = resolve_targets(config)
33
+
34
+ items = resolve_items(targets, client)
35
+ files = items.flat_map { |item| item.fetch("files") }
36
+
37
+ diffs = compute_diffs(files) # read local drift before any overwrite
38
+ report = install_files(files)
39
+
40
+ deps = Dependencies.new(project, runner: @runner)
41
+ dep_confirm = ->(desc) { @ui.confirm?(desc) }
42
+ gems = deps.ensure_gems(union(items, "gems"), yes: @yes, confirm: dep_confirm)
43
+ pins = deps.ensure_importmap_pins(union(items, "importmap"), yes: @yes, confirm: dep_confirm)
44
+
45
+ record_installed(config, client, targets, items)
46
+ config.save
47
+
48
+ result = { updated: targets, report:, gems:, pins:, diffs: }
49
+ emit(result)
50
+ Dependencies.raise_on_failure!(gems, pins)
51
+
52
+ result
53
+ end
54
+
55
+ private
56
+
57
+ def resolve_targets(config)
58
+ targets = @names.empty? ? config.installed.keys : @names
59
+ targets.each do |name|
60
+ raise Shadwire::Error, "#{name} is not installed" unless config.installed.key?(name)
61
+ end
62
+ targets
63
+ end
64
+
65
+ def resolve_items(targets, client)
66
+ @no_deps ? targets.map { |name| client.item(name) } : Resolver.expand(targets, client)
67
+ end
68
+
69
+ def compute_diffs(files)
70
+ diffs = {}
71
+ files.each do |file|
72
+ target = file["target"]
73
+ path = File.join(@root, target)
74
+ next unless File.exist?(path)
75
+
76
+ local = File.read(path)
77
+ diffs[target] = Differ.unified(local, file["content"], path: target) unless local == file["content"]
78
+ end
79
+ diffs
80
+ end
81
+
82
+ def install_files(files)
83
+ if @yes || @overwrite
84
+ Installer.new(@root, overwrite: :always).install(files)
85
+ else
86
+ Installer.new(@root, overwrite: :prompt, confirm: content_aware_confirm(files)).install(files)
87
+ end
88
+ end
89
+
90
+ # Skips files whose on-disk content already matches the registry; prompts
91
+ # only for locally-modified files.
92
+ def content_aware_confirm(files)
93
+ by_target = files.to_h { |file| [file["target"], file["content"]] }
94
+ lambda do |target|
95
+ current = File.read(File.join(@root, target))
96
+ return false if current == by_target[target]
97
+
98
+ @ui.confirm?("Overwrite modified #{target}?")
99
+ end
100
+ end
101
+
102
+ def union(items, key)
103
+ items.flat_map { |item| Array(item[key]) }.uniq
104
+ end
105
+
106
+ def record_installed(config, client, targets, items)
107
+ by_name = items.to_h { |item| [item["name"], item] }
108
+ version = client.index["version"]
109
+ targets.each do |name|
110
+ item = by_name[name] || client.item(name)
111
+ file_targets = item.fetch("files").map { |file| file["target"] }
112
+ config.record_installed(name, version, file_targets)
113
+ end
114
+ end
115
+
116
+ def emit(result)
117
+ return @ui.say(JSON.generate(json_payload(result))) if @json
118
+
119
+ human(result)
120
+ end
121
+
122
+ # `overwritten` is the whole point: update clobbers local edits, and the
123
+ # human path prints the diff. Omitting it from JSON let an agent destroy
124
+ # customizations without ever being told they existed.
125
+ def json_payload(result)
126
+ {
127
+ updated: result[:updated],
128
+ written: result[:report][:written],
129
+ skipped: result[:report][:skipped],
130
+ overwritten: result[:diffs].keys & result[:report][:written],
131
+ diffs: result[:diffs],
132
+ gems: result[:gems][:applied],
133
+ gemsFailed: result[:gems][:failed],
134
+ pins: result[:pins][:applied],
135
+ pinsFailed: result[:pins][:failed]
136
+ }
137
+ end
138
+
139
+ def human(result)
140
+ @ui.say("Updated #{result[:updated].join(", ")}.")
141
+ result[:diffs].each_value { |rendered| @ui.say(rendered) }
142
+ result[:report][:written].each { |target| @ui.say(" write #{target}") }
143
+ result[:report][:skipped].each { |target| @ui.say(" skip #{target}") }
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Shadwire
7
+ # Reads and writes a consuming Rails app's shadwire.json.
8
+ #
9
+ # On-disk JSON shape (mirrors shadcn's components.json conventions):
10
+ #
11
+ # {
12
+ # "registry": "https://edumoraes.github.io/shadwire/r",
13
+ # "tailwind": { "css": "app/assets/tailwind/application.css" },
14
+ # "aliases": {
15
+ # "components": "app/components",
16
+ # "ui": "app/components/ui",
17
+ # "helpers": "app/helpers",
18
+ # "controllers": "app/javascript/controllers",
19
+ # "vendorCss": "vendor/shadwire"
20
+ # },
21
+ # "installed": {
22
+ # "button": { "version": "1.0.0", "files": ["app/components/ui/button_component.rb"] }
23
+ # }
24
+ # }
25
+ class Config
26
+ CONFIG_FILE = "shadwire.json"
27
+
28
+ DEFAULTS = {
29
+ "registry" => "https://edumoraes.github.io/shadwire/r",
30
+ "tailwind" => { "css" => "app/assets/tailwind/application.css" },
31
+ "aliases" => {
32
+ "components" => "app/components",
33
+ "ui" => "app/components/ui",
34
+ "helpers" => "app/helpers",
35
+ "controllers" => "app/javascript/controllers",
36
+ "vendorCss" => "vendor/shadwire"
37
+ },
38
+ "installed" => {}
39
+ }.freeze
40
+
41
+ attr_reader :root, :tailwind_css, :aliases, :installed
42
+ attr_accessor :registry
43
+
44
+ # Loads shadwire.json from the given app root, or returns a Config with
45
+ # defaults when the file does not exist.
46
+ def self.load(root)
47
+ path = File.join(root, CONFIG_FILE)
48
+ data = File.exist?(path) ? parse(path) : JSON.parse(JSON.generate(DEFAULTS))
49
+ new(root, data)
50
+ end
51
+
52
+ # A hand-edited shadwire.json is common enough that a raw JSON::ParserError
53
+ # backtrace is the wrong answer; name the file and the problem.
54
+ def self.parse(path)
55
+ JSON.parse(File.read(path))
56
+ rescue JSON::ParserError => e
57
+ raise ConfigError, "#{path} is not valid JSON: #{e.message}"
58
+ end
59
+
60
+ # Writes pretty JSON to <root>/shadwire.json.
61
+ def save
62
+ FileUtils.mkdir_p(@root)
63
+ File.write(File.join(@root, CONFIG_FILE), JSON.pretty_generate(to_h) + "\n")
64
+ end
65
+
66
+ # Records an installed component: installed[name] = { "version" => ..., "files" => [...] }.
67
+ def record_installed(name, version, files)
68
+ @installed[name] = { "version" => version, "files" => files }
69
+ end
70
+
71
+ # Removes an entry from installed.
72
+ def forget(name)
73
+ @installed.delete(name)
74
+ end
75
+
76
+ private
77
+
78
+ def initialize(root, data)
79
+ @root = root
80
+ @registry = data.fetch("registry", DEFAULTS["registry"])
81
+ @tailwind_css = data.dig("tailwind", "css") || DEFAULTS.dig("tailwind", "css")
82
+ @aliases = data.fetch("aliases", JSON.parse(JSON.generate(DEFAULTS["aliases"])))
83
+ @installed = data.fetch("installed", {})
84
+ end
85
+
86
+ def to_h
87
+ {
88
+ "registry" => @registry,
89
+ "tailwind" => { "css" => @tailwind_css },
90
+ "aliases" => @aliases,
91
+ "installed" => @installed
92
+ }
93
+ end
94
+ end
95
+ end