@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31

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