@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,57 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "qmd_sync"
5
+ require_relative "power_tools"
6
+
7
+ # QmdHook — decision logic for the power-tools UserPromptSubmit hook (intents 66,
8
+ # 66b). Pure and dependency-injected: returns the additionalContext string to
9
+ # emit, or nil to emit nothing. The executable hook wires real deps and prints;
10
+ # this is unit-tested with a fake runner/detector (no real qmd, no network).
11
+ #
12
+ # When qmd is present it still injects scored qmd hits (intent 66), then appends
13
+ # the PowerTools mandate (a MUST obligation per present tool: qmd for finding
14
+ # intents, serena for code navigation) instead of the old soft reminder.
15
+ module QmdHook
16
+ module_function
17
+
18
+ MIN_PROMPT_LENGTH = 10
19
+
20
+ def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
21
+ detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
22
+ serena_detector: nil)
23
+ serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
24
+ qmd_present = !!detector.call
25
+ serena_present = !!serena_detector.call
26
+ return nil unless qmd_present || serena_present
27
+
28
+ p = prompt.to_s.strip
29
+ # The hit SEARCH is the only expensive step and the only one gated by prompt
30
+ # triviality: skip it for short or bare-"continue" prompts (and when qmd is
31
+ # absent). The mandate itself is always-on for whichever tools are present.
32
+ search_ok = qmd_present && p.length >= MIN_PROMPT_LENGTH && p.downcase != "continue"
33
+
34
+ parts = []
35
+ if search_ok
36
+ collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
37
+ hits = QmdSync.search(p, collections: collections, limit: limit,
38
+ min_score: min_score, runner: runner, detector: detector)
39
+ if hits.any?
40
+ parts << "Related / prior Plastic intents (qmd BM25, includes completed) — " \
41
+ "check before treating this as new work:"
42
+ hits.each do |h|
43
+ loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
44
+ pct = (h[:score] * 100).round
45
+ parts << "- [#{pct}%] #{loc} — #{h[:title]}"
46
+ end
47
+ parts << ""
48
+ end
49
+ end
50
+
51
+ mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
52
+ serena_detector: -> { serena_present })
53
+ parts << mandate if mandate
54
+ return nil if parts.empty?
55
+ parts.join("\n")
56
+ end
57
+ end
@@ -0,0 +1,230 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "json"
6
+
7
+ # QmdSync — the single place Plastic talks to QMD (intent 45a).
8
+ #
9
+ # Plastic computes only the Plastic-specific inputs (which store directory maps to
10
+ # which `plastic-`prefixed collection, derived from projects.yml, plus a short
11
+ # context description). The actual indexing is delegated to the `qmd` CLI; this
12
+ # module never reimplements QMD's logic. QMD is optional: every public entry
13
+ # no-ops cleanly when `qmd` is not on PATH.
14
+ #
15
+ # Pure and dependency-injected: all shelling-out goes through an injected
16
+ # `runner` callable, so the whole module is unit-testable with no real binary,
17
+ # no network, and no model downloads. The default runner shells out to `qmd`.
18
+ module QmdSync
19
+ module_function
20
+
21
+ # A runner is `->(args_array) { [stdout_string, success_boolean] }`.
22
+ # The default invokes the real `qmd` binary.
23
+ def default_runner
24
+ lambda do |args|
25
+ require "open3"
26
+ out, _err, status = Open3.capture3("qmd", *args)
27
+ [out, status.success?]
28
+ end
29
+ end
30
+
31
+ # True when `qmd` is resolvable on PATH. The probe is injectable so tests do
32
+ # not depend on the host having qmd installed.
33
+ def detect(path_probe: method(:which_qmd))
34
+ !!path_probe.call
35
+ end
36
+
37
+ def which_qmd
38
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
39
+ candidate = File.join(dir, "qmd")
40
+ File.file?(candidate) && File.executable?(candidate)
41
+ end
42
+ end
43
+
44
+ # Collection name for a store directory.
45
+ # global store (<plastic_home>/store) -> "plastic-global"
46
+ # project store (<.../projects/<slug>/store) -> "plastic-<slug>"
47
+ # Slug is resolved from projects.yml by matching the project path; falls back
48
+ # to the directory's parent name when no registry match exists.
49
+ def collection_name(store_dir, plastic_home:)
50
+ store_dir = File.expand_path(store_dir)
51
+ global_store = File.expand_path(File.join(plastic_home, "store"))
52
+ return "plastic-global" if store_dir == global_store
53
+
54
+ slug = slug_for_store(store_dir, plastic_home: plastic_home)
55
+ "plastic-#{slug}"
56
+ end
57
+
58
+ # Every store Plastic knows about: the global store plus each registered
59
+ # project store. Returns [{collection:, dir:}, ...].
60
+ def enumerate_stores(plastic_home:)
61
+ stores = [{
62
+ collection: "plastic-global",
63
+ dir: File.expand_path(File.join(plastic_home, "store")),
64
+ }]
65
+
66
+ projects = load_projects(plastic_home)
67
+ projects.each do |slug, info|
68
+ path = info.is_a?(Hash) ? info["path"] : nil
69
+ next unless path
70
+ project_store = File.join(File.expand_path(path), "store")
71
+ # Project stores live under ~/.plastic/projects/<slug>/store as the mirror;
72
+ # registry `path` is the project code dir, so the tactical store is the
73
+ # plastic_home projects mirror.
74
+ mirror_store = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
75
+ dir = Dir.exist?(mirror_store) ? mirror_store : project_store
76
+ stores << { collection: "plastic-#{slug}", dir: dir }
77
+ end
78
+ stores
79
+ end
80
+
81
+ # Register a store directory as a collection. Idempotent: re-running is safe.
82
+ # `qmd collection add` exits non-zero when the collection already exists, so we
83
+ # check the collection list first and treat an existing collection as success.
84
+ # No-op when qmd is absent. (`qmd context add` attaches a summary to a document
85
+ # path, not a collection-level description, so it is not used here.)
86
+ def register(collection:, dir:, runner: default_runner, detector: method(:detect))
87
+ return skip_result unless detector.call
88
+ return { ran: true, ok: true, output: "exists" } if list_collections(runner).include?(collection)
89
+ out, ok = runner.call(["collection", "add", dir, "--name", collection])
90
+ { ran: true, ok: ok, output: out.to_s.strip }
91
+ end
92
+
93
+ # Re-index a single collection: refresh the corpus then its embeddings.
94
+ # Scoped embed (-c) keeps delivery-time reindex fast. No-op when qmd absent.
95
+ def reindex(collection:, runner: default_runner, detector: method(:detect))
96
+ return skip_result unless detector.call
97
+ _o1, ok1 = runner.call(["update"])
98
+ _o2, ok2 = runner.call(["embed", "-c", collection])
99
+ { ran: true, ok: (ok1 && ok2) }
100
+ end
101
+
102
+ # Non-blocking reindex for the completion path. QMD has no incremental reindex
103
+ # (`qmd update` is a full rescan, `qmd embed -c` re-embeds the whole collection,
104
+ # ~2 min), so running it inline would block the agent's turn; spawning detached
105
+ # returns immediately. No-op when qmd absent. Spawner/detector are injected so
106
+ # tests assert behavior with no real qmd and no real spawned process.
107
+ def reindex_async(collection:, detector: method(:detect), spawner: method(:default_async_spawner))
108
+ return skip_result unless detector.call
109
+ pid = spawner.call(collection)
110
+ { ran: true, async: true, pid: pid }
111
+ end
112
+
113
+ # Default spawner: launch `qmd update && qmd embed -c <collection>` detached and
114
+ # non-blocking, discarding output, then detach so it never blocks the turn.
115
+ def default_async_spawner(collection)
116
+ require "shellwords"
117
+ cmd = "qmd update && qmd embed -c #{Shellwords.escape(collection)}"
118
+ pid = Process.spawn(cmd, out: File::NULL, err: File::NULL, pgroup: true)
119
+ Process.detach(pid)
120
+ pid
121
+ end
122
+
123
+ # Read-only status used by doctor and the session-start report line.
124
+ # Returns a structured hash; never mutates the index.
125
+ def status(plastic_home:, runner: default_runner, detector: method(:detect))
126
+ return { present: false } unless detector.call
127
+
128
+ expected = enumerate_stores(plastic_home: plastic_home).map { |s| s[:collection] }
129
+ listed = list_collections(runner)
130
+ missing = expected - listed
131
+
132
+ { present: true, expected: expected, registered: listed,
133
+ missing: missing, all_registered: missing.empty? }
134
+ end
135
+
136
+ # Read-only BM25 search over one or more collections. Returns hits sorted by
137
+ # score (desc), filtered by min_score, capped at limit:
138
+ # [{ score: Float, file: String, line: Integer|nil, title: String, snippet: String }, ...]
139
+ # No-ops to [] when qmd is absent or the query is blank. Uses `qmd search --json`
140
+ # (BM25, no embeddings / no model downloads). Pure via the injected runner.
141
+ def search(query, collections:, limit: 3, min_score: 0.5, runner: default_runner, detector: method(:detect))
142
+ return [] unless detector.call
143
+ q = query.to_s.strip
144
+ return [] if q.empty? || Array(collections).empty?
145
+
146
+ args = ["search", q]
147
+ Array(collections).each { |c| args.concat(["-c", c]) }
148
+ args << "--json"
149
+
150
+ out, ok = runner.call(args)
151
+ return [] unless ok && out && !out.strip.empty?
152
+
153
+ parsed = begin
154
+ JSON.parse(out)
155
+ rescue StandardError
156
+ return []
157
+ end
158
+ return [] unless parsed.is_a?(Array)
159
+
160
+ parsed.filter_map do |h|
161
+ next unless h.is_a?(Hash)
162
+ score = h["score"].to_f
163
+ next if score < min_score
164
+ { score: score, file: h["file"].to_s, line: h["line"],
165
+ title: h["title"].to_s, snippet: h["snippet"].to_s }
166
+ end.sort_by { |h| -h[:score] }.first(limit)
167
+ end
168
+
169
+ # Which collections to search for a given working directory: the matched
170
+ # project's collection plus plastic-global, or just plastic-global when the
171
+ # CWD is not inside any registered project. Project match = CWD equals the
172
+ # registered path or is nested under it.
173
+ def collections_for_cwd(cwd, plastic_home:)
174
+ cwd = File.expand_path(cwd)
175
+ projects = load_projects(plastic_home)
176
+ slug, = projects.find do |_s, info|
177
+ path = info.is_a?(Hash) ? info["path"] : nil
178
+ next false unless path
179
+ root = File.expand_path(path)
180
+ cwd == root || cwd.start_with?(root + File::SEPARATOR)
181
+ end
182
+ slug ? ["plastic-#{slug}", "plastic-global"] : ["plastic-global"]
183
+ end
184
+
185
+ # --- internals ---
186
+
187
+ def skip_result
188
+ { ran: false, ok: true, skipped: true }
189
+ end
190
+
191
+ def list_collections(runner)
192
+ out, ok = runner.call(["collection", "list"])
193
+ return [] unless ok && out
194
+ # qmd prints lines like "plastic-global (qmd://plastic-global/)"; pull the
195
+ # leading collection token off each non-indented line.
196
+ out.lines.filter_map do |line|
197
+ next if line.start_with?(" ", "\t")
198
+ m = line.strip.match(/\A([A-Za-z0-9][\w.-]*)\b/)
199
+ m && m[1]
200
+ end.reject { |t| %w[Collections No].include?(t) }
201
+ end
202
+
203
+ def load_projects(plastic_home)
204
+ path = File.join(plastic_home, "projects.yml")
205
+ return {} unless File.exist?(path)
206
+ data = begin
207
+ YAML.safe_load(File.read(path)) || {}
208
+ rescue StandardError
209
+ {}
210
+ end
211
+ projects = data.is_a?(Hash) ? data["projects"] : nil
212
+ projects.is_a?(Hash) ? projects : {}
213
+ end
214
+
215
+ def slug_for_store(store_dir, plastic_home:)
216
+ projects = load_projects(plastic_home)
217
+ projects.each do |slug, info|
218
+ path = info.is_a?(Hash) ? info["path"] : nil
219
+ next unless path
220
+ project_root = File.expand_path(path)
221
+ mirror = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
222
+ return slug.to_s if store_dir == File.join(project_root, "store") || store_dir == mirror
223
+ end
224
+ # Fallback: <...>/projects/<slug>/store -> slug, else parent dir name.
225
+ parts = store_dir.split(File::SEPARATOR)
226
+ idx = parts.rindex("projects")
227
+ return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
228
+ File.basename(File.dirname(store_dir))
229
+ end
230
+ end
@@ -0,0 +1,100 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ # StoreProvisioning — the single source of truth for creating a registered
8
+ # project's intent store (intent 61).
9
+ #
10
+ # A project can be registered in `projects.yml` yet have no store on disk. This
11
+ # module deterministically and idempotently provisions that store at
12
+ # `~/.plastic/projects/{slug}/store`: it makes the store directory, then writes,
13
+ # only if missing, `.gitkeep`, `INDEX.md` (from templates/index.md), and
14
+ # `project.yml` (from templates/project.yml). Re-running never clobbers existing
15
+ # files. The logic was migrated here from the orphaned
16
+ # `InstallerCore#bootstrap_project_store` so there is one definition that the
17
+ # `provision-project-store` CLI, the `plastic-add-project-store` skill, the
18
+ # doctor fix hint, and the project skills all consult.
19
+ #
20
+ # Pure filesystem and dependency-injected: `provision` accepts an injectable
21
+ # `plastic_home` and `package_root` (so templates resolve from a known root and
22
+ # tests stay hermetic), uses no `eval`, performs no qmd mutation, no `system`
23
+ # or `spawn`, no network, and no global-constant injection. An unknown or
24
+ # unregistered slug creates nothing and returns an error result.
25
+ module StoreProvisioning
26
+ module_function
27
+
28
+ # Repo root resolved from this file's location (scripts/lib/), so templates
29
+ # resolve at package_root/templates/...; tests can inject a fake package_root.
30
+ PACKAGE_ROOT = File.expand_path("../..", __dir__)
31
+
32
+ # Provision the store for a registered project.
33
+ #
34
+ # Returns a result hash mirroring IntentValidator.validate's hash style:
35
+ # { ok: true, store_dir: "...", created: [paths written this run] }
36
+ # { ok: false, error: "..." } (nothing is created)
37
+ def provision(slug, plastic_home: File.join(Dir.home, ".plastic"),
38
+ package_root: PACKAGE_ROOT)
39
+ unless registered?(slug, plastic_home)
40
+ return {
41
+ ok: false,
42
+ error: "project '#{slug}' is not registered in projects.yml; " \
43
+ "provisioning requires the project to already be registered",
44
+ }
45
+ end
46
+
47
+ project_dir = File.join(plastic_home, "projects", slug)
48
+ store_dir = File.join(project_dir, "store")
49
+ FileUtils.mkdir_p(store_dir)
50
+
51
+ created = []
52
+ created << write_if_missing(File.join(store_dir, ".gitkeep"), "")
53
+
54
+ index_template = File.join(package_root, "templates", "index.md")
55
+ if File.exist?(index_template)
56
+ created << write_if_missing(File.join(project_dir, "INDEX.md"),
57
+ File.read(index_template))
58
+ end
59
+
60
+ project_template = File.join(package_root, "templates", "project.yml")
61
+ if File.exist?(project_template)
62
+ created << write_if_missing(File.join(project_dir, "project.yml"),
63
+ File.read(project_template))
64
+ end
65
+
66
+ { ok: true, store_dir: store_dir, created: created.compact }
67
+ end
68
+
69
+ # True iff `slug` is a key under the `projects` mapping in projects.yml.
70
+ # Reads with a rescue-to-safe-default reader so a malformed file never raises.
71
+ def registered?(slug, plastic_home)
72
+ projects = load_projects(plastic_home)
73
+ projects.is_a?(Hash) && projects.key?(slug)
74
+ end
75
+
76
+ # Parse projects.yml -> the `projects` Hash, or {} on any error/absence.
77
+ # Mirrors QmdSync#load_projects (scripts/lib/qmd_sync.rb).
78
+ def load_projects(plastic_home)
79
+ path = File.join(plastic_home, "projects.yml")
80
+ return {} unless File.exist?(path)
81
+
82
+ data = begin
83
+ YAML.safe_load(File.read(path)) || {}
84
+ rescue StandardError
85
+ {}
86
+ end
87
+ projects = data.is_a?(Hash) ? data["projects"] : nil
88
+ projects.is_a?(Hash) ? projects : {}
89
+ end
90
+
91
+ # Write `content` to `path` only when `path` does not exist. Returns the path
92
+ # when a file was written, or nil when the file already existed (so callers
93
+ # can build a `created` list). Copied from InstallerCore#write_if_missing.
94
+ def write_if_missing(path, content)
95
+ return nil if File.exist?(path)
96
+
97
+ File.write(path, content)
98
+ path
99
+ end
100
+ end
@@ -17,7 +17,7 @@ GLOBAL_ROOT="$HOME/.plastic"
17
17
  LOCAL_PLASTIC="$PROJECT_PATH/.plastic"
18
18
 
19
19
  if [ ! -d "$GLOBAL_ROOT" ]; then
20
- echo "Error: Global Plastic not installed. Run /plastic:install first." >&2
20
+ echo "Error: Global Plastic not installed. Run /plastic-install first." >&2
21
21
  exit 1
22
22
  fi
23
23