@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2

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 (99) hide show
  1. package/PLASTIC.md +163 -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 +687 -0
  23. package/scripts/doctor.rb +1054 -628
  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 -529
  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 +235 -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 +226 -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 +74 -36
  61. package/skills/creating-intent/evals/evals.json +56 -0
  62. package/skills/creating-intent/references/lifecycle.md +76 -0
  63. package/skills/creating-intent/references/wikilinks.md +8 -0
  64. package/skills/creating-project/SKILL.md +40 -8
  65. package/skills/creating-project/references/hubs-projects.md +55 -0
  66. package/skills/dashboard/SKILL.md +121 -0
  67. package/skills/dashboard/templates/dashboard-global.md +31 -0
  68. package/skills/dashboard/templates/dashboard-project.md +40 -0
  69. package/skills/doctor/SKILL.md +51 -4
  70. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  71. package/skills/doctor/report.md +4 -0
  72. package/skills/evaluating-skills/SKILL.md +140 -0
  73. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  74. package/skills/evaluating-skills/evals/evals.json +75 -0
  75. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  76. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  77. package/skills/executing-plan/SKILL.md +5 -3
  78. package/skills/install/SKILL.md +69 -8
  79. package/skills/intent-curator/SKILL.md +3 -3
  80. package/skills/linking-intents/SKILL.md +16 -7
  81. package/skills/linking-intents/references/zettelkasten.md +38 -0
  82. package/skills/managing-index/SKILL.md +5 -1
  83. package/skills/managing-index/references/zettelkasten-linking.md +1 -1
  84. package/skills/releasing/SKILL.md +80 -23
  85. package/skills/releasing/references/deprecations.md +60 -0
  86. package/skills/research/SKILL.md +2 -2
  87. package/skills/savepoint/SKILL.md +46 -37
  88. package/skills/savepoint/references/context-management.md +32 -0
  89. package/skills/uninstall/SKILL.md +39 -28
  90. package/skills/update/SKILL.md +41 -44
  91. package/skills/versions/SKILL.md +65 -0
  92. package/skills/writing-instructions/SKILL.md +159 -0
  93. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  94. package/skills/writing-plans/SKILL.md +5 -5
  95. package/templates/agents.md +7 -7
  96. package/templates/outcome.md +13 -0
  97. package/templates/savepoint.md +14 -13
  98. package/templates/spec.md +25 -0
  99. package/bin/install.js +0 -29
@@ -2,571 +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
-
112
- def show_help
113
- puts <<~HELP
114
-
115
- plastic - Intent-driven idea development system
116
-
117
- Usage:
118
- npx @zalom/plastic@latest [options]
119
35
 
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
36
+ puts "\n\u{1f9e0} Plastic v#{version}\n\n"
128
37
 
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")
191
-
192
- write_if_missing(File.join(PLASTIC_HOME, "INDEX.md"), <<~MD)
193
- # Index
194
-
195
- ## Active
196
-
197
- ## Future
198
-
199
- ## Clusters
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"
200
53
 
201
- ## Abandoned
54
+ distribute(mode)
55
+ bootstrap if fresh
202
56
 
203
- ## Completed
204
- MD
57
+ results = selected.map { |key| install_for_agent(key, force) }
205
58
 
206
- write_if_missing(File.join(PLASTIC_HOME, "AGENTS.md"), <<~MD)
207
- # Plastic \u{2014} Agent Instructions
59
+ action = ledger_action || (fresh ? "install" : "reinstall")
60
+ ledger_append(version, action)
208
61
 
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
- def bootstrap_project_store(slug)
221
- project_dir = File.join(PLASTIC_HOME, "projects", slug)
222
- store_dir = File.join(project_dir, "store")
223
-
224
- FileUtils.mkdir_p(store_dir)
225
-
226
- template = File.join(PACKAGE_ROOT, "templates", "project.yml")
227
- dest = File.join(project_dir, "project.yml")
228
- write_if_missing(dest, File.read(template)) if File.exist?(template)
229
-
230
- write_if_missing(File.join(project_dir, "INDEX.md"), <<~MD)
231
- # Index
232
-
233
- ## Active
234
-
235
- ## Future
236
-
237
- ## Clusters
238
-
239
- ## Abandoned
240
-
241
- ## Completed
242
- MD
243
- end
244
-
245
- # --- Agent adapters ---
246
-
247
- def install_for_agent(key, force)
248
- config = agent_config(key)
249
- return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
250
-
251
- unless File.directory?(config[:dir])
252
- 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
253
64
  end
254
65
 
255
- case key
256
- when "claude" then install_claude(config, force)
257
- when "codex" then install_codex(config, force)
258
- when "hermes" then install_hermes(config, force)
66
+ def installed?
67
+ File.exist?(File.join(plastic_home, "VERSION"))
259
68
  end
260
- end
261
69
 
262
- def install_claude(config, force)
263
- hooks_dir = File.join(config[:dir], "hooks")
264
- skills_dir = File.join(config[:dir], "skills", "plastic")
265
- plastic_dir = File.join(config[:dir], "plastic")
266
-
267
- FileUtils.mkdir_p(hooks_dir)
268
- FileUtils.mkdir_p(skills_dir)
269
- FileUtils.mkdir_p(plastic_dir)
270
-
271
- installed = []
272
-
273
- # Copy hooks, rewriting script paths for the installed location
274
- hook_source = File.join(PACKAGE_ROOT, "hooks")
275
- Dir.glob(File.join(hook_source, "*")).each do |f|
276
- next unless File.file?(f)
277
- basename = File.basename(f)
278
- next if %w[hooks.json run-hook].include?(basename)
279
- dest_name = basename.start_with?("plastic-") ? basename : "plastic-#{basename}"
280
- dest = File.join(hooks_dir, dest_name)
281
- content = File.read(f)
282
- content = content.gsub('$SCRIPT_DIR/../scripts/', '$HOME/.plastic/scripts/')
283
- File.write(dest, content)
284
- FileUtils.chmod(0o755, dest)
285
- installed << dest
70
+ def installed_version
71
+ path = File.join(plastic_home, "VERSION")
72
+ File.exist?(path) ? File.read(path).strip : nil
286
73
  end
287
74
 
288
- # Copy skills recursively
289
- skills_source = File.join(PACKAGE_ROOT, "skills")
290
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
291
-
292
- # Write VERSION
293
- version_file = File.join(plastic_dir, "VERSION")
294
- File.write(version_file, "#{VERSION}\n")
295
- installed << version_file
296
-
297
- # Merge hooks into settings.json
298
- settings_path = File.join(config[:dir], "settings.json")
299
- merge_claude_hooks(settings_path)
300
-
301
- # Write manifest
302
- manifest_path = File.join(plastic_dir, "manifest.json")
303
- write_manifest(installed, manifest_path)
304
-
305
- { agent: config[:name], success: true, files: installed.size }
306
- end
307
-
308
- def install_codex(config, force)
309
- skills_dir = File.join(config[:dir], "skills", "plastic")
310
- FileUtils.mkdir_p(skills_dir)
311
-
312
- installed = []
313
- skills_source = File.join(PACKAGE_ROOT, "skills")
314
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
315
-
316
- manifest_path = File.join(config[:dir], "plastic-manifest.json")
317
- write_manifest(installed, manifest_path)
318
-
319
- { agent: config[:name], success: true, files: installed.size }
320
- end
321
-
322
- def install_hermes(config, force)
323
- skills_dir = File.join(config[:dir], "skills", "plastic")
324
- FileUtils.mkdir_p(skills_dir)
325
-
326
- installed = []
327
- skills_source = File.join(PACKAGE_ROOT, "skills")
328
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
329
-
330
- manifest_path = File.join(config[:dir], "plastic-manifest.json")
331
- write_manifest(installed, manifest_path)
332
-
333
- { agent: config[:name], success: true, files: installed.size }
334
- end
335
-
336
- # --- settings.json merge (read-modify-write, never clobber) ---
75
+ private
337
76
 
338
- def merge_claude_hooks(settings_path)
339
- settings = read_json_safe(settings_path) || {}
340
- return if settings.nil?
341
-
342
- hooks = settings["hooks"] ||= {}
343
- hook_dir = File.join(Dir.home, ".claude", "hooks")
344
-
345
- purge_stale_plastic_hooks(hooks)
346
-
347
- plastic_hooks = {
348
- "SessionStart" => {
349
- "matcher" => "",
350
- "hooks" => [
351
- { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
352
- { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
353
- ],
354
- },
355
- "PreCompact" => {
356
- "matcher" => "",
357
- "hooks" => [
358
- { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
359
- ],
360
- },
361
- "PostToolUse" => {
362
- "matcher" => "Write|Edit",
363
- "hooks" => [
364
- { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
365
- ],
366
- },
367
- "UserPromptSubmit" => {
368
- "matcher" => "",
369
- "hooks" => [
370
- { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
371
- { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
372
- ],
373
- },
374
- }
375
-
376
- plastic_hooks.each do |event, group|
377
- hooks[event] ||= []
378
- existing = hooks[event].find { |g| g.is_a?(Hash) && g["hooks"].is_a?(Array) && g["hooks"].any? { |h| h["command"].to_s.include?("plastic-") } }
379
-
380
- if existing
381
- existing["matcher"] = group["matcher"]
382
- existing["hooks"] = group["hooks"]
383
- else
384
- hooks[event] << group
385
- end
386
- end
387
-
388
- existing_status = settings["statusLine"]
389
- if existing_status && !existing_status.dig("command").to_s.include?("plastic-")
390
- cache_dir = File.join(PLASTIC_HOME, ".cache")
391
- FileUtils.mkdir_p(cache_dir)
392
- 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]
393
81
  end
394
82
 
395
- settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
396
-
397
- write_json_atomic(settings_path, settings)
398
- end
399
-
400
- def purge_stale_plastic_hooks(hooks)
401
- plastic_cmd = ->(cmd) { cmd.to_s.include?("plastic-") }
402
-
403
- hooks.delete("statusLine")
404
-
405
- hooks.each do |event, groups|
406
- next unless groups.is_a?(Array)
407
-
408
- hooks[event] = groups.map do |group|
409
- if group.is_a?(Hash) && group["hooks"].is_a?(Array)
410
- group["hooks"].reject! { |h| plastic_cmd.call(h["command"]) }
411
- group unless group["hooks"].empty?
412
- elsif group.is_a?(Hash) && group["command"]
413
- 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"
414
88
  else
415
- group
416
- end
417
- end.compact
418
- end
419
- end
420
-
421
- # --- Uninstall ---
422
-
423
- def handle_uninstall(agents)
424
- agents.each do |key|
425
- config = agent_config(key)
426
- next unless config
427
-
428
- result = uninstall_agent(key, config)
429
- if result[:success]
430
- puts " \u{2705} #{config[:name]}: uninstalled (#{result[:files]} files removed)"
431
- else
432
- puts " \u{26a0}\u{fe0f} #{config[:name]}: #{result[:reason]}"
433
- end
434
- end
435
-
436
- puts "\n Note: ~/.plastic/ (your intent store) is preserved.\n\n"
437
- end
438
-
439
- def uninstall_agent(key, config)
440
- unless File.directory?(config[:dir])
441
- return { success: false, reason: "#{config[:dir]} not found" }
442
- end
443
-
444
- manifest_path = case key
445
- when "claude" then File.join(config[:dir], "plastic", "manifest.json")
446
- else File.join(config[:dir], "plastic-manifest.json")
447
- end
448
-
449
- files_removed = 0
450
-
451
- if File.exist?(manifest_path)
452
- manifest = JSON.parse(File.read(manifest_path)) rescue {}
453
- (manifest["files"] || {}).each_key do |f|
454
- if File.exist?(f)
455
- File.delete(f)
456
- files_removed += 1
89
+ puts " \u{26a0}\u{fe0f} #{r[:agent]}: #{r[:reason]}"
457
90
  end
458
91
  end
459
- File.delete(manifest_path)
460
- files_removed += 1
461
- end
462
-
463
- # Clean known directories
464
- dirs_to_clean = case key
465
- when "claude" then [File.join(config[:dir], "plastic"), File.join(config[:dir], "skills", "plastic")]
466
- else [File.join(config[:dir], "skills", "plastic")]
467
- end
468
-
469
- dirs_to_clean.each { |d| FileUtils.rm_rf(d) if File.directory?(d) }
470
-
471
- # Clean hooks from settings.json (Claude Code only)
472
- if key == "claude"
473
- settings_path = File.join(config[:dir], "settings.json")
474
- remove_claude_hooks(settings_path) if File.exist?(settings_path)
475
- end
476
-
477
- { success: true, files: files_removed }
478
- end
479
92
 
480
- def remove_claude_hooks(settings_path)
481
- settings = read_json_safe(settings_path)
482
- return unless settings && settings["hooks"]
93
+ installed = results.select { |r| r[:success] }
94
+ return unless installed.any?
483
95
 
484
- settings["hooks"].each do |event, groups|
485
- next unless groups.is_a?(Array)
486
-
487
- settings["hooks"][event] = groups.map do |group|
488
- if group.is_a?(Hash) && group["hooks"].is_a?(Array)
489
- group["hooks"].reject! { |h| h["command"].to_s.include?("plastic-") }
490
- group unless group["hooks"].empty?
491
- elsif group.is_a?(Hash) && group["command"]
492
- group["command"].to_s.include?("plastic-") ? nil : group
493
- else
494
- group
495
- end
496
- 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"
497
100
  end
498
101
 
499
- settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
500
- settings.delete("hooks") if settings["hooks"]&.empty?
501
- if settings.dig("statusLine", "command").to_s.include?("plastic-")
502
- settings.delete("statusLine")
503
- original_path = File.join(PLASTIC_HOME, ".cache", "original-statusline.json")
504
- if File.exist?(original_path)
505
- original = JSON.parse(File.read(original_path)) rescue nil
506
- settings["statusLine"] = original if original
507
- end
508
- end
102
+ def show_help
103
+ puts <<~HELP
509
104
 
510
- write_json_atomic(settings_path, settings)
511
- end
105
+ plastic install — install or re-sync Plastic for an agent
512
106
 
513
- # --- Utilities ---
107
+ Usage:
108
+ npx @zalom/plastic install [options]
514
109
 
515
- def agent_config(key)
516
- AGENTS.find { |a| a[:key] == key }
517
- 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
518
115
 
519
- def copy_dir_recursive(src, dest)
520
- files = []
521
- FileUtils.mkdir_p(dest)
522
- Dir.entries(src).reject { |e| e.start_with?(".") }.each do |entry|
523
- src_path = File.join(src, entry)
524
- dest_path = File.join(dest, entry)
525
- if File.directory?(src_path)
526
- files += copy_dir_recursive(src_path, dest_path)
527
- elsif File.file?(src_path)
528
- FileUtils.cp(src_path, dest_path)
529
- files << dest_path
530
- end
531
- end
532
- files
533
- end
116
+ Channel options:
117
+ --latest Stable channel (default)
118
+ --beta Beta channel
119
+ --alpha Alpha channel
534
120
 
535
- def read_json_safe(path)
536
- return nil unless File.exist?(path)
537
- JSON.parse(File.read(path))
538
- rescue JSON::ParserError
539
- # Try JSONC stripping (remove // comments and trailing commas)
540
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
541
- JSON.parse(content)
542
- rescue
543
- nil
544
- 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
545
125
 
546
- def write_json_atomic(path, data)
547
- content = JSON.pretty_generate(data) + "\n"
548
- tmp = "#{path}.plastic-tmp.#{Process.pid}"
549
- File.write(tmp, content)
550
- File.rename(tmp, path)
551
- rescue => e
552
- File.delete(tmp) if tmp && File.exist?(tmp)
553
- raise e
554
- 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`.
555
129
 
556
- def write_manifest(files, manifest_path)
557
- entries = {}
558
- files.each do |f|
559
- entries[f] = Digest::SHA256.file(f).hexdigest if File.exist?(f)
130
+ HELP
560
131
  end
561
-
562
- data = { "version" => "1", "created" => Time.now.utc.iso8601, "files" => entries }
563
- File.write(manifest_path, JSON.pretty_generate(data) + "\n")
564
- end
565
-
566
- def write_if_missing(path, content)
567
- File.write(path, content) unless File.exist?(path)
568
132
  end
569
133
 
570
- # --- Run ---
571
-
572
- main
134
+ exit(Install.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__