@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
@@ -0,0 +1,747 @@
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
+ global_files = core_files.values.map { |d| File.join(plastic_home, d) }
181
+ global_files << File.join(plastic_home, "VERSION")
182
+ global_files = global_files.select { |p| File.exist?(p) }
183
+ write_manifest(global_files, File.join(plastic_home, "manifest.json"))
184
+
185
+ puts " \u{2705} Core files synced (v#{version})"
186
+ end
187
+
188
+ # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
189
+ # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
190
+ # by install_sync_test).
191
+ def core_files
192
+ {
193
+ "PLASTIC.md" => "PLASTIC.md",
194
+ "deprecations.yml" => "deprecations.yml",
195
+ "scripts/folgezettel-id" => "scripts/folgezettel-id",
196
+ "scripts/read-config" => "scripts/read-config",
197
+ "scripts/select-update-target" => "scripts/select-update-target",
198
+ "scripts/hook-session-start" => "scripts/hook-session-start",
199
+ "scripts/hook-continue" => "scripts/hook-continue",
200
+ "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
201
+ "scripts/hook-gate-check" => "scripts/hook-gate-check",
202
+ "scripts/hook-code-gate" => "scripts/hook-code-gate",
203
+ "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
204
+ "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
205
+ "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
206
+ "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
207
+ "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
208
+ "scripts/qmd-sync" => "scripts/qmd-sync",
209
+ "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
210
+ "scripts/validate-intent" => "scripts/validate-intent",
211
+ "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
212
+ "scripts/provision-project-store" => "scripts/provision-project-store",
213
+ "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
214
+ "scripts/install.rb" => "scripts/install.rb",
215
+ "scripts/update.rb" => "scripts/update.rb",
216
+ "scripts/uninstall.rb" => "scripts/uninstall.rb",
217
+ "scripts/versions.rb" => "scripts/versions.rb",
218
+ "scripts/doctor.rb" => "scripts/doctor.rb",
219
+ "scripts/dashboard.rb" => "scripts/dashboard.rb",
220
+ }
221
+ end
222
+
223
+ def bootstrap
224
+ puts " \u{1f331} First install \u{2014} bootstrapping store..."
225
+
226
+ FileUtils.mkdir_p(File.join(plastic_home, "store"))
227
+ FileUtils.mkdir_p(File.join(plastic_home, "projects"))
228
+
229
+ write_if_missing(File.join(plastic_home, "config.yml"), <<~YAML)
230
+ version: 3
231
+ execution_mode: subagent-driven
232
+ stale_threshold_days: 3
233
+ hash_length: 6
234
+ hash_algorithm: sha256-base36
235
+ max_slug_words: 5
236
+ agent:
237
+ type: claude-code
238
+ parallel_mode: agent-teams
239
+ YAML
240
+
241
+ write_if_missing(File.join(plastic_home, "projects.yml"), "---\nprojects: {}\n")
242
+
243
+ write_if_missing(File.join(plastic_home, "INDEX.md"), <<~MD)
244
+ # Index
245
+
246
+ ## Active
247
+
248
+ ## Future
249
+
250
+ ## Clusters
251
+
252
+ ## Abandoned
253
+
254
+ ## Completed
255
+ MD
256
+
257
+ write_if_missing(File.join(plastic_home, "AGENTS.md"), <<~MD)
258
+ # Plastic \u{2014} Agent Instructions
259
+
260
+ Read `PLASTIC.md` in this directory. It contains all Plastic conventions.
261
+ Follow it exactly. Never modify it \u{2014} it is overwritten on plugin updates.
262
+
263
+ This file (`AGENTS.md`) is where project-specific rules live.
264
+
265
+ ---
266
+ MD
267
+
268
+ puts " \u{2705} Store bootstrapped"
269
+ end
270
+
271
+ # --- Agent adapters ---
272
+
273
+ def manifest_path_for(key, config)
274
+ case key
275
+ when "claude" then File.join(config[:dir], "plastic", "manifest.json")
276
+ else File.join(config[:dir], "plastic-manifest.json")
277
+ end
278
+ end
279
+
280
+ def manifest_files(manifest_path)
281
+ return [] unless File.exist?(manifest_path)
282
+ data = JSON.parse(File.read(manifest_path)) rescue {}
283
+ (data["files"] || {}).keys
284
+ end
285
+
286
+ def install_for_agent(key, force)
287
+ config = agent_config(key)
288
+ return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
289
+
290
+ unless File.directory?(config[:dir])
291
+ return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
292
+ end
293
+
294
+ # Capture the prior manifest so we can prune files that no longer ship
295
+ # (renamed/removed skills) after a re-copy. This gives leftover-free updates.
296
+ old_files = manifest_files(manifest_path_for(key, config))
297
+
298
+ result = case key
299
+ when "claude" then install_claude(config, force)
300
+ when "codex" then install_codex(config, force)
301
+ when "hermes" then install_hermes(config, force)
302
+ end
303
+
304
+ new_files = manifest_files(manifest_path_for(key, config))
305
+ pruned = prune_removed_files(old_files - new_files)
306
+ result[:pruned] = pruned if pruned.positive?
307
+ result
308
+ end
309
+
310
+ # Delete tracked files present in the old manifest but absent from the new one,
311
+ # then remove any now-empty skill directories they lived in.
312
+ def prune_removed_files(stale_files)
313
+ removed = 0
314
+ dirs = []
315
+ stale_files.each do |f|
316
+ if File.exist?(f)
317
+ File.delete(f)
318
+ removed += 1
319
+ end
320
+ dirs << File.dirname(f)
321
+ end
322
+ dirs.uniq.sort_by { |d| -d.length }.each do |d|
323
+ FileUtils.rmdir(d) if File.directory?(d) && Dir.empty?(d)
324
+ end
325
+ removed
326
+ end
327
+
328
+ def install_claude(config, force)
329
+ hooks_dir = File.join(config[:dir], "hooks")
330
+ skills_root = File.join(config[:dir], "skills")
331
+ plastic_dir = File.join(config[:dir], "plastic")
332
+
333
+ FileUtils.mkdir_p(hooks_dir)
334
+ FileUtils.mkdir_p(skills_root)
335
+ FileUtils.mkdir_p(plastic_dir)
336
+
337
+ installed = []
338
+
339
+ # Copy hooks, rewriting script paths for the installed location
340
+ hook_source = File.join(package_root, "hooks")
341
+ Dir.glob(File.join(hook_source, "*")).each do |f|
342
+ next unless File.file?(f)
343
+ basename = File.basename(f)
344
+ next if %w[hooks.json run-hook].include?(basename)
345
+ dest_name = basename.start_with?("plastic-") ? basename : "plastic-#{basename}"
346
+ dest = File.join(hooks_dir, dest_name)
347
+ content = File.read(f)
348
+ content = content.gsub('$SCRIPT_DIR/../scripts/', '$HOME/.plastic/scripts/')
349
+ File.write(dest, content)
350
+ FileUtils.chmod(0o755, dest)
351
+ installed << dest
352
+ end
353
+
354
+ # Copy skills as flat, hyphen-namespaced personal skills (plastic-<name>/)
355
+ skills_source = File.join(package_root, "skills")
356
+ installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
357
+
358
+ # Write VERSION
359
+ version_file = File.join(plastic_dir, "VERSION")
360
+ File.write(version_file, "#{version}\n")
361
+ installed << version_file
362
+
363
+ # Remove any prior plugin/marketplace install so the layouts don't coexist
364
+ migrate_legacy_plugin(config[:dir])
365
+
366
+ # Merge hooks + statusline into settings.json (no plugin registration)
367
+ settings_path = File.join(config[:dir], "settings.json")
368
+ merge_claude_hooks(settings_path)
369
+
370
+ # Write manifest
371
+ manifest_path = File.join(plastic_dir, "manifest.json")
372
+ write_manifest(installed, manifest_path)
373
+
374
+ { agent: config[:name], success: true, files: installed.size }
375
+ end
376
+
377
+ def install_codex(config, force)
378
+ installed = []
379
+ skills_source = File.join(package_root, "skills")
380
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
381
+
382
+ write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
383
+ { agent: config[:name], success: true, files: installed.size }
384
+ end
385
+
386
+ def install_hermes(config, force)
387
+ installed = []
388
+ skills_source = File.join(package_root, "skills")
389
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
390
+
391
+ write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
392
+ { agent: config[:name], success: true, files: installed.size }
393
+ end
394
+
395
+ # Copy each skills/<name>/ to <skills_root>/plastic-<name>/ (flat, namespaced by
396
+ # directory name — the only personal-skill namespacing Claude Code supports).
397
+ # The non-skill `_active-intent-gate.md` is relocated to ~/.plastic/ instead.
398
+ def install_skills_flat(skills_source, skills_root)
399
+ installed = []
400
+ FileUtils.mkdir_p(skills_root)
401
+
402
+ Dir.children(skills_source).reject { |e| e.start_with?(".") }.each do |entry|
403
+ src = File.join(skills_source, entry)
404
+ if File.directory?(src)
405
+ installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
406
+ elsif entry == "_active-intent-gate.md"
407
+ FileUtils.mkdir_p(plastic_home)
408
+ dest = File.join(plastic_home, entry)
409
+ FileUtils.cp(src, dest)
410
+ installed << dest
411
+ end
412
+ end
413
+
414
+ installed
415
+ end
416
+
417
+ # --- Legacy plugin migration ---
418
+
419
+ # Earlier versions registered Plastic as a local marketplace plugin
420
+ # (plastic@plastic) for skill discovery. The current model uses flat,
421
+ # hyphen-namespaced personal skills, so any prior plugin layout is removed here
422
+ # to stop the two coexisting. Returns a list of what was migrated.
423
+ def migrate_legacy_plugin(claude_dir)
424
+ removed = []
425
+
426
+ legacy_dirs = [
427
+ File.join(claude_dir, "plugins", "marketplaces", "plastic"),
428
+ File.join(claude_dir, "plugins", "cache", "plastic"),
429
+ File.join(claude_dir, "skills", "plastic"), # old nested skills layout
430
+ ]
431
+ legacy_dirs.each do |d|
432
+ if File.directory?(d)
433
+ FileUtils.rm_rf(d)
434
+ removed << d
435
+ end
436
+ end
437
+
438
+ # settings.json: drop enabledPlugins + extraKnownMarketplaces entries
439
+ settings_path = File.join(claude_dir, "settings.json")
440
+ settings = read_json_safe(settings_path)
441
+ if settings
442
+ changed = false
443
+ if settings.dig("enabledPlugins", "plastic@plastic")
444
+ settings["enabledPlugins"].delete("plastic@plastic")
445
+ settings.delete("enabledPlugins") if settings["enabledPlugins"].empty?
446
+ changed = true
447
+ end
448
+ if settings.dig("extraKnownMarketplaces", "plastic")
449
+ settings["extraKnownMarketplaces"].delete("plastic")
450
+ settings.delete("extraKnownMarketplaces") if settings["extraKnownMarketplaces"].empty?
451
+ changed = true
452
+ end
453
+ if changed
454
+ write_json_atomic(settings_path, settings)
455
+ removed << "settings.json: plastic@plastic plugin registration"
456
+ end
457
+ end
458
+
459
+ # plugins/known_marketplaces.json: drop the plastic entry
460
+ known_path = File.join(claude_dir, "plugins", "known_marketplaces.json")
461
+ known = read_json_safe(known_path)
462
+ if known.is_a?(Hash) && known.key?("plastic")
463
+ known.delete("plastic")
464
+ write_json_atomic(known_path, known)
465
+ removed << "known_marketplaces.json: plastic"
466
+ end
467
+
468
+ unless removed.empty?
469
+ puts " \u{1f9f9} Migrated legacy plugin install:"
470
+ removed.each { |r| puts " - #{tilde(r)}" }
471
+ end
472
+
473
+ removed
474
+ end
475
+
476
+ def tilde(path)
477
+ path.sub(Dir.home, "~")
478
+ end
479
+
480
+ # --- settings.json merge (read-modify-write, never clobber) ---
481
+
482
+ def merge_claude_hooks(settings_path)
483
+ settings = read_json_safe(settings_path) || {}
484
+ return if settings.nil?
485
+
486
+ hooks = settings["hooks"] ||= {}
487
+ hook_dir = File.join(Dir.home, ".claude", "hooks")
488
+
489
+ purge_stale_plastic_hooks(hooks)
490
+
491
+ plastic_hooks = {
492
+ "SessionStart" => {
493
+ "matcher" => "",
494
+ "hooks" => [
495
+ { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
496
+ { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
497
+ ],
498
+ },
499
+ "PreCompact" => {
500
+ "matcher" => "",
501
+ "hooks" => [
502
+ { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
503
+ ],
504
+ },
505
+ "PreToolUse" => {
506
+ "matcher" => "Write|Edit|NotebookEdit",
507
+ "hooks" => [
508
+ { "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
509
+ ],
510
+ },
511
+ "PostToolUse" => {
512
+ "matcher" => "Write|Edit",
513
+ "hooks" => [
514
+ { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
515
+ ],
516
+ },
517
+ "UserPromptSubmit" => {
518
+ "matcher" => "",
519
+ "hooks" => [
520
+ { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
521
+ { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
522
+ { "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
523
+ ],
524
+ },
525
+ }
526
+
527
+ plastic_hooks.each do |event, group|
528
+ hooks[event] ||= []
529
+ existing = hooks[event].find { |g| g.is_a?(Hash) && g["hooks"].is_a?(Array) && g["hooks"].any? { |h| h["command"].to_s.include?("plastic-") } }
530
+
531
+ if existing
532
+ existing["matcher"] = group["matcher"]
533
+ existing["hooks"] = group["hooks"]
534
+ else
535
+ hooks[event] << group
536
+ end
537
+ end
538
+
539
+ existing_status = settings["statusLine"]
540
+ if existing_status && !existing_status.dig("command").to_s.include?("plastic-")
541
+ cache_dir = File.join(plastic_home, ".cache")
542
+ FileUtils.mkdir_p(cache_dir)
543
+ File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
544
+ end
545
+
546
+ settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
547
+
548
+ # No plugin/marketplace registration: skills are flat personal skills
549
+ # (plastic-<name>/) discovered directly from ~/.claude/skills.
550
+
551
+ write_json_atomic(settings_path, settings)
552
+ end
553
+
554
+ def purge_stale_plastic_hooks(hooks)
555
+ plastic_cmd = ->(cmd) { cmd.to_s.include?("plastic-") }
556
+
557
+ hooks.delete("statusLine")
558
+
559
+ hooks.each do |event, groups|
560
+ next unless groups.is_a?(Array)
561
+
562
+ hooks[event] = groups.map do |group|
563
+ if group.is_a?(Hash) && group["hooks"].is_a?(Array)
564
+ group["hooks"].reject! { |h| plastic_cmd.call(h["command"]) }
565
+ group unless group["hooks"].empty?
566
+ elsif group.is_a?(Hash) && group["command"]
567
+ plastic_cmd.call(group["command"]) ? nil : group
568
+ else
569
+ group
570
+ end
571
+ end.compact
572
+ end
573
+ end
574
+
575
+ # --- Uninstall ---
576
+
577
+ def handle_uninstall(uninstall_agents)
578
+ uninstall_agents.each do |key|
579
+ config = agent_config(key)
580
+ next unless config
581
+
582
+ result = uninstall_agent(key, config)
583
+ unless result[:success]
584
+ puts " \u{26a0}\u{fe0f} #{config[:name]}: #{result[:reason]}"
585
+ next
586
+ end
587
+
588
+ puts " \u{2705} #{config[:name]}: uninstalled (#{result[:files]} files removed)"
589
+ result[:removed].each { |r| puts " removed: #{tilde(r)}" }
590
+ end
591
+
592
+ # What was deliberately left behind
593
+ puts "\n Left in place (not removed):"
594
+ puts " - #{tilde(plastic_home)} (your intent store, history, and projects)"
595
+ puts " - any non-Plastic entries in settings.json"
596
+
597
+ puts "\n Verify removal:"
598
+ puts " ls ~/.claude/skills | grep '^plastic-' # → no output"
599
+ puts " ls ~/.claude/hooks | grep '^plastic-' # → no output"
600
+ puts " grep -c plastic ~/.claude/settings.json # → only hook refs gone"
601
+ puts "\n To also delete your intent store: rm -rf #{tilde(plastic_home)}\n\n"
602
+ end
603
+
604
+ def uninstall_agent(key, config)
605
+ unless File.directory?(config[:dir])
606
+ return { success: false, reason: "#{config[:dir]} not found" }
607
+ end
608
+
609
+ removed = []
610
+ manifest_path = manifest_path_for(key, config)
611
+
612
+ if File.exist?(manifest_path)
613
+ manifest = JSON.parse(File.read(manifest_path)) rescue {}
614
+ (manifest["files"] || {}).each_key do |f|
615
+ if File.exist?(f)
616
+ File.delete(f)
617
+ removed << f
618
+ end
619
+ end
620
+ File.delete(manifest_path)
621
+ removed << manifest_path
622
+ end
623
+
624
+ # Remove flat plastic-<name>/ skill dirs (now-empty after manifest deletion,
625
+ # plus any the manifest missed) and the plastic state dir.
626
+ skills_root = File.join(config[:dir], "skills")
627
+ if File.directory?(skills_root)
628
+ Dir.children(skills_root).select { |e| e.start_with?("plastic-") }.each do |d|
629
+ full = File.join(skills_root, d)
630
+ FileUtils.rm_rf(full)
631
+ removed << full
632
+ end
633
+ end
634
+
635
+ [File.join(config[:dir], "plastic")].each do |d|
636
+ if File.directory?(d)
637
+ FileUtils.rm_rf(d)
638
+ removed << d
639
+ end
640
+ end
641
+
642
+ # Claude Code: clean hooks/statusline and any legacy plugin registration
643
+ if key == "claude"
644
+ settings_path = File.join(config[:dir], "settings.json")
645
+ remove_claude_hooks(settings_path) if File.exist?(settings_path)
646
+ removed.concat(migrate_legacy_plugin(config[:dir]))
647
+ end
648
+
649
+ { success: true, files: removed.size, removed: removed }
650
+ end
651
+
652
+ def remove_claude_hooks(settings_path)
653
+ settings = read_json_safe(settings_path)
654
+ return unless settings && settings["hooks"]
655
+
656
+ settings["hooks"].each do |event, groups|
657
+ next unless groups.is_a?(Array)
658
+
659
+ settings["hooks"][event] = groups.map do |group|
660
+ if group.is_a?(Hash) && group["hooks"].is_a?(Array)
661
+ group["hooks"].reject! { |h| h["command"].to_s.include?("plastic-") }
662
+ group unless group["hooks"].empty?
663
+ elsif group.is_a?(Hash) && group["command"]
664
+ group["command"].to_s.include?("plastic-") ? nil : group
665
+ else
666
+ group
667
+ end
668
+ end.compact
669
+ end
670
+
671
+ settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
672
+ settings.delete("hooks") if settings["hooks"]&.empty?
673
+ if settings.dig("statusLine", "command").to_s.include?("plastic-")
674
+ settings.delete("statusLine")
675
+ original_path = File.join(plastic_home, ".cache", "original-statusline.json")
676
+ if File.exist?(original_path)
677
+ original = JSON.parse(File.read(original_path)) rescue nil
678
+ settings["statusLine"] = original if original
679
+ end
680
+ end
681
+
682
+ # Remove from enabledPlugins
683
+ if settings["enabledPlugins"]
684
+ settings["enabledPlugins"].delete("plastic@plastic")
685
+ settings.delete("enabledPlugins") if settings["enabledPlugins"].empty?
686
+ end
687
+
688
+ write_json_atomic(settings_path, settings)
689
+ end
690
+
691
+ # --- Utilities ---
692
+
693
+ def agent_config(key)
694
+ agents.find { |a| a[:key] == key }
695
+ end
696
+
697
+ def copy_dir_recursive(src, dest)
698
+ files = []
699
+ FileUtils.mkdir_p(dest)
700
+ Dir.entries(src).reject { |e| e.start_with?(".") }.each do |entry|
701
+ src_path = File.join(src, entry)
702
+ dest_path = File.join(dest, entry)
703
+ if File.directory?(src_path)
704
+ files += copy_dir_recursive(src_path, dest_path)
705
+ elsif File.file?(src_path)
706
+ FileUtils.cp(src_path, dest_path)
707
+ files << dest_path
708
+ end
709
+ end
710
+ files
711
+ end
712
+
713
+ def read_json_safe(path)
714
+ return nil unless File.exist?(path)
715
+ JSON.parse(File.read(path))
716
+ rescue JSON::ParserError
717
+ # Try JSONC stripping (remove // comments and trailing commas)
718
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
719
+ JSON.parse(content)
720
+ rescue
721
+ nil
722
+ end
723
+
724
+ def write_json_atomic(path, data)
725
+ content = JSON.pretty_generate(data) + "\n"
726
+ tmp = "#{path}.plastic-tmp.#{Process.pid}"
727
+ File.write(tmp, content)
728
+ File.rename(tmp, path)
729
+ rescue => e
730
+ File.delete(tmp) if tmp && File.exist?(tmp)
731
+ raise e
732
+ end
733
+
734
+ def write_manifest(files, manifest_path)
735
+ entries = {}
736
+ files.each do |f|
737
+ entries[f] = Digest::SHA256.file(f).hexdigest if File.exist?(f)
738
+ end
739
+
740
+ data = { "version" => "1", "created" => Time.now.utc.iso8601, "files" => entries }
741
+ File.write(manifest_path, JSON.pretty_generate(data) + "\n")
742
+ end
743
+
744
+ def write_if_missing(path, content)
745
+ File.write(path, content) unless File.exist?(path)
746
+ end
747
+ end