prompt_manager 1.0.1 → 1.1.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 +130 -25
  3. data/README.md +127 -263
  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 +60 -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 -131
  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 -81
  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 -127
  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,180 +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
33
-
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:
29
+ Full documentation: <https://madbomber.github.io/prompt_manager>
45
30
 
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
43
+ require 'pm'
77
44
  ```
78
45
 
79
- ## Usage
46
+ Requires Ruby 4.0.9 or later. There are no runtime dependencies.
80
47
 
81
- `PM.parse` accepts a file path, a Symbol, a single word, or a string:
48
+ ## Quick start
82
49
 
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'))
50
+ Given `code_review.md`:
87
51
 
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")
94
- ```
95
-
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.
97
-
98
- Given a file `code_review.md`:
99
-
100
- ```md
52
+ ```markdown
101
53
  ---
102
54
  title: Code Review
103
- provider: openai
104
55
  model: gpt-4
105
- temperature: 0.3
106
56
  parameters:
107
57
  language: ruby
108
58
  code: null
109
- style_guide: ~/guides/default.md
110
59
  ---
111
- Review the following <%= language %> code using the style guide
112
- at <%= style_guide %>:
60
+ Review the following <%= language %> code:
113
61
 
114
62
  <%= code %>
115
63
  ```
116
64
 
117
- Parse it:
118
-
119
65
  ```ruby
120
66
  parsed = PM.parse('code_review.md')
121
- parsed.metadata.parameters
122
- #=> {'language' => 'ruby', 'code' => nil, 'style_guide' => '~/guides/default.md'}
123
- ```
124
67
 
125
- 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}
126
71
 
127
- ```ruby
128
- # Provide required params, accept defaults for the rest
129
72
  parsed.to_s('code' => File.read('app.rb'))
130
-
131
- # Override defaults
132
73
  parsed.to_s('code' => File.read('app.py'), 'language' => 'python')
133
74
 
134
- # All params have defaults — no arguments needed
135
- parsed.to_s
136
-
137
- # Missing a required parameter raises an error
138
75
  parsed.to_s
139
- #=> ArgumentError: Missing required parameters: code
76
+ #=> PM::MissingParameterError: Missing required parameters: code
140
77
  ```
141
78
 
142
- 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.
143
80
 
144
- ### Shell expansion
81
+ ## What `PM.parse` accepts
145
82
 
146
- 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
147
89
 
148
- ```md
149
- ---
150
- title: Deploy Check
151
- parameters:
152
- environment: null
153
- ---
154
- Current user: $USER
155
- Home directory: ${HOME}
156
- Date: $(date +%Y-%m-%d)
157
- Git branch: $(git rev-parse --abbrev-ref HEAD)
158
- Deploy to: <%= environment %>
90
+ PM.parse_string('hello') # always string content, never a file lookup
159
91
  ```
160
92
 
161
- - `$ENVAR` and `${ENVAR}` are replaced with the environment variable's value.
162
- - `$(command)` is executed and replaced with its stdout.
163
- - Missing environment variables are replaced with an empty string.
164
- - 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.
165
95
 
166
- Shell expansion is also available directly:
96
+ ## Processing pipeline
167
97
 
168
- ```ruby
169
- 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
170
114
  ```
171
115
 
172
- ### 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`.
173
122
 
174
- Use `include` in ERB to compose prompts from multiple files:
123
+ ## Composing prompts
175
124
 
176
- ```md
125
+ ```markdown
177
126
  ---
178
- title: Full Review
179
127
  parameters:
180
128
  code: null
181
129
  ---
@@ -184,182 +132,98 @@ parameters:
184
132
  Review this code:
185
133
  <%= code %>
186
134
 
187
- <%= include 'common/footer.md' %>
188
- ```
189
-
190
- 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.
191
-
192
- Nested includes work — A can include B which includes C. Circular includes raise an error.
193
-
194
- ### Inserting raw file content
195
-
196
- 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:
197
-
198
- ```md
199
- ---
200
- title: Code Review
201
- parameters:
202
- feedback_style: null
203
- ---
204
- Review this Ruby code:
205
-
206
- ```ruby
207
135
  <%= insert 'app/models/user.rb' %>
208
136
  ```
209
137
 
210
- Use a <%= feedback_style %> tone.
211
- ```
212
-
213
- Paths are resolved relative to the parent file's directory (same as `include`). Absolute paths work from any context. Missing files raise an error.
214
-
215
- `insert` vs `include`:
216
-
217
- | | `insert` | `include` |
138
+ | | `include` | `insert` (alias `read`) |
218
139
  |---|---|---|
219
- | File types | Any | `.md` only |
220
- | ERB in content | Preserved as literal text | Rendered |
221
- | Shell expansion | Not applied | Applied |
222
- | Recursion | None | Nested includes supported |
223
- | 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 |
224
145
 
225
- After calling `to_s`, the parent's metadata has an `includes` key with a tree of what was included:
226
-
227
- ```ruby
228
- parsed = PM.parse('full_review.md')
229
- parsed.to_s('code' => File.read('app.rb'))
230
-
231
- parsed.metadata.includes
232
- #=> [
233
- # {
234
- # path: "/prompts/common/header.md",
235
- # depth: 1,
236
- # metadata: { title: "Header", ... },
237
- # includes: []
238
- # },
239
- # {
240
- # path: "/prompts/common/footer.md",
241
- # depth: 1,
242
- # metadata: { title: "Footer", ... },
243
- # includes: []
244
- # }
245
- # ]
246
- ```
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.
247
148
 
248
- ### Custom directives
249
-
250
- #### Block-based registration
251
-
252
- Register custom methods available in ERB templates:
149
+ ## Custom directives
253
150
 
254
151
  ```ruby
255
- PM.register(:env) { |_ctx, key| ENV.fetch(key, '') }
256
- 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
257
154
  ```
258
155
 
259
- Register multiple names for the same directive (aliases):
260
-
261
- ```ruby
262
- PM.register(:webpage, :website, :web) { |_ctx, url| fetch_page(url) }
263
- ```
264
-
265
- #### Class-based directives
266
-
267
- For organized groups of directives, subclass `PM::Directive`:
268
-
269
156
  ```ruby
270
157
  class MyDirectives < PM::Directive
271
- desc "Fetch environment variable"
272
- def env(ctx, key)
273
- ENV.fetch(key, '')
274
- end
275
-
276
- desc "Run a shell command"
277
- def run(ctx, cmd)
278
- `#{cmd}`.chomp
158
+ desc "Recent commits"
159
+ def commits(ctx, count = 5)
160
+ `git log --oneline -#{count}`.chomp
279
161
  end
280
- alias_method :exec, :run
162
+ alias_method :log, :commits
281
163
  end
282
164
 
283
- # Register all directive subclasses with PM
284
165
  PM::Directive.register_all
285
166
  ```
286
167
 
287
- `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`).
288
173
 
289
- Override `build_dispatch_block` on your base class to customize how methods are called:
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.
178
+
179
+ ## Configuration
290
180
 
291
181
  ```ruby
292
- class MyDirectives < PM::Directive
293
- class << self
294
- def build_dispatch_block(inst, method_name)
295
- proc { |_ctx, *args| inst.send(method_name, args) }
296
- end
297
- 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
298
188
  end
299
- ```
300
-
301
- #### RenderContext
302
-
303
- The first argument to every directive block (or class method) is a `PM::RenderContext`:
304
-
305
- - `ctx.directory` — directory of the file being rendered
306
- - `ctx.params` — merged parameter values
307
- - `ctx.metadata` — the current file's metadata
308
- - `ctx.depth` — include nesting depth
309
- - `ctx.included` — Set of file paths already in the include chain
310
-
311
- #### Duplicate detection and reset
312
189
 
313
- Registering a name that already exists raises an error:
314
-
315
- ```ruby
316
- PM.register(:include) { |_ctx, path| path }
317
- #=> 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)
318
192
  ```
319
193
 
320
- Reset to built-in directives only:
194
+ ## Exporting
321
195
 
322
196
  ```ruby
323
- PM.reset_directives!
324
- # Restores: include, insert, read
197
+ parsed.to_h #=> { metadata: {...}, content: "..." }
198
+ parsed.to_markdown #=> front matter + content, re-parseable with PM.parse_string
325
199
  ```
326
200
 
327
- ### Disabling processing stages
328
-
329
- Set `shell: false` or `erb: false` in the metadata to skip those stages:
330
-
331
- ```md
332
- ---
333
- title: Raw Template
334
- shell: false
335
- erb: false
336
- ---
337
- This $USER and <%= name %> content is preserved as-is.
338
- ```
201
+ ## Errors
339
202
 
340
- 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.
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/).
341
208
 
342
- ### HTML comment stripping
209
+ ## Security model
343
210
 
344
- HTML comments are stripped before any other processing:
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/).
345
216
 
346
- ```md
347
- <!-- This comment will be removed -->
348
- ---
349
- title: My Prompt
350
- ---
351
- Content here. <!-- This too -->
352
- ```
217
+ PM is not thread-safe for configuration or directive registration; configure and
218
+ register at boot, then parse and render from any thread.
353
219
 
354
- Comments are also available directly:
220
+ ## Development
355
221
 
356
- ```ruby
357
- PM.strip_comments(string)
222
+ ```bash
223
+ bundle install
224
+ bundle exec rake test # runs the suite with a 100% line-coverage gate
358
225
  ```
359
226
 
360
- ## Processing pipeline
227
+ ## License
361
228
 
362
- 1. Strip HTML comments
363
- 2. Extract YAML metadata and markdown content
364
- 3. Shell expansion (`$ENVAR`, `$(command)`) when `shell: true`
365
- 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