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