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