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

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