@zalom/plastic 1.0.0-alpha.18 → 1.0.0-alpha.19

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.
@@ -2,738 +2,133 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # Plastic installer — runs via npx shim or directly.
6
- # Usage: ruby scripts/install.rb [--claude] [--codex] [--hermes] [--all] [--alpha] [--beta] [--latest] [--uninstall] [--force] [--help]
7
-
8
- require "json"
9
- require "yaml"
10
- require "fileutils"
11
- require "digest"
12
-
13
- PACKAGE_ROOT = ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)
14
- PLASTIC_HOME = File.join(Dir.home, ".plastic")
15
- VERSION = File.read(File.join(PACKAGE_ROOT, "package.json")).then { |s| JSON.parse(s)["version"] }
16
-
17
- AGENTS = [
18
- { key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude" },
19
- { key: "codex", name: "Codex CLI", dir: File.join(Dir.home, ".agents"), flag: "--codex" },
20
- { key: "hermes", name: "Hermes", dir: File.join(Dir.home, ".hermes"), flag: "--hermes" },
21
- ].freeze
22
-
23
- def main
24
- flags = parse_flags(ARGV)
25
-
26
- if flags[:help]
27
- show_help
28
- return
29
- end
30
-
31
- puts "\n\u{1f9e0} Plastic v#{VERSION}\n\n"
32
-
33
- if flags[:uninstall]
34
- handle_uninstall(flags[:agents].empty? ? ["claude"] : flags[:agents])
35
- return
36
- end
37
-
38
- agents = flags[:agents].empty? ? prompt_agents : flags[:agents]
39
-
40
- if agents.empty?
41
- puts "No agents selected. Nothing to do."
42
- return
43
- end
44
-
45
- mode = File.exist?(File.join(PLASTIC_HOME, "INDEX.md")) ? :update : :install
46
- puts "Mode: #{mode}"
47
- puts "Agents: #{agents.map { |k| agent_config(k)[:name] }.join(", ")}\n\n"
48
-
49
- distribute(mode)
50
- bootstrap if mode == :install
51
-
52
- results = agents.map { |key| install_for_agent(key, flags[:force]) }
53
-
54
- puts "\n\u{2014} Results \u{2014}\n\n"
55
- results.each do |r|
56
- if r[:success]
57
- puts " \u{2705} #{r[:agent]}: #{r[:files]} files installed"
58
- else
59
- puts " \u{26a0}\u{fe0f} #{r[:agent]}: #{r[:reason]}"
5
+ # Plastic — `install` verb. Runs via `npx @zalom/plastic install` (bin/plastic.js) or directly.
6
+ # Usage: ruby scripts/install.rb [--claude|--codex|--hermes|--all] [--alpha|--beta|--latest] [--reinstall] [--force] [--help]
7
+ #
8
+ # One-shot by design:
9
+ # - no install present -> fresh install + bootstrap store
10
+ # - already installed -> refuse (point at `update` / `--reinstall`)
11
+ # - --reinstall -> re-sync core files for the installed version (repair); store untouched
12
+ #
13
+ # `update` and `versions` delegate their file-sync here via `--reinstall --ledger-action <action>`,
14
+ # so install.rb is the single file-syncer; the ledger action is contextual.
15
+
16
+ require_relative "lib/installer_core"
17
+
18
+ class Install < InstallerCore
19
+ def cli(argv = ARGV)
20
+ if argv.include?("--help") || argv.include?("-h")
21
+ show_help
22
+ return 0
60
23
  end
61
- end
62
-
63
- installed = results.select { |r| r[:success] }
64
- if installed.any?
65
- puts "\n\u{2705} Plastic v#{VERSION} #{mode == :update ? "updated" : "installed"}."
66
- puts " Registered for: #{installed.map { |r| r[:agent] }.join(", ")}"
67
- puts " Run /clear (or restart your agent) to pick up new conventions.\n\n"
68
- end
69
- end
70
-
71
- # --- Flag parsing ---
72
24
 
73
- def parse_flags(argv)
74
- flags = { agents: [], force: false, uninstall: false, help: false, channel: "latest" }
25
+ force = argv.include?("--force")
26
+ reinstall = argv.include?("--reinstall")
27
+ ledger_action = flag_value(argv, "--ledger-action")
75
28
 
76
- argv.each do |arg|
77
- case arg
78
- when "--all" then flags[:agents] = AGENTS.map { |a| a[:key] }
79
- when "--force" then flags[:force] = true
80
- when "--uninstall" then flags[:uninstall] = true
81
- when "--help", "-h" then flags[:help] = true
82
- when "--alpha" then flags[:channel] = "alpha"
83
- when "--beta" then flags[:channel] = "beta"
84
- when "--latest" then flags[:channel] = "latest"
85
- else
86
- agent = AGENTS.find { |a| a[:flag] == arg }
87
- flags[:agents] << agent[:key] if agent
29
+ selected = agent_keys_from(argv)
30
+ selected = prompt_agents if selected.empty?
31
+ if selected.empty?
32
+ puts "No agents selected. Nothing to do."
33
+ return 0
88
34
  end
89
- end
90
35
 
91
- flags
92
- end
93
-
94
- def prompt_agents
95
- unless $stdin.tty?
96
- return ["claude"]
97
- end
98
-
99
- puts "Which agents should Plastic register for?\n\n"
100
- AGENTS.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:dir]})" }
101
- puts " #{AGENTS.size + 1}. All"
102
- puts
103
-
104
- print "Select (comma-separated numbers, or Enter for Claude Code): "
105
- answer = $stdin.gets&.strip || ""
106
-
107
- return ["claude"] if answer.empty?
108
-
109
- nums = answer.split(",").map { |n| n.strip.to_i }
110
- return AGENTS.map { |a| a[:key] } if nums.include?(AGENTS.size + 1)
111
-
112
- nums.select { |n| n >= 1 && n <= AGENTS.size }.map { |n| AGENTS[n - 1][:key] }
113
- end
114
-
115
- def show_help
116
- puts <<~HELP
117
-
118
- plastic - Intent-driven idea development system
119
-
120
- Usage:
121
- npx @zalom/plastic [options]
36
+ puts "\n\u{1f9e0} Plastic v#{version}\n\n"
122
37
 
123
- Agent options:
124
- --claude Install for Claude Code
125
- --codex Install for Codex CLI
126
- --hermes Install for Hermes
127
- --all Install for all supported agents
128
-
129
- Channel options:
130
- --latest Install from stable channel (default)
131
- --beta Install from beta channel
132
- --alpha Install from alpha channel
133
-
134
- Other options:
135
- --force Overwrite existing files without prompting
136
- --uninstall Remove Plastic from agent directories
137
- -h, --help Show this help
138
-
139
- Examples:
140
- npx @zalom/plastic --claude Install stable for Claude Code
141
- npx @zalom/plastic@alpha --claude Install alpha for Claude Code
142
- npx @zalom/plastic@beta --claude Install beta for Claude Code
143
- npx @zalom/plastic --all Install stable for all agents
144
- npx @zalom/plastic --uninstall Remove from agent directories
145
-
146
- HELP
147
- end
148
-
149
- # --- Distribution phase ---
150
-
151
- def distribute(mode)
152
- puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{PLASTIC_HOME}"
153
-
154
- FileUtils.mkdir_p(PLASTIC_HOME)
155
- FileUtils.mkdir_p(File.join(PLASTIC_HOME, "scripts", "lib"))
156
-
157
- core_files = {
158
- "PLASTIC.md" => "PLASTIC.md",
159
- "deprecations.yml" => "deprecations.yml",
160
- "scripts/folgezettel-id" => "scripts/folgezettel-id",
161
- "scripts/read-config" => "scripts/read-config",
162
- "scripts/select-update-target" => "scripts/select-update-target",
163
- "scripts/hook-session-start" => "scripts/hook-session-start",
164
- "scripts/hook-continue" => "scripts/hook-continue",
165
- "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
166
- "scripts/hook-gate-check" => "scripts/hook-gate-check",
167
- "scripts/hook-code-gate" => "scripts/hook-code-gate",
168
- "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
169
- "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
170
- "scripts/doctor.rb" => "scripts/doctor.rb",
171
- "scripts/dashboard.rb" => "scripts/dashboard.rb",
172
- }
173
-
174
- core_files.each do |src, dest|
175
- src_path = File.join(PACKAGE_ROOT, src)
176
- dest_path = File.join(PLASTIC_HOME, dest)
177
- FileUtils.cp(src_path, dest_path) if File.exist?(src_path)
178
- end
179
-
180
- File.write(File.join(PLASTIC_HOME, "VERSION"), "#{VERSION}\n")
181
-
182
- Dir.glob(File.join(PLASTIC_HOME, "scripts", "*")).each { |f| FileUtils.chmod(0o755, f) }
183
-
184
- puts " \u{2705} Core files synced (v#{VERSION})"
185
- end
186
-
187
- def bootstrap
188
- puts " \u{1f331} First install \u{2014} bootstrapping store..."
189
-
190
- FileUtils.mkdir_p(File.join(PLASTIC_HOME, "store"))
191
- FileUtils.mkdir_p(File.join(PLASTIC_HOME, "projects"))
192
-
193
- write_if_missing(File.join(PLASTIC_HOME, "config.yml"), <<~YAML)
194
- version: 3
195
- execution_mode: subagent-driven
196
- stale_threshold_days: 3
197
- hash_length: 6
198
- hash_algorithm: sha256-base36
199
- max_slug_words: 5
200
- agent:
201
- type: claude-code
202
- parallel_mode: agent-teams
203
- YAML
204
-
205
- write_if_missing(File.join(PLASTIC_HOME, "projects.yml"), "---\nprojects: {}\n")
206
-
207
- write_if_missing(File.join(PLASTIC_HOME, "INDEX.md"), <<~MD)
208
- # Index
209
-
210
- ## Active
211
-
212
- ## Future
213
-
214
- ## Clusters
215
-
216
- ## Abandoned
217
-
218
- ## Completed
219
- MD
220
-
221
- write_if_missing(File.join(PLASTIC_HOME, "AGENTS.md"), <<~MD)
222
- # Plastic \u{2014} Agent Instructions
223
-
224
- Read `PLASTIC.md` in this directory. It contains all Plastic conventions.
225
- Follow it exactly. Never modify it \u{2014} it is overwritten on plugin updates.
226
-
227
- This file (`AGENTS.md`) is where project-specific rules live.
228
-
229
- ---
230
- MD
231
-
232
- puts " \u{2705} Store bootstrapped"
233
- end
234
-
235
- def bootstrap_project_store(slug)
236
- project_dir = File.join(PLASTIC_HOME, "projects", slug)
237
- store_dir = File.join(project_dir, "store")
238
-
239
- FileUtils.mkdir_p(store_dir)
240
-
241
- template = File.join(PACKAGE_ROOT, "templates", "project.yml")
242
- dest = File.join(project_dir, "project.yml")
243
- write_if_missing(dest, File.read(template)) if File.exist?(template)
244
-
245
- write_if_missing(File.join(project_dir, "INDEX.md"), <<~MD)
246
- # Index
247
-
248
- ## Active
249
-
250
- ## Future
251
-
252
- ## Clusters
253
-
254
- ## Abandoned
255
-
256
- ## Completed
257
- MD
258
- end
259
-
260
- # --- Agent adapters ---
261
-
262
- def manifest_path_for(key, config)
263
- case key
264
- when "claude" then File.join(config[:dir], "plastic", "manifest.json")
265
- else File.join(config[:dir], "plastic-manifest.json")
266
- end
267
- end
268
-
269
- def manifest_files(manifest_path)
270
- return [] unless File.exist?(manifest_path)
271
- data = JSON.parse(File.read(manifest_path)) rescue {}
272
- (data["files"] || {}).keys
273
- end
274
-
275
- def install_for_agent(key, force)
276
- config = agent_config(key)
277
- return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
278
-
279
- unless File.directory?(config[:dir])
280
- return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
281
- end
282
-
283
- # Capture the prior manifest so we can prune files that no longer ship
284
- # (renamed/removed skills) after a re-copy. This gives leftover-free updates.
285
- old_files = manifest_files(manifest_path_for(key, config))
286
-
287
- result = case key
288
- when "claude" then install_claude(config, force)
289
- when "codex" then install_codex(config, force)
290
- when "hermes" then install_hermes(config, force)
291
- end
292
-
293
- new_files = manifest_files(manifest_path_for(key, config))
294
- pruned = prune_removed_files(old_files - new_files)
295
- result[:pruned] = pruned if pruned.positive?
296
- result
297
- end
298
-
299
- # Delete tracked files present in the old manifest but absent from the new one,
300
- # then remove any now-empty skill directories they lived in.
301
- def prune_removed_files(stale_files)
302
- removed = 0
303
- dirs = []
304
- stale_files.each do |f|
305
- if File.exist?(f)
306
- File.delete(f)
307
- removed += 1
38
+ if installed? && !reinstall
39
+ warn "Plastic v#{installed_version} is already installed."
40
+ warn " - To upgrade: npx @zalom/plastic update"
41
+ warn " - To re-sync files: npx @zalom/plastic install --reinstall"
42
+ return 1
308
43
  end
309
- dirs << File.dirname(f)
310
- end
311
- dirs.uniq.sort_by { |d| -d.length }.each do |d|
312
- FileUtils.rmdir(d) if File.directory?(d) && Dir.empty?(d)
313
- end
314
- removed
315
- end
316
-
317
- def install_claude(config, force)
318
- hooks_dir = File.join(config[:dir], "hooks")
319
- skills_root = File.join(config[:dir], "skills")
320
- plastic_dir = File.join(config[:dir], "plastic")
321
-
322
- FileUtils.mkdir_p(hooks_dir)
323
- FileUtils.mkdir_p(skills_root)
324
- FileUtils.mkdir_p(plastic_dir)
325
44
 
326
- installed = []
327
-
328
- # Copy hooks, rewriting script paths for the installed location
329
- hook_source = File.join(PACKAGE_ROOT, "hooks")
330
- Dir.glob(File.join(hook_source, "*")).each do |f|
331
- next unless File.file?(f)
332
- basename = File.basename(f)
333
- next if %w[hooks.json run-hook].include?(basename)
334
- dest_name = basename.start_with?("plastic-") ? basename : "plastic-#{basename}"
335
- dest = File.join(hooks_dir, dest_name)
336
- content = File.read(f)
337
- content = content.gsub('$SCRIPT_DIR/../scripts/', '$HOME/.plastic/scripts/')
338
- File.write(dest, content)
339
- FileUtils.chmod(0o755, dest)
340
- installed << dest
45
+ run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action)
46
+ 0
341
47
  end
342
48
 
343
- # Copy skills as flat, hyphen-namespaced personal skills (plastic-<name>/)
344
- skills_source = File.join(PACKAGE_ROOT, "skills")
345
- installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
346
-
347
- # Write VERSION
348
- version_file = File.join(plastic_dir, "VERSION")
349
- File.write(version_file, "#{VERSION}\n")
350
- installed << version_file
351
-
352
- # Remove any prior plugin/marketplace install so the layouts don't coexist
353
- migrate_legacy_plugin(config[:dir])
354
-
355
- # Merge hooks + statusline into settings.json (no plugin registration)
356
- settings_path = File.join(config[:dir], "settings.json")
357
- merge_claude_hooks(settings_path)
358
-
359
- # Write manifest
360
- manifest_path = File.join(plastic_dir, "manifest.json")
361
- write_manifest(installed, manifest_path)
362
-
363
- { agent: config[:name], success: true, files: installed.size }
364
- end
365
-
366
- def install_codex(config, force)
367
- installed = []
368
- skills_source = File.join(PACKAGE_ROOT, "skills")
369
- installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
370
-
371
- write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
372
- { agent: config[:name], success: true, files: installed.size }
373
- end
49
+ # Hermetic entrypoint (no prompting / no exit). Returns the per-agent results array.
50
+ def run(selected:, force: false, reinstall: false, ledger_action: nil)
51
+ fresh = !installed?
52
+ mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
374
53
 
375
- def install_hermes(config, force)
376
- installed = []
377
- skills_source = File.join(PACKAGE_ROOT, "skills")
378
- installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
54
+ distribute(mode)
55
+ bootstrap if fresh
379
56
 
380
- write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
381
- { agent: config[:name], success: true, files: installed.size }
382
- end
57
+ results = selected.map { |key| install_for_agent(key, force) }
383
58
 
384
- # Copy each skills/<name>/ to <skills_root>/plastic-<name>/ (flat, namespaced by
385
- # directory name — the only personal-skill namespacing Claude Code supports).
386
- # The non-skill `_active-intent-gate.md` is relocated to ~/.plastic/ instead.
387
- def install_skills_flat(skills_source, skills_root)
388
- installed = []
389
- FileUtils.mkdir_p(skills_root)
59
+ action = ledger_action || (fresh ? "install" : "reinstall")
60
+ ledger_append(version, action)
390
61
 
391
- Dir.children(skills_source).reject { |e| e.start_with?(".") }.each do |entry|
392
- src = File.join(skills_source, entry)
393
- if File.directory?(src)
394
- installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
395
- elsif entry == "_active-intent-gate.md"
396
- FileUtils.mkdir_p(PLASTIC_HOME)
397
- dest = File.join(PLASTIC_HOME, entry)
398
- FileUtils.cp(src, dest)
399
- installed << dest
400
- end
62
+ print_results(results, fresh ? :install : :reinstall)
63
+ results
401
64
  end
402
65
 
403
- installed
404
- end
405
-
406
- # --- Legacy plugin migration ---
407
-
408
- # Earlier versions registered Plastic as a local marketplace plugin
409
- # (plastic@plastic) for skill discovery. The current model uses flat,
410
- # hyphen-namespaced personal skills, so any prior plugin layout is removed here
411
- # to stop the two coexisting. Returns a list of what was migrated.
412
- def migrate_legacy_plugin(claude_dir)
413
- removed = []
414
-
415
- legacy_dirs = [
416
- File.join(claude_dir, "plugins", "marketplaces", "plastic"),
417
- File.join(claude_dir, "plugins", "cache", "plastic"),
418
- File.join(claude_dir, "skills", "plastic"), # old nested skills layout
419
- ]
420
- legacy_dirs.each do |d|
421
- if File.directory?(d)
422
- FileUtils.rm_rf(d)
423
- removed << d
424
- end
66
+ def installed?
67
+ File.exist?(File.join(plastic_home, "VERSION"))
425
68
  end
426
69
 
427
- # settings.json: drop enabledPlugins + extraKnownMarketplaces entries
428
- settings_path = File.join(claude_dir, "settings.json")
429
- settings = read_json_safe(settings_path)
430
- if settings
431
- changed = false
432
- if settings.dig("enabledPlugins", "plastic@plastic")
433
- settings["enabledPlugins"].delete("plastic@plastic")
434
- settings.delete("enabledPlugins") if settings["enabledPlugins"].empty?
435
- changed = true
436
- end
437
- if settings.dig("extraKnownMarketplaces", "plastic")
438
- settings["extraKnownMarketplaces"].delete("plastic")
439
- settings.delete("extraKnownMarketplaces") if settings["extraKnownMarketplaces"].empty?
440
- changed = true
441
- end
442
- if changed
443
- write_json_atomic(settings_path, settings)
444
- removed << "settings.json: plastic@plastic plugin registration"
445
- end
70
+ def installed_version
71
+ path = File.join(plastic_home, "VERSION")
72
+ File.exist?(path) ? File.read(path).strip : nil
446
73
  end
447
74
 
448
- # plugins/known_marketplaces.json: drop the plastic entry
449
- known_path = File.join(claude_dir, "plugins", "known_marketplaces.json")
450
- known = read_json_safe(known_path)
451
- if known.is_a?(Hash) && known.key?("plastic")
452
- known.delete("plastic")
453
- write_json_atomic(known_path, known)
454
- removed << "known_marketplaces.json: plastic"
455
- end
75
+ private
456
76
 
457
- unless removed.empty?
458
- puts " \u{1f9f9} Migrated legacy plugin install:"
459
- removed.each { |r| puts " - #{tilde(r)}" }
77
+ def flag_value(argv, name)
78
+ i = argv.index(name)
79
+ return nil unless i && argv[i + 1]
80
+ argv[i + 1]
460
81
  end
461
82
 
462
- removed
463
- end
464
-
465
- def tilde(path)
466
- path.sub(Dir.home, "~")
467
- end
468
-
469
- # --- settings.json merge (read-modify-write, never clobber) ---
470
-
471
- def merge_claude_hooks(settings_path)
472
- settings = read_json_safe(settings_path) || {}
473
- return if settings.nil?
474
-
475
- hooks = settings["hooks"] ||= {}
476
- hook_dir = File.join(Dir.home, ".claude", "hooks")
477
-
478
- purge_stale_plastic_hooks(hooks)
479
-
480
- plastic_hooks = {
481
- "SessionStart" => {
482
- "matcher" => "",
483
- "hooks" => [
484
- { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
485
- { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
486
- ],
487
- },
488
- "PreCompact" => {
489
- "matcher" => "",
490
- "hooks" => [
491
- { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
492
- ],
493
- },
494
- "PreToolUse" => {
495
- "matcher" => "Write|Edit|NotebookEdit",
496
- "hooks" => [
497
- { "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
498
- ],
499
- },
500
- "PostToolUse" => {
501
- "matcher" => "Write|Edit",
502
- "hooks" => [
503
- { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
504
- ],
505
- },
506
- "UserPromptSubmit" => {
507
- "matcher" => "",
508
- "hooks" => [
509
- { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
510
- { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
511
- { "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
512
- ],
513
- },
514
- }
515
-
516
- plastic_hooks.each do |event, group|
517
- hooks[event] ||= []
518
- existing = hooks[event].find { |g| g.is_a?(Hash) && g["hooks"].is_a?(Array) && g["hooks"].any? { |h| h["command"].to_s.include?("plastic-") } }
519
-
520
- if existing
521
- existing["matcher"] = group["matcher"]
522
- existing["hooks"] = group["hooks"]
523
- else
524
- hooks[event] << group
525
- end
526
- end
527
-
528
- existing_status = settings["statusLine"]
529
- if existing_status && !existing_status.dig("command").to_s.include?("plastic-")
530
- cache_dir = File.join(PLASTIC_HOME, ".cache")
531
- FileUtils.mkdir_p(cache_dir)
532
- File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
533
- end
534
-
535
- settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
536
-
537
- # No plugin/marketplace registration: skills are flat personal skills
538
- # (plastic-<name>/) discovered directly from ~/.claude/skills.
539
-
540
- write_json_atomic(settings_path, settings)
541
- end
542
-
543
- def purge_stale_plastic_hooks(hooks)
544
- plastic_cmd = ->(cmd) { cmd.to_s.include?("plastic-") }
545
-
546
- hooks.delete("statusLine")
547
-
548
- hooks.each do |event, groups|
549
- next unless groups.is_a?(Array)
550
-
551
- hooks[event] = groups.map do |group|
552
- if group.is_a?(Hash) && group["hooks"].is_a?(Array)
553
- group["hooks"].reject! { |h| plastic_cmd.call(h["command"]) }
554
- group unless group["hooks"].empty?
555
- elsif group.is_a?(Hash) && group["command"]
556
- plastic_cmd.call(group["command"]) ? nil : group
83
+ def print_results(results, mode)
84
+ puts "\n\u{2014} Results \u{2014}\n\n"
85
+ results.each do |r|
86
+ if r[:success]
87
+ puts " \u{2705} #{r[:agent]}: #{r[:files]} files installed"
557
88
  else
558
- group
89
+ puts " \u{26a0}\u{fe0f} #{r[:agent]}: #{r[:reason]}"
559
90
  end
560
- end.compact
561
- end
562
- end
563
-
564
- # --- Uninstall ---
565
-
566
- def handle_uninstall(agents)
567
- agents.each do |key|
568
- config = agent_config(key)
569
- next unless config
570
-
571
- result = uninstall_agent(key, config)
572
- unless result[:success]
573
- puts " \u{26a0}\u{fe0f} #{config[:name]}: #{result[:reason]}"
574
- next
575
91
  end
576
92
 
577
- puts " \u{2705} #{config[:name]}: uninstalled (#{result[:files]} files removed)"
578
- result[:removed].each { |r| puts " removed: #{tilde(r)}" }
579
- end
580
-
581
- # What was deliberately left behind
582
- puts "\n Left in place (not removed):"
583
- puts " - #{tilde(PLASTIC_HOME)} (your intent store, history, and projects)"
584
- puts " - any non-Plastic entries in settings.json"
585
-
586
- puts "\n Verify removal:"
587
- puts " ls ~/.claude/skills | grep '^plastic-' # → no output"
588
- puts " ls ~/.claude/hooks | grep '^plastic-' # → no output"
589
- puts " grep -c plastic ~/.claude/settings.json # → only hook refs gone"
590
- puts "\n To also delete your intent store: rm -rf #{tilde(PLASTIC_HOME)}\n\n"
591
- end
592
-
593
- def uninstall_agent(key, config)
594
- unless File.directory?(config[:dir])
595
- return { success: false, reason: "#{config[:dir]} not found" }
596
- end
597
-
598
- removed = []
599
- manifest_path = manifest_path_for(key, config)
600
-
601
- if File.exist?(manifest_path)
602
- manifest = JSON.parse(File.read(manifest_path)) rescue {}
603
- (manifest["files"] || {}).each_key do |f|
604
- if File.exist?(f)
605
- File.delete(f)
606
- removed << f
607
- end
608
- end
609
- File.delete(manifest_path)
610
- removed << manifest_path
611
- end
612
-
613
- # Remove flat plastic-<name>/ skill dirs (now-empty after manifest deletion,
614
- # plus any the manifest missed) and the plastic state dir.
615
- skills_root = File.join(config[:dir], "skills")
616
- if File.directory?(skills_root)
617
- Dir.children(skills_root).select { |e| e.start_with?("plastic-") }.each do |d|
618
- full = File.join(skills_root, d)
619
- FileUtils.rm_rf(full)
620
- removed << full
621
- end
622
- end
623
-
624
- [File.join(config[:dir], "plastic")].each do |d|
625
- if File.directory?(d)
626
- FileUtils.rm_rf(d)
627
- removed << d
628
- end
629
- end
630
-
631
- # Claude Code: clean hooks/statusline and any legacy plugin registration
632
- if key == "claude"
633
- settings_path = File.join(config[:dir], "settings.json")
634
- remove_claude_hooks(settings_path) if File.exist?(settings_path)
635
- removed.concat(migrate_legacy_plugin(config[:dir]))
636
- end
637
-
638
- { success: true, files: removed.size, removed: removed }
639
- end
640
-
641
- def remove_claude_hooks(settings_path)
642
- settings = read_json_safe(settings_path)
643
- return unless settings && settings["hooks"]
93
+ installed = results.select { |r| r[:success] }
94
+ return unless installed.any?
644
95
 
645
- settings["hooks"].each do |event, groups|
646
- next unless groups.is_a?(Array)
647
-
648
- settings["hooks"][event] = groups.map do |group|
649
- if group.is_a?(Hash) && group["hooks"].is_a?(Array)
650
- group["hooks"].reject! { |h| h["command"].to_s.include?("plastic-") }
651
- group unless group["hooks"].empty?
652
- elsif group.is_a?(Hash) && group["command"]
653
- group["command"].to_s.include?("plastic-") ? nil : group
654
- else
655
- group
656
- end
657
- end.compact
96
+ verb = mode == :reinstall ? "re-synced" : "installed"
97
+ puts "\n\u{2705} Plastic v#{version} #{verb}."
98
+ puts " Registered for: #{installed.map { |r| r[:agent] }.join(", ")}"
99
+ puts " Run /clear (or restart your agent) to pick up new conventions.\n\n"
658
100
  end
659
101
 
660
- settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
661
- settings.delete("hooks") if settings["hooks"]&.empty?
662
- if settings.dig("statusLine", "command").to_s.include?("plastic-")
663
- settings.delete("statusLine")
664
- original_path = File.join(PLASTIC_HOME, ".cache", "original-statusline.json")
665
- if File.exist?(original_path)
666
- original = JSON.parse(File.read(original_path)) rescue nil
667
- settings["statusLine"] = original if original
668
- end
669
- end
102
+ def show_help
103
+ puts <<~HELP
670
104
 
671
- # Remove from enabledPlugins
672
- if settings["enabledPlugins"]
673
- settings["enabledPlugins"].delete("plastic@plastic")
674
- settings.delete("enabledPlugins") if settings["enabledPlugins"].empty?
675
- end
105
+ plastic install — install or re-sync Plastic for an agent
676
106
 
677
- write_json_atomic(settings_path, settings)
678
- end
107
+ Usage:
108
+ npx @zalom/plastic install [options]
679
109
 
680
- # --- Utilities ---
110
+ Agent options:
111
+ --claude Install for Claude Code
112
+ --codex Install for Codex CLI
113
+ --hermes Install for Hermes
114
+ --all Install for all supported agents
681
115
 
682
- def agent_config(key)
683
- AGENTS.find { |a| a[:key] == key }
684
- end
116
+ Channel options:
117
+ --latest Stable channel (default)
118
+ --beta Beta channel
119
+ --alpha Alpha channel
685
120
 
686
- def copy_dir_recursive(src, dest)
687
- files = []
688
- FileUtils.mkdir_p(dest)
689
- Dir.entries(src).reject { |e| e.start_with?(".") }.each do |entry|
690
- src_path = File.join(src, entry)
691
- dest_path = File.join(dest, entry)
692
- if File.directory?(src_path)
693
- files += copy_dir_recursive(src_path, dest_path)
694
- elsif File.file?(src_path)
695
- FileUtils.cp(src_path, dest_path)
696
- files << dest_path
697
- end
698
- end
699
- files
700
- end
121
+ Other options:
122
+ --reinstall Re-sync core files for the installed version (repair). Store untouched.
123
+ --force Overwrite existing files without prompting
124
+ -h, --help Show this help
701
125
 
702
- def read_json_safe(path)
703
- return nil unless File.exist?(path)
704
- JSON.parse(File.read(path))
705
- rescue JSON::ParserError
706
- # Try JSONC stripping (remove // comments and trailing commas)
707
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
708
- JSON.parse(content)
709
- rescue
710
- nil
711
- end
126
+ Notes:
127
+ Install is one-shot. If Plastic is already installed, use `update` to upgrade or
128
+ `install --reinstall` to repair. To change versions, use `update` / `versions`.
712
129
 
713
- def write_json_atomic(path, data)
714
- content = JSON.pretty_generate(data) + "\n"
715
- tmp = "#{path}.plastic-tmp.#{Process.pid}"
716
- File.write(tmp, content)
717
- File.rename(tmp, path)
718
- rescue => e
719
- File.delete(tmp) if tmp && File.exist?(tmp)
720
- raise e
721
- end
722
-
723
- def write_manifest(files, manifest_path)
724
- entries = {}
725
- files.each do |f|
726
- entries[f] = Digest::SHA256.file(f).hexdigest if File.exist?(f)
130
+ HELP
727
131
  end
728
-
729
- data = { "version" => "1", "created" => Time.now.utc.iso8601, "files" => entries }
730
- File.write(manifest_path, JSON.pretty_generate(data) + "\n")
731
132
  end
732
133
 
733
- def write_if_missing(path, content)
734
- File.write(path, content) unless File.exist?(path)
735
- end
736
-
737
- # --- Run ---
738
-
739
- main
134
+ exit(Install.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__