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