@zalom/plastic 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (76) hide show
  1. package/PLASTIC.md +128 -473
  2. package/README.md +90 -58
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/bin/plastic.js +57 -0
  6. package/bin/test +28 -0
  7. package/deprecations.yml +7 -6
  8. package/hooks/auto-arm +5 -0
  9. package/hooks/bash-gate +3 -0
  10. package/hooks/check-update +12 -8
  11. package/hooks/code-gate +10 -0
  12. package/hooks/hooks.json +25 -4
  13. package/hooks/statusline +50 -10
  14. package/package.json +2 -2
  15. package/scripts/dashboard.rb +480 -0
  16. package/scripts/doctor.rb +973 -0
  17. package/scripts/hook-auto-arm +52 -0
  18. package/scripts/hook-bash-gate +53 -0
  19. package/scripts/hook-code-gate +39 -0
  20. package/scripts/hook-continue +15 -114
  21. package/scripts/hook-gate-check +19 -4
  22. package/scripts/hook-session-start +76 -31
  23. package/scripts/install.rb +91 -480
  24. package/scripts/lib/bridge.rb +255 -0
  25. package/scripts/lib/installer_core.rb +760 -0
  26. package/scripts/migrate-to-global +1 -1
  27. package/scripts/select-update-target +93 -0
  28. package/scripts/uninstall.rb +53 -0
  29. package/scripts/update.rb +142 -0
  30. package/scripts/versions.rb +141 -0
  31. package/skills/_active-intent-gate.md +26 -0
  32. package/skills/auto/SKILL.md +62 -9
  33. package/skills/auto/evals/evals.json +92 -0
  34. package/skills/auto/references/agent-architecture.md +60 -0
  35. package/skills/brainstorming/SKILL.md +143 -0
  36. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  37. package/skills/continuing/SKILL.md +102 -77
  38. package/skills/continuing/evals/evals.json +136 -0
  39. package/skills/continuing/references/context-management.md +32 -0
  40. package/skills/creating-intent/SKILL.md +16 -1
  41. package/skills/creating-intent/references/lifecycle.md +74 -0
  42. package/skills/creating-intent/references/wikilinks.md +8 -0
  43. package/skills/creating-project/SKILL.md +8 -4
  44. package/skills/creating-project/references/hubs-projects.md +55 -0
  45. package/skills/dashboard/SKILL.md +92 -0
  46. package/skills/doctor/SKILL.md +116 -0
  47. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  48. package/skills/doctor/report.md +96 -0
  49. package/skills/evaluating-skills/SKILL.md +140 -0
  50. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  51. package/skills/evaluating-skills/evals/evals.json +75 -0
  52. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  53. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  54. package/skills/executing-plan/SKILL.md +3 -3
  55. package/skills/install/SKILL.md +56 -8
  56. package/skills/intent-curator/SKILL.md +3 -3
  57. package/skills/linking-intents/SKILL.md +5 -1
  58. package/skills/linking-intents/references/zettelkasten.md +33 -0
  59. package/skills/managing-index/SKILL.md +5 -1
  60. package/skills/releasing/SKILL.md +119 -18
  61. package/skills/releasing/references/deprecations.md +44 -0
  62. package/skills/research/SKILL.md +114 -0
  63. package/skills/savepoint/SKILL.md +46 -37
  64. package/skills/savepoint/references/context-management.md +32 -0
  65. package/skills/uninstall/SKILL.md +39 -28
  66. package/skills/update/SKILL.md +41 -36
  67. package/skills/versions/SKILL.md +65 -0
  68. package/skills/writing-instructions/SKILL.md +159 -0
  69. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  70. package/skills/writing-plans/SKILL.md +183 -0
  71. package/templates/agents.md +16 -0
  72. package/templates/outcome.md +13 -0
  73. package/templates/project.yml +5 -0
  74. package/templates/savepoint.md +14 -13
  75. package/templates/spec.md +25 -0
  76. package/bin/install.js +0 -29
@@ -2,522 +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] [--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
24
 
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
25
+ force = argv.include?("--force")
26
+ reinstall = argv.include?("--reinstall")
27
+ ledger_action = flag_value(argv, "--ledger-action")
70
28
 
71
- # --- Flag parsing ---
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
34
+ end
72
35
 
73
- def parse_flags(argv)
74
- flags = { agents: [], force: false, uninstall: false, help: false }
36
+ puts "\n\u{1f9e0} Plastic v#{version}\n\n"
75
37
 
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
- else
83
- agent = AGENTS.find { |a| a[:flag] == arg }
84
- flags[:agents] << agent[:key] if agent
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
85
43
  end
86
- end
87
-
88
- flags
89
- end
90
44
 
91
- def prompt_agents
92
- unless $stdin.tty?
93
- return ["claude"]
45
+ run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action)
46
+ 0
94
47
  end
95
48
 
96
- puts "Which agents should Plastic register for?\n\n"
97
- AGENTS.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:dir]})" }
98
- puts " #{AGENTS.size + 1}. All"
99
- puts
100
-
101
- print "Select (comma-separated numbers, or Enter for Claude Code): "
102
- answer = $stdin.gets&.strip || ""
103
-
104
- return ["claude"] if answer.empty?
105
-
106
- nums = answer.split(",").map { |n| n.strip.to_i }
107
- return AGENTS.map { |a| a[:key] } if nums.include?(AGENTS.size + 1)
108
-
109
- nums.select { |n| n >= 1 && n <= AGENTS.size }.map { |n| AGENTS[n - 1][:key] }
110
- end
111
-
112
- def show_help
113
- puts <<~HELP
114
-
115
- plastic - Intent-driven idea development system
116
-
117
- Usage:
118
- npx @zalom/plastic@latest [options]
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"
119
53
 
120
- Options:
121
- --claude Install for Claude Code
122
- --codex Install for Codex CLI
123
- --hermes Install for Hermes
124
- --all Install for all supported agents
125
- --force Overwrite existing files without prompting
126
- --uninstall Remove Plastic from agent directories
127
- -h, --help Show this help
54
+ distribute(mode)
55
+ bootstrap if fresh
128
56
 
129
- Examples:
130
- npx @zalom/plastic@latest Interactive agent selection
131
- npx @zalom/plastic@latest --claude Install for Claude Code only
132
- npx @zalom/plastic@latest --all Install for all agents
133
- npx @zalom/plastic@latest --uninstall Remove from agent directories
57
+ results = selected.map { |key| install_for_agent(key, force) }
134
58
 
135
- HELP
136
- end
137
-
138
- # --- Distribution phase ---
139
-
140
- def distribute(mode)
141
- puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{PLASTIC_HOME}"
142
-
143
- FileUtils.mkdir_p(PLASTIC_HOME)
144
- FileUtils.mkdir_p(File.join(PLASTIC_HOME, "scripts"))
145
-
146
- core_files = {
147
- "PLASTIC.md" => "PLASTIC.md",
148
- "deprecations.yml" => "deprecations.yml",
149
- "scripts/folgezettel-id" => "scripts/folgezettel-id",
150
- "scripts/read-config" => "scripts/read-config",
151
- }
59
+ action = ledger_action || (fresh ? "install" : "reinstall")
60
+ ledger_append(version, action)
152
61
 
153
- core_files.each do |src, dest|
154
- src_path = File.join(PACKAGE_ROOT, src)
155
- dest_path = File.join(PLASTIC_HOME, dest)
156
- FileUtils.cp(src_path, dest_path) if File.exist?(src_path)
62
+ print_results(results, fresh ? :install : :reinstall)
63
+ results
157
64
  end
158
65
 
159
- File.write(File.join(PLASTIC_HOME, "VERSION"), "#{VERSION}\n")
160
-
161
- Dir.glob(File.join(PLASTIC_HOME, "scripts", "*")).each { |f| FileUtils.chmod(0o755, f) }
162
-
163
- puts " \u{2705} Core files synced (v#{VERSION})"
164
- end
165
-
166
- def bootstrap
167
- puts " \u{1f331} First install \u{2014} bootstrapping store..."
168
-
169
- FileUtils.mkdir_p(File.join(PLASTIC_HOME, "store"))
170
- FileUtils.mkdir_p(File.join(PLASTIC_HOME, "projects"))
171
-
172
- write_if_missing(File.join(PLASTIC_HOME, "config.yml"), <<~YAML)
173
- version: 3
174
- execution_mode: subagent-driven
175
- stale_threshold_days: 3
176
- hash_length: 6
177
- hash_algorithm: sha256-base36
178
- max_slug_words: 5
179
- agent:
180
- type: claude-code
181
- parallel_mode: agent-teams
182
- YAML
183
-
184
- write_if_missing(File.join(PLASTIC_HOME, "projects.yml"), "---\nprojects: {}\n")
185
-
186
- write_if_missing(File.join(PLASTIC_HOME, "INDEX.md"), <<~MD)
187
- # Index
188
-
189
- ## Active
190
-
191
- ## Future
192
-
193
- ## Clusters
194
-
195
- ## Abandoned
196
-
197
- ## Completed
198
- MD
199
-
200
- write_if_missing(File.join(PLASTIC_HOME, "AGENTS.md"), <<~MD)
201
- # Plastic \u{2014} Agent Instructions
202
-
203
- Read `PLASTIC.md` in this directory. It contains all Plastic conventions.
204
- Follow it exactly. Never modify it \u{2014} it is overwritten on plugin updates.
205
-
206
- This file (`AGENTS.md`) is where project-specific rules live.
207
-
208
- ---
209
- MD
210
-
211
- puts " \u{2705} Store bootstrapped"
212
- end
213
-
214
- # --- Agent adapters ---
215
-
216
- def install_for_agent(key, force)
217
- config = agent_config(key)
218
- return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
219
-
220
- unless File.directory?(config[:dir])
221
- return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
66
+ def installed?
67
+ File.exist?(File.join(plastic_home, "VERSION"))
222
68
  end
223
69
 
224
- case key
225
- when "claude" then install_claude(config, force)
226
- when "codex" then install_codex(config, force)
227
- when "hermes" then install_hermes(config, force)
70
+ def installed_version
71
+ path = File.join(plastic_home, "VERSION")
72
+ File.exist?(path) ? File.read(path).strip : nil
228
73
  end
229
- end
230
-
231
- def install_claude(config, force)
232
- hooks_dir = File.join(config[:dir], "hooks")
233
- skills_dir = File.join(config[:dir], "skills", "plastic")
234
- plastic_dir = File.join(config[:dir], "plastic")
235
74
 
236
- FileUtils.mkdir_p(hooks_dir)
237
- FileUtils.mkdir_p(skills_dir)
238
- FileUtils.mkdir_p(plastic_dir)
75
+ private
239
76
 
240
- installed = []
241
-
242
- # Copy hooks
243
- hook_source = File.join(PACKAGE_ROOT, "hooks")
244
- Dir.glob(File.join(hook_source, "*")).each do |f|
245
- next unless File.file?(f)
246
- basename = File.basename(f)
247
- next if %w[hooks.json run-hook].include?(basename)
248
- dest_name = basename.start_with?("plastic-") ? basename : "plastic-#{basename}"
249
- dest = File.join(hooks_dir, dest_name)
250
- FileUtils.cp(f, dest)
251
- FileUtils.chmod(0o755, dest)
252
- installed << dest
77
+ def flag_value(argv, name)
78
+ i = argv.index(name)
79
+ return nil unless i && argv[i + 1]
80
+ argv[i + 1]
253
81
  end
254
82
 
255
- # Copy skills recursively
256
- skills_source = File.join(PACKAGE_ROOT, "skills")
257
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
258
-
259
- # Write VERSION
260
- version_file = File.join(plastic_dir, "VERSION")
261
- File.write(version_file, "#{VERSION}\n")
262
- installed << version_file
263
-
264
- # Merge hooks into settings.json
265
- settings_path = File.join(config[:dir], "settings.json")
266
- merge_claude_hooks(settings_path)
267
-
268
- # Write manifest
269
- manifest_path = File.join(plastic_dir, "manifest.json")
270
- write_manifest(installed, manifest_path)
271
-
272
- { agent: config[:name], success: true, files: installed.size }
273
- end
274
-
275
- def install_codex(config, force)
276
- skills_dir = File.join(config[:dir], "skills", "plastic")
277
- FileUtils.mkdir_p(skills_dir)
278
-
279
- installed = []
280
- skills_source = File.join(PACKAGE_ROOT, "skills")
281
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
282
-
283
- manifest_path = File.join(config[:dir], "plastic-manifest.json")
284
- write_manifest(installed, manifest_path)
285
-
286
- { agent: config[:name], success: true, files: installed.size }
287
- end
288
-
289
- def install_hermes(config, force)
290
- skills_dir = File.join(config[:dir], "skills", "plastic")
291
- FileUtils.mkdir_p(skills_dir)
292
-
293
- installed = []
294
- skills_source = File.join(PACKAGE_ROOT, "skills")
295
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
296
-
297
- manifest_path = File.join(config[:dir], "plastic-manifest.json")
298
- write_manifest(installed, manifest_path)
299
-
300
- { agent: config[:name], success: true, files: installed.size }
301
- end
302
-
303
- # --- settings.json merge (read-modify-write, never clobber) ---
304
-
305
- def merge_claude_hooks(settings_path)
306
- settings = read_json_safe(settings_path) || {}
307
- return if settings.nil?
308
-
309
- hooks = settings["hooks"] ||= {}
310
- hook_dir = File.join(Dir.home, ".claude", "hooks")
311
-
312
- purge_stale_plastic_hooks(hooks)
313
-
314
- plastic_hooks = {
315
- "SessionStart" => {
316
- "matcher" => "",
317
- "hooks" => [
318
- { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
319
- { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
320
- ],
321
- },
322
- "PreCompact" => {
323
- "matcher" => "",
324
- "hooks" => [
325
- { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
326
- ],
327
- },
328
- "PostToolUse" => {
329
- "matcher" => "Write|Edit",
330
- "hooks" => [
331
- { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
332
- ],
333
- },
334
- "UserPromptSubmit" => {
335
- "matcher" => "",
336
- "hooks" => [
337
- { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
338
- { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
339
- ],
340
- },
341
- }
342
-
343
- plastic_hooks.each do |event, group|
344
- hooks[event] ||= []
345
- existing = hooks[event].find { |g| g.is_a?(Hash) && g["hooks"].is_a?(Array) && g["hooks"].any? { |h| h["command"].to_s.include?("plastic-") } }
346
-
347
- if existing
348
- existing["matcher"] = group["matcher"]
349
- existing["hooks"] = group["hooks"]
350
- else
351
- hooks[event] << group
352
- end
353
- end
354
-
355
- settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
356
-
357
- write_json_atomic(settings_path, settings)
358
- end
359
-
360
- def purge_stale_plastic_hooks(hooks)
361
- stale = ->(cmd) { cmd.to_s.match?(/ruby\s+.*plastic-/) || cmd.to_s.match?(/plastic-[a-z-]+\.rb/) }
362
-
363
- hooks.each do |event, groups|
364
- next unless groups.is_a?(Array)
365
-
366
- hooks[event] = groups.map do |group|
367
- if group.is_a?(Hash) && group["hooks"].is_a?(Array)
368
- group["hooks"].reject! { |h| stale.call(h["command"]) }
369
- group unless group["hooks"].empty?
370
- elsif group.is_a?(Hash) && group["command"]
371
- stale.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"
372
88
  else
373
- group
89
+ puts " \u{26a0}\u{fe0f} #{r[:agent]}: #{r[:reason]}"
374
90
  end
375
- end.compact
376
- end
377
- end
378
-
379
- # --- Uninstall ---
380
-
381
- def handle_uninstall(agents)
382
- agents.each do |key|
383
- config = agent_config(key)
384
- next unless config
385
-
386
- result = uninstall_agent(key, config)
387
- if result[:success]
388
- puts " \u{2705} #{config[:name]}: uninstalled (#{result[:files]} files removed)"
389
- else
390
- puts " \u{26a0}\u{fe0f} #{config[:name]}: #{result[:reason]}"
391
91
  end
392
- end
393
92
 
394
- puts "\n Note: ~/.plastic/ (your intent store) is preserved.\n\n"
395
- end
396
-
397
- def uninstall_agent(key, config)
398
- unless File.directory?(config[:dir])
399
- return { success: false, reason: "#{config[:dir]} not found" }
400
- end
401
-
402
- manifest_path = case key
403
- when "claude" then File.join(config[:dir], "plastic", "manifest.json")
404
- else File.join(config[:dir], "plastic-manifest.json")
405
- end
406
-
407
- files_removed = 0
408
-
409
- if File.exist?(manifest_path)
410
- manifest = JSON.parse(File.read(manifest_path)) rescue {}
411
- (manifest["files"] || {}).each_key do |f|
412
- if File.exist?(f)
413
- File.delete(f)
414
- files_removed += 1
415
- end
416
- end
417
- File.delete(manifest_path)
418
- files_removed += 1
419
- end
420
-
421
- # Clean known directories
422
- dirs_to_clean = case key
423
- when "claude" then [File.join(config[:dir], "plastic"), File.join(config[:dir], "skills", "plastic")]
424
- else [File.join(config[:dir], "skills", "plastic")]
425
- end
426
-
427
- dirs_to_clean.each { |d| FileUtils.rm_rf(d) if File.directory?(d) }
428
-
429
- # Clean hooks from settings.json (Claude Code only)
430
- if key == "claude"
431
- settings_path = File.join(config[:dir], "settings.json")
432
- remove_claude_hooks(settings_path) if File.exist?(settings_path)
433
- end
434
-
435
- { success: true, files: files_removed }
436
- end
437
-
438
- def remove_claude_hooks(settings_path)
439
- settings = read_json_safe(settings_path)
440
- return unless settings && settings["hooks"]
93
+ installed = results.select { |r| r[:success] }
94
+ return unless installed.any?
441
95
 
442
- settings["hooks"].each do |event, groups|
443
- next unless groups.is_a?(Array)
444
-
445
- settings["hooks"][event] = groups.map do |group|
446
- if group.is_a?(Hash) && group["hooks"].is_a?(Array)
447
- group["hooks"].reject! { |h| h["command"].to_s.include?("plastic-") }
448
- group unless group["hooks"].empty?
449
- elsif group.is_a?(Hash) && group["command"]
450
- group["command"].to_s.include?("plastic-") ? nil : group
451
- else
452
- group
453
- end
454
- 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"
455
100
  end
456
101
 
457
- settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
458
- settings.delete("hooks") if settings["hooks"]&.empty?
459
- settings.delete("statusLine") if settings.dig("statusLine", "command").to_s.include?("plastic-")
102
+ def show_help
103
+ puts <<~HELP
460
104
 
461
- write_json_atomic(settings_path, settings)
462
- end
105
+ plastic install — install or re-sync Plastic for an agent
463
106
 
464
- # --- Utilities ---
107
+ Usage:
108
+ npx @zalom/plastic install [options]
465
109
 
466
- def agent_config(key)
467
- AGENTS.find { |a| a[:key] == key }
468
- end
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
469
115
 
470
- def copy_dir_recursive(src, dest)
471
- files = []
472
- FileUtils.mkdir_p(dest)
473
- Dir.entries(src).reject { |e| e.start_with?(".") }.each do |entry|
474
- src_path = File.join(src, entry)
475
- dest_path = File.join(dest, entry)
476
- if File.directory?(src_path)
477
- files += copy_dir_recursive(src_path, dest_path)
478
- elsif File.file?(src_path)
479
- FileUtils.cp(src_path, dest_path)
480
- files << dest_path
481
- end
482
- end
483
- files
484
- end
116
+ Channel options:
117
+ --latest Stable channel (default)
118
+ --beta Beta channel
119
+ --alpha Alpha channel
485
120
 
486
- def read_json_safe(path)
487
- return nil unless File.exist?(path)
488
- JSON.parse(File.read(path))
489
- rescue JSON::ParserError
490
- # Try JSONC stripping (remove // comments and trailing commas)
491
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
492
- JSON.parse(content)
493
- rescue
494
- nil
495
- 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
496
125
 
497
- def write_json_atomic(path, data)
498
- content = JSON.pretty_generate(data) + "\n"
499
- tmp = "#{path}.plastic-tmp.#{Process.pid}"
500
- File.write(tmp, content)
501
- File.rename(tmp, path)
502
- rescue => e
503
- File.delete(tmp) if tmp && File.exist?(tmp)
504
- raise e
505
- 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`.
506
129
 
507
- def write_manifest(files, manifest_path)
508
- entries = {}
509
- files.each do |f|
510
- entries[f] = Digest::SHA256.file(f).hexdigest if File.exist?(f)
130
+ HELP
511
131
  end
512
-
513
- data = { "version" => "1", "created" => Time.now.utc.iso8601, "files" => entries }
514
- File.write(manifest_path, JSON.pretty_generate(data) + "\n")
515
- end
516
-
517
- def write_if_missing(path, content)
518
- File.write(path, content) unless File.exist?(path)
519
132
  end
520
133
 
521
- # --- Run ---
522
-
523
- main
134
+ exit(Install.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__