prompt_manager 1.0.2 → 1.2.0

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -27
  3. data/README.md +127 -271
  4. data/lib/pm/configuration.rb +34 -4
  5. data/lib/pm/core_directives.rb +21 -20
  6. data/lib/pm/directive.rb +26 -34
  7. data/lib/pm/directives.rb +55 -16
  8. data/lib/pm/errors.rb +71 -0
  9. data/lib/pm/metadata.rb +110 -9
  10. data/lib/pm/parsed.rb +51 -46
  11. data/lib/pm/render_scope.rb +74 -0
  12. data/lib/pm/renderer.rb +59 -0
  13. data/lib/pm/shell.rb +95 -40
  14. data/lib/pm/version.rb +1 -1
  15. data/lib/pm.rb +116 -57
  16. data/lib/prompt_manager.rb +6 -0
  17. metadata +13 -113
  18. data/.envrc +0 -3
  19. data/.irbrc +0 -14
  20. data/COMMITS.md +0 -196
  21. data/Rakefile +0 -12
  22. data/docs/api/configuration.md +0 -59
  23. data/docs/api/constants.md +0 -60
  24. data/docs/api/index.md +0 -14
  25. data/docs/api/metadata.md +0 -99
  26. data/docs/api/parsed.md +0 -98
  27. data/docs/api/pm-module.md +0 -169
  28. data/docs/api/render-context.md +0 -51
  29. data/docs/architecture/design-decisions.md +0 -70
  30. data/docs/architecture/index.md +0 -6
  31. data/docs/architecture/processing-pipeline.md +0 -112
  32. data/docs/assets/css/custom.css +0 -1
  33. data/docs/assets/images/prompt_manager.gif +0 -0
  34. data/docs/assets/images/prompt_manager.mp4 +0 -0
  35. data/docs/examples/ai-agent-prompts.md +0 -173
  36. data/docs/examples/code-review-prompt.md +0 -107
  37. data/docs/examples/index.md +0 -7
  38. data/docs/examples/multi-file-composition.md +0 -123
  39. data/docs/getting-started/configuration.md +0 -106
  40. data/docs/getting-started/index.md +0 -7
  41. data/docs/getting-started/installation.md +0 -34
  42. data/docs/getting-started/quick-start.md +0 -94
  43. data/docs/guides/comment-stripping.md +0 -64
  44. data/docs/guides/custom-directives.md +0 -180
  45. data/docs/guides/erb-rendering.md +0 -102
  46. data/docs/guides/includes.md +0 -184
  47. data/docs/guides/index.md +0 -11
  48. data/docs/guides/parameters.md +0 -96
  49. data/docs/guides/parsing.md +0 -151
  50. data/docs/guides/shell-expansion.md +0 -108
  51. data/docs/index.md +0 -70
  52. data/mkdocs.yml +0 -181
data/README.md CHANGED
@@ -2,188 +2,128 @@
2
2
 
3
3
  <table>
4
4
  <tr>
5
- <td width="50%" align="center" valign="top">
6
- <img src="docs/assets/images/prompt_manager.gif" alt="PromptManager" width="70%"><br>
5
+ <td width="40%" align="center" valign="top">
6
+ <img src="docs/assets/images/prompt_manager.gif" alt="PromptManager" width="100%"><br>
7
7
  <em>"Prompts with superpowers"</em>
8
8
  </td>
9
- <td width="50%" valign="top">
10
- Like an enchanted librarian enhancing books of knowledge, PromptManager (PM) helps you masterfully orchestrate and organize your AI prompts through wisdom and experience. Each prompt becomes a living entity that can be categorized, parameterized, and interconnected with golden threads of relationships.
11
-
12
- <h3>Key Features</h3>
13
-
14
- - <strong>YAML Metadata</strong> - Parse from markdown strings or files<br>
15
- - <strong>Conditional Shell Expansion</strong> - $ENVAR, ${ENVAR}, $(command) substitution<br>
16
- - <strong>Conditional ERB Templates</strong> - On-demand rendering with named parameters<br>
17
- - <strong>Recursive Include System</strong> - Compose prompts from multiple files<br>
18
- - <strong>Raw File Insert</strong> - Insert any file's content verbatim<br>
19
- - <strong>Custom Directives</strong> - Register custom methods for ERB templates<br>
20
- - <strong>Configurable Pipeline</strong> - Enable/disable stages per prompt or globally<br>
21
- - <strong>Comment Stripping</strong> - HTML comments removed before processing
9
+ <td width="60%" valign="top">
10
+ <strong>Parse YAML front matter from markdown, expand shell references, compose prompts from other files, and render ERB on demand.</strong><br><br>
11
+ PM treats a prompt file as a parameterized template: YAML front matter for metadata and parameters, <code>$VAR</code> / <code>$(command)</code> for environment context, <code>&lt;%= ... %&gt;</code> for rendering, and <code>include</code> / <code>insert</code> for composition.
12
+
13
+ <h3>Key Features</h3>
14
+ <ul>
15
+ <li><strong>YAML Metadata</strong> - Parse from markdown strings or files</li>
16
+ <li><strong>Shell Expansion</strong> - <code>$VAR</code>, <code>${VAR}</code>, <code>$(command)</code>; <code>\$</code> escapes; code fences are skipped</li>
17
+ <li><strong>ERB Templates</strong> - On-demand rendering with required and default parameters</li>
18
+ <li><strong>Includes</strong> - Compose prompts from other files, with a tracked include tree</li>
19
+ <li><strong>Raw Inserts</strong> - Drop any file in verbatim</li>
20
+ <li><strong>Custom Directives</strong> - Blocks or <code>PM::Directive</code> subclasses, callable from ERB</li>
21
+ <li><strong>Configurable Pipeline</strong> - Enable, disable, or lock stages globally or per file</li>
22
+ <li><strong>Typed Errors</strong> - A <code>PM::Error</code> hierarchy for everything PM raises</li>
23
+ <li><strong>Zero runtime dependencies</strong> - Ruby 4.0.9+ and its standard library</li>
24
+ </ul>
22
25
  </td>
23
26
  </tr>
24
27
  </table>
25
28
 
26
- ## Installation
27
-
28
- ```ruby
29
- require 'pm'
30
- ```
31
-
32
- ## Configuration
29
+ Full documentation: <https://madbomber.github.io/prompt_manager>
33
30
 
34
- Set global defaults with `PM.configure`:
35
-
36
- ```ruby
37
- PM.configure do |config|
38
- config.prompts_dir = '~/.prompts' # default: ''
39
- config.shell = true # default: true
40
- config.erb = true # default: true
41
- end
42
- ```
43
-
44
- **`prompts_dir`** is prepended to relative file paths passed to `PM.parse`. Absolute paths bypass it:
45
-
46
- ```ruby
47
- PM.configure { |c| c.prompts_dir = '/usr/share/prompts' }
48
-
49
- PM.parse('code_review.md')
50
- #=> reads /usr/share/prompts/code_review.md
51
-
52
- PM.parse('/absolute/path/review.md')
53
- #=> reads /absolute/path/review.md (prompts_dir ignored)
54
- ```
55
-
56
- **`shell`** and **`erb`** set the global defaults for new parses. Per-file YAML metadata overrides the global setting:
31
+ ## Installation
57
32
 
58
33
  ```ruby
59
- PM.configure { |c| c.shell = false }
60
-
61
- # All files now default to shell: false
62
- # A file with "shell: true" in its YAML still gets shell expansion
34
+ # Gemfile
35
+ gem 'prompt_manager'
63
36
  ```
64
37
 
65
- Reset all settings to defaults:
66
-
67
- ```ruby
68
- PM.config.reset!
38
+ ```bash
39
+ gem install prompt_manager
69
40
  ```
70
41
 
71
- Access the current configuration:
72
-
73
42
  ```ruby
74
- PM.config.prompts_dir #=> ''
75
- PM.config.shell #=> true
76
- PM.config.erb #=> true
77
- ```
78
-
79
- ## Usage
80
-
81
- `PM.parse` accepts a file path, a Symbol, a single word, or a string:
82
-
83
- ```ruby
84
- # File path (String ending in .md or Pathname)
85
- parsed = PM.parse('code_review.md')
86
- parsed = PM.parse(Pathname.new('code_review.md'))
87
-
88
- # Symbol or single word (treated as basename, .md appended)
89
- parsed = PM.parse(:code_review) #=> parses code_review.md
90
- parsed = PM.parse('code_review') #=> parses code_review.md
91
-
92
- # String content
93
- parsed = PM.parse("---\ntitle: Hello\n---\nContent here")
43
+ require 'pm'
94
44
  ```
95
45
 
96
- When given a file path, `parse` adds `directory`, `name`, `created_at`, and `modified_at` to the metadata. Both forms run the full processing pipeline.
46
+ Requires Ruby 4.0.9 or later. There are no runtime dependencies.
97
47
 
98
- Note that `PM.parse` treats single words (e.g. `'hello'`) as prompt IDs and appends `.md` to look them up as files. To parse a single word as literal string content, use `PM.parse_string`:
48
+ ## Quick start
99
49
 
100
- ```ruby
101
- PM.parse('hello') #=> looks for hello.md in prompts_dir
102
- PM.parse_string('hello') #=> parses "hello" as string content
103
- PM.parse_string('Any string') #=> always parsed as content, never as a file
104
- ```
50
+ Given `code_review.md`:
105
51
 
106
- Given a file `code_review.md`:
107
-
108
- ```md
52
+ ```markdown
109
53
  ---
110
54
  title: Code Review
111
- provider: openai
112
55
  model: gpt-4
113
- temperature: 0.3
114
56
  parameters:
115
57
  language: ruby
116
58
  code: null
117
- style_guide: ~/guides/default.md
118
59
  ---
119
- Review the following <%= language %> code using the style guide
120
- at <%= style_guide %>:
60
+ Review the following <%= language %> code:
121
61
 
122
62
  <%= code %>
123
63
  ```
124
64
 
125
- Parse it:
126
-
127
65
  ```ruby
128
66
  parsed = PM.parse('code_review.md')
129
- parsed.metadata.parameters
130
- #=> {'language' => 'ruby', 'code' => nil, 'style_guide' => '~/guides/default.md'}
131
- ```
132
67
 
133
- Build the prompt with `to_s`:
68
+ parsed.metadata.title #=> "Code Review"
69
+ parsed.metadata.model #=> "gpt-4"
70
+ parsed.parameters #=> {"language" => "ruby", "code" => nil}
134
71
 
135
- ```ruby
136
- # Provide required params, accept defaults for the rest
137
72
  parsed.to_s('code' => File.read('app.rb'))
138
-
139
- # Override defaults
140
73
  parsed.to_s('code' => File.read('app.py'), 'language' => 'python')
141
74
 
142
- # All params have defaults — no arguments needed
143
- parsed.to_s
144
-
145
- # Missing a required parameter raises an error
146
75
  parsed.to_s
147
- #=> ArgumentError: Missing required parameters: code
76
+ #=> PM::MissingParameterError: Missing required parameters: code
148
77
  ```
149
78
 
150
- Parameters with a `null` default in the YAML are required. Parameters with any other default are optional.
79
+ A parameter whose default is `null` is required. Any other default is optional.
151
80
 
152
- ### Shell expansion
81
+ ## What `PM.parse` accepts
153
82
 
154
- Shell references are expanded during parsing (when `shell: true`, the default):
83
+ ```ruby
84
+ PM.parse('code_review.md') # String ending in .md -> file
85
+ PM.parse(Pathname.new('review.md')) # Pathname -> file
86
+ PM.parse(:code_review) # Symbol -> code_review.md
87
+ PM.parse('code_review') # single word -> code_review.md
88
+ PM.parse("---\ntitle: Hi\n---\nBody") # anything else -> string content
155
89
 
156
- ```md
157
- ---
158
- title: Deploy Check
159
- parameters:
160
- environment: null
161
- ---
162
- Current user: $USER
163
- Home directory: ${HOME}
164
- Date: $(date +%Y-%m-%d)
165
- Git branch: $(git rev-parse --abbrev-ref HEAD)
166
- Deploy to: <%= environment %>
90
+ PM.parse_string('hello') # always string content, never a file lookup
167
91
  ```
168
92
 
169
- - `$ENVAR` and `${ENVAR}` are replaced with the environment variable's value.
170
- - `$(command)` is executed and replaced with its stdout.
171
- - Missing environment variables are replaced with an empty string.
172
- - Failed commands raise an error.
93
+ Relative paths are resolved against `PM.config.prompts_dir`. File parses add
94
+ `directory`, `name`, `created_at`, and `modified_at` to the metadata.
173
95
 
174
- Shell expansion is also available directly:
96
+ ## Processing pipeline
175
97
 
176
- ```ruby
177
- PM.expand_shell(string)
98
+ 1. **Strip HTML comments** (`<!-- ... -->`)
99
+ 2. **Extract YAML front matter** into `PM::Metadata`
100
+ 3. **Shell expansion** of the content, when `shell: true` (the default)
101
+ 4. **ERB rendering** when `to_s` is called, when `erb: true` (the default)
102
+
103
+ Stages 3 and 4 can be turned off per file (`shell: false`, `erb: false`) or
104
+ globally (`PM.config.shell = false`). `PM.config.lock!` makes the global values
105
+ final so a file cannot turn a stage back on.
106
+
107
+ ## Shell expansion
108
+
109
+ ```markdown
110
+ Current user: $USER
111
+ Home: ${HOME}
112
+ Date: $(date +%Y-%m-%d)
113
+ Literal: \$HOME
178
114
  ```
179
115
 
180
- ### Including other prompt files
116
+ - `$VAR` and `${VAR}` (UPPERCASE names) are replaced with the environment value, or `""` when unset.
117
+ - `$(command)` runs via the shell and is replaced with its stdout (trailing newline removed).
118
+ - `\$` writes a literal `$`.
119
+ - Command output is spliced in verbatim: a `$VAR` or `<%= %>` that a command prints is **not** expanded or evaluated.
120
+ - Fenced code blocks (``` or ~~~) are copied through untouched.
121
+ - A failing command raises `PM::ShellCommandError` (with `#command`, `#status`, `#stderr`); an unmatched `$(` raises `PM::ShellExpansionError`.
181
122
 
182
- Use `include` in ERB to compose prompts from multiple files:
123
+ ## Composing prompts
183
124
 
184
- ```md
125
+ ```markdown
185
126
  ---
186
- title: Full Review
187
127
  parameters:
188
128
  code: null
189
129
  ---
@@ -192,182 +132,98 @@ parameters:
192
132
  Review this code:
193
133
  <%= code %>
194
134
 
195
- <%= include 'common/footer.md' %>
196
- ```
197
-
198
- Included files go through the full processing pipeline (comment stripping, metadata extraction, shell expansion, ERB rendering). The parent's parameter values are passed to included files.
199
-
200
- Nested includes work — A can include B which includes C. Circular includes raise an error.
201
-
202
- ### Inserting raw file content
203
-
204
- Use `insert` (or its alias `read`) to insert any file's content verbatim. Unlike `include`, the inserted content is not parsed, shell-expanded, or ERB-rendered — it appears as-is:
205
-
206
- ```md
207
- ---
208
- title: Code Review
209
- parameters:
210
- feedback_style: null
211
- ---
212
- Review this Ruby code:
213
-
214
- ```ruby
215
135
  <%= insert 'app/models/user.rb' %>
216
136
  ```
217
137
 
218
- Use a <%= feedback_style %> tone.
219
- ```
220
-
221
- Paths are resolved relative to the parent file's directory (same as `include`). Absolute paths work from any context. Missing files raise an error.
222
-
223
- `insert` vs `include`:
224
-
225
- | | `insert` | `include` |
138
+ | | `include` | `insert` (alias `read`) |
226
139
  |---|---|---|
227
- | File types | Any | `.md` only |
228
- | ERB in content | Preserved as literal text | Rendered |
229
- | Shell expansion | Not applied | Applied |
230
- | Recursion | None | Nested includes supported |
231
- | Metadata tracking | None | `metadata.includes` tree |
140
+ | Content is | parsed, shell-expanded, and ERB-rendered | copied verbatim |
141
+ | Parameters | parent values passed down | n/a |
142
+ | Nesting | yes, up to `PM.config.max_include_depth` (default 10) | none |
143
+ | Same file twice | allowed; only a true cycle raises `PM::CircularIncludeError` | allowed |
144
+ | Tracked in `metadata.includes` | yes | no |
232
145
 
233
- After calling `to_s`, the parent's metadata has an `includes` key with a tree of what was included:
146
+ Paths resolve relative to the including file's directory. `include` needs a file
147
+ context and raises `PM::IncludeContextError` from a string-parsed prompt.
234
148
 
235
- ```ruby
236
- parsed = PM.parse('full_review.md')
237
- parsed.to_s('code' => File.read('app.rb'))
238
-
239
- parsed.metadata.includes
240
- #=> [
241
- # {
242
- # path: "/prompts/common/header.md",
243
- # depth: 1,
244
- # metadata: { title: "Header", ... },
245
- # includes: []
246
- # },
247
- # {
248
- # path: "/prompts/common/footer.md",
249
- # depth: 1,
250
- # metadata: { title: "Footer", ... },
251
- # includes: []
252
- # }
253
- # ]
254
- ```
255
-
256
- ### Custom directives
257
-
258
- #### Block-based registration
259
-
260
- Register custom methods available in ERB templates:
149
+ ## Custom directives
261
150
 
262
151
  ```ruby
263
- PM.register(:env) { |_ctx, key| ENV.fetch(key, '') }
264
- PM.register(:run) { |_ctx, cmd| `#{cmd}`.chomp }
152
+ PM.register(:env) { |_ctx, key| ENV.fetch(key, '') }
153
+ PM.register(:webpage, :website, :web) { |_ctx, url| fetch_page(url) } # aliases
265
154
  ```
266
155
 
267
- Register multiple names for the same directive (aliases):
268
-
269
- ```ruby
270
- PM.register(:webpage, :website, :web) { |_ctx, url| fetch_page(url) }
271
- ```
272
-
273
- #### Class-based directives
274
-
275
- For organized groups of directives, subclass `PM::Directive`:
276
-
277
156
  ```ruby
278
157
  class MyDirectives < PM::Directive
279
- desc "Fetch environment variable"
280
- def env(ctx, key)
281
- ENV.fetch(key, '')
158
+ desc "Recent commits"
159
+ def commits(ctx, count = 5)
160
+ `git log --oneline -#{count}`.chomp
282
161
  end
283
-
284
- desc "Run a shell command"
285
- def run(ctx, cmd)
286
- `#{cmd}`.chomp
287
- end
288
- alias_method :exec, :run
162
+ alias_method :log, :commits
289
163
  end
290
164
 
291
- # Register all directive subclasses with PM
292
165
  PM::Directive.register_all
293
166
  ```
294
167
 
295
- `desc` marks the next method as a directive. Methods without `desc` are helpers and won't be registered. `alias_method` aliases are detected automatically.
168
+ Every directive receives a `PM::RenderContext` (`directory`, `params`, `included`,
169
+ `depth`, `metadata`) followed by the arguments from the ERB call. Registering a name
170
+ that is already taken raises `PM::DirectiveAlreadyRegisteredError`; `PM.register`
171
+ registers nothing if any requested name is taken. `PM.reset_directives!` restores
172
+ the built-ins (`include`, `insert`, `read`).
173
+
174
+ Inside a template a bare name resolves to a **parameter** when called without
175
+ arguments, then to a **directive**, then to `nil`. Set
176
+ `PM.config.strict_parameters = true` to make unknown names raise
177
+ `PM::UnknownParameterError` instead.
296
178
 
297
- Override `build_dispatch_block` on your base class to customize how methods are called:
179
+ ## Configuration
298
180
 
299
181
  ```ruby
300
- class MyDirectives < PM::Directive
301
- class << self
302
- def build_dispatch_block(inst, method_name)
303
- proc { |_ctx, *args| inst.send(method_name, args) }
304
- end
305
- end
182
+ PM.configure do |c|
183
+ c.prompts_dir = '~/.prompts' # default ''
184
+ c.shell = true # default true
185
+ c.erb = true # default true
186
+ c.max_include_depth = 10 # default 10; nil = unlimited
187
+ c.strict_parameters = false # default false
306
188
  end
307
- ```
308
-
309
- #### RenderContext
310
-
311
- The first argument to every directive block (or class method) is a `PM::RenderContext`:
312
-
313
- - `ctx.directory` — directory of the file being rendered
314
- - `ctx.params` — merged parameter values
315
- - `ctx.metadata` — the current file's metadata
316
- - `ctx.depth` — include nesting depth
317
- - `ctx.included` — Set of file paths already in the include chain
318
189
 
319
- #### Duplicate detection and reset
320
-
321
- Registering a name that already exists raises an error:
322
-
323
- ```ruby
324
- PM.register(:include) { |_ctx, path| path }
325
- #=> RuntimeError: Directive already registered: include
190
+ PM.config.lock! # per-file shell:/erb: can no longer override the globals
191
+ PM.config.reset! # back to defaults (also unlocks)
326
192
  ```
327
193
 
328
- Reset to built-in directives only:
194
+ ## Exporting
329
195
 
330
196
  ```ruby
331
- PM.reset_directives!
332
- # Restores: include, insert, read
197
+ parsed.to_h #=> { metadata: {...}, content: "..." }
198
+ parsed.to_markdown #=> front matter + content, re-parseable with PM.parse_string
333
199
  ```
334
200
 
335
- ### Disabling processing stages
201
+ ## Errors
336
202
 
337
- Set `shell: false` or `erb: false` in the metadata to skip those stages:
203
+ Everything PM raises itself descends from `PM::Error`, except parameter errors,
204
+ which descend from `ArgumentError` for backward compatibility. Standard-library
205
+ errors pass through: `Errno::ENOENT` for a missing file, `Psych::SyntaxError` for
206
+ bad YAML, `SyntaxError` for bad ERB. See the
207
+ [Errors reference](https://madbomber.github.io/prompt_manager/api/errors/).
338
208
 
339
- ```md
340
- ---
341
- title: Raw Template
342
- shell: false
343
- erb: false
344
- ---
345
- This $USER and <%= name %> content is preserved as-is.
346
- ```
209
+ ## Security model
347
210
 
348
- Both default to `true` when not specified. You can change these defaults globally via `PM.configure` (see [Configuration](#configuration)). Per-file metadata always overrides the global setting.
211
+ Prompt files are **code**: ERB can run any Ruby, `$(...)` runs shell commands, and
212
+ `include` / `insert` read any path the process can. Only parse files you trust.
213
+ Command output and inserted file contents are inert (never expanded or evaluated),
214
+ and `PM.config.lock!` keeps a file from re-enabling a stage you turned off. See the
215
+ [Security guide](https://madbomber.github.io/prompt_manager/guides/security/).
349
216
 
350
- ### HTML comment stripping
217
+ PM is not thread-safe for configuration or directive registration; configure and
218
+ register at boot, then parse and render from any thread.
351
219
 
352
- HTML comments are stripped before any other processing:
220
+ ## Development
353
221
 
354
- ```md
355
- <!-- This comment will be removed -->
356
- ---
357
- title: My Prompt
358
- ---
359
- Content here. <!-- This too -->
360
- ```
361
-
362
- Comments are also available directly:
363
-
364
- ```ruby
365
- PM.strip_comments(string)
222
+ ```bash
223
+ bundle install
224
+ bundle exec rake test # runs the suite with a 100% line-coverage gate
366
225
  ```
367
226
 
368
- ## Processing pipeline
227
+ ## License
369
228
 
370
- 1. Strip HTML comments
371
- 2. Extract YAML metadata and markdown content
372
- 3. Shell expansion (`$ENVAR`, `$(command)`) when `shell: true`
373
- 4. ERB rendering on demand via `to_s` when `erb: true`
229
+ MIT. See [LICENSE](LICENSE).
@@ -1,17 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PM
4
+ # Global settings. Reach it through PM.config or PM.configure.
5
+ #
6
+ # prompts_dir -- prepended to relative paths given to PM.parse ('' = none)
7
+ # shell -- default for shell expansion when a file does not say
8
+ # erb -- default for ERB rendering when a file does not say
9
+ # locked -- when true, per-file `shell:` / `erb:` cannot override the globals
10
+ # max_include_depth -- deepest allowed `include` nesting (nil = unlimited)
11
+ # strict_parameters -- when true, an unknown name in a template raises instead of rendering ""
4
12
  class Configuration
5
- attr_accessor :prompts_dir, :shell, :erb
13
+ attr_accessor :prompts_dir, :shell, :erb, :max_include_depth
14
+ attr_writer :locked, :strict_parameters
15
+
16
+ DEFAULT_MAX_INCLUDE_DEPTH = 10
6
17
 
7
18
  def initialize
8
19
  reset!
9
20
  end
10
21
 
11
22
  def reset!
12
- @prompts_dir = ''
13
- @shell = true
14
- @erb = true
23
+ @prompts_dir = ''
24
+ @shell = true
25
+ @erb = true
26
+ @locked = false
27
+ @max_include_depth = DEFAULT_MAX_INCLUDE_DEPTH
28
+ @strict_parameters = false
29
+ self
30
+ end
31
+
32
+ # Stop per-file front matter from overriding the global shell/erb settings.
33
+ def lock!
34
+ @locked = true
35
+ self
15
36
  end
37
+
38
+ def unlock!
39
+ @locked = false
40
+ self
41
+ end
42
+
43
+ def locked? = @locked
44
+
45
+ def strict_parameters? = @strict_parameters
16
46
  end
17
47
  end
@@ -1,31 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/pm/core_directives.rb
4
- #
5
- # PM's built-in directives: include, insert/read.
6
- # Defined as a PM::Directive subclass using the desc/alias DSL.
3
+ require 'pathname'
7
4
 
8
5
  module PM
6
+ # PM's built-in directives: include, insert (alias read).
9
7
  class CoreDirectives < Directive
10
8
  desc "Include and render another prompt file"
11
9
  def include(ctx, path)
12
10
  unless ctx.directory
13
- raise 'include requires a file context (use PM.parse with a file path)'
11
+ raise IncludeContextError, 'include requires a file context (use PM.parse with a file path)'
14
12
  end
15
13
 
16
14
  full_path = File.expand_path(path, ctx.directory)
15
+ check_include_allowed!(ctx, full_path)
17
16
 
18
- if ctx.included.include?(full_path)
19
- raise "Circular include detected: #{full_path}"
20
- end
21
-
22
- child = PM.parse(full_path)
17
+ child = PM.parse(Pathname.new(full_path)) # Pathname: always read as a file, whatever the extension
23
18
  result = child.render_with(ctx.params, ctx.included, ctx.depth + 1)
24
19
 
25
20
  ctx.metadata.includes << {
26
21
  path: full_path,
27
22
  depth: ctx.depth + 1,
28
- metadata: child.metadata.to_h.reject { |k, _| k == :includes },
23
+ metadata: child.metadata.to_h.reject { |key, _| key == :includes },
29
24
  includes: child.metadata.includes
30
25
  }
31
26
 
@@ -34,18 +29,24 @@ module PM
34
29
 
35
30
  desc "Insert a file's raw content verbatim (no ERB, no shell expansion)"
36
31
  def insert(ctx, path)
37
- full_path = if ctx&.directory
38
- File.expand_path(path, ctx.directory)
39
- else
40
- File.expand_path(path)
41
- end
42
-
43
- unless File.exist?(full_path)
44
- raise "insert: file not found: #{full_path}"
45
- end
32
+ full_path = File.expand_path(path, ctx.directory)
33
+ raise FileNotFoundError, "insert: file not found: #{full_path}" unless File.file?(full_path)
46
34
 
47
35
  File.read(full_path)
48
36
  end
49
37
  alias_method :read, :insert
38
+
39
+ private
40
+
41
+ def check_include_allowed!(ctx, full_path)
42
+ if ctx.included.include?(full_path)
43
+ raise CircularIncludeError, "Circular include detected: #{full_path}"
44
+ end
45
+
46
+ max = PM.config.max_include_depth
47
+ return unless max && ctx.depth + 1 > max
48
+
49
+ raise IncludeDepthError, "Include depth exceeds #{max} (PM.config.max_include_depth): #{full_path}"
50
+ end
50
51
  end
51
52
  end