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