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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -27
- data/README.md +127 -271
- data/lib/pm/configuration.rb +34 -4
- data/lib/pm/core_directives.rb +21 -20
- data/lib/pm/directive.rb +26 -34
- data/lib/pm/directives.rb +55 -16
- data/lib/pm/errors.rb +71 -0
- data/lib/pm/metadata.rb +110 -9
- data/lib/pm/parsed.rb +51 -46
- data/lib/pm/render_scope.rb +74 -0
- data/lib/pm/renderer.rb +59 -0
- data/lib/pm/shell.rb +95 -40
- data/lib/pm/version.rb +1 -1
- data/lib/pm.rb +116 -57
- data/lib/prompt_manager.rb +6 -0
- metadata +13 -113
- data/.envrc +0 -3
- data/.irbrc +0 -14
- data/COMMITS.md +0 -196
- data/Rakefile +0 -12
- data/docs/api/configuration.md +0 -59
- data/docs/api/constants.md +0 -60
- data/docs/api/index.md +0 -14
- data/docs/api/metadata.md +0 -99
- data/docs/api/parsed.md +0 -98
- data/docs/api/pm-module.md +0 -169
- data/docs/api/render-context.md +0 -51
- data/docs/architecture/design-decisions.md +0 -70
- data/docs/architecture/index.md +0 -6
- data/docs/architecture/processing-pipeline.md +0 -112
- data/docs/assets/css/custom.css +0 -1
- data/docs/assets/images/prompt_manager.gif +0 -0
- data/docs/assets/images/prompt_manager.mp4 +0 -0
- data/docs/examples/ai-agent-prompts.md +0 -173
- data/docs/examples/code-review-prompt.md +0 -107
- data/docs/examples/index.md +0 -7
- data/docs/examples/multi-file-composition.md +0 -123
- data/docs/getting-started/configuration.md +0 -106
- data/docs/getting-started/index.md +0 -7
- data/docs/getting-started/installation.md +0 -34
- data/docs/getting-started/quick-start.md +0 -94
- data/docs/guides/comment-stripping.md +0 -64
- data/docs/guides/custom-directives.md +0 -180
- data/docs/guides/erb-rendering.md +0 -102
- data/docs/guides/includes.md +0 -184
- data/docs/guides/index.md +0 -11
- data/docs/guides/parameters.md +0 -96
- data/docs/guides/parsing.md +0 -151
- data/docs/guides/shell-expansion.md +0 -108
- data/docs/index.md +0 -70
- data/mkdocs.yml +0 -181
data/README.md
CHANGED
|
@@ -2,188 +2,128 @@
|
|
|
2
2
|
|
|
3
3
|
<table>
|
|
4
4
|
<tr>
|
|
5
|
-
<td width="
|
|
6
|
-
<img src="docs/assets/images/prompt_manager.gif" alt="PromptManager" width="
|
|
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="
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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><%= ... %></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
|
-
|
|
27
|
-
|
|
28
|
-
```ruby
|
|
29
|
-
require 'pm'
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Configuration
|
|
29
|
+
Full documentation: <https://madbomber.github.io/prompt_manager>
|
|
33
30
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
Requires Ruby 4.0.9 or later. There are no runtime dependencies.
|
|
97
47
|
|
|
98
|
-
|
|
48
|
+
## Quick start
|
|
99
49
|
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#=>
|
|
76
|
+
#=> PM::MissingParameterError: Missing required parameters: code
|
|
148
77
|
```
|
|
149
78
|
|
|
150
|
-
|
|
79
|
+
A parameter whose default is `null` is required. Any other default is optional.
|
|
151
80
|
|
|
152
|
-
|
|
81
|
+
## What `PM.parse` accepts
|
|
153
82
|
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
96
|
+
## Processing pipeline
|
|
175
97
|
|
|
176
|
-
|
|
177
|
-
PM
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
## Composing prompts
|
|
183
124
|
|
|
184
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
|
228
|
-
|
|
|
229
|
-
|
|
|
230
|
-
|
|
|
231
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
264
|
-
PM.register(:
|
|
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 "
|
|
280
|
-
def
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
## Configuration
|
|
298
180
|
|
|
299
181
|
```ruby
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
+
## Exporting
|
|
329
195
|
|
|
330
196
|
```ruby
|
|
331
|
-
|
|
332
|
-
|
|
197
|
+
parsed.to_h #=> { metadata: {...}, content: "..." }
|
|
198
|
+
parsed.to_markdown #=> front matter + content, re-parseable with PM.parse_string
|
|
333
199
|
```
|
|
334
200
|
|
|
335
|
-
|
|
201
|
+
## Errors
|
|
336
202
|
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
## Development
|
|
353
221
|
|
|
354
|
-
```
|
|
355
|
-
|
|
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
|
-
##
|
|
227
|
+
## License
|
|
369
228
|
|
|
370
|
-
|
|
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).
|
data/lib/pm/configuration.rb
CHANGED
|
@@ -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
|
|
14
|
-
@erb
|
|
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
|
data/lib/pm/core_directives.rb
CHANGED
|
@@ -1,31 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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 { |
|
|
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 =
|
|
38
|
-
|
|
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
|