@zalom/plastic 1.0.0-alpha.8 → 1.0.0-beta.1

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