PM (PromptManager)
Installation
require 'pm'Configuration
Set global defaults with PM.configure:
PM.configure do |config|
config.prompts_dir = '~/.prompts' # default: ''
config.shell = true # default: true
config.erb = true # default: true
endprompts_dir is prepended to relative file paths passed to PM.parse. Absolute paths bypass it:
PM.configure { |c| c.prompts_dir = '/usr/share/prompts' }
PM.parse('code_review.md')
#=> reads /usr/share/prompts/code_review.md
PM.parse('/absolute/path/review.md')
#=> reads /absolute/path/review.md (prompts_dir ignored)shell and erb set the global defaults for new parses. Per-file YAML metadata overrides the global setting:
PM.configure { |c| c.shell = false }
# All files now default to shell: false
# A file with "shell: true" in its YAML still gets shell expansionReset all settings to defaults:
PM.config.reset!Access the current configuration:
PM.config.prompts_dir #=> ''
PM.config.shell #=> true
PM.config.erb #=> trueUsage
PM.parse accepts a file path, a Symbol, a single word, or a string:
# File path (String ending in .md or Pathname)
parsed = PM.parse('code_review.md')
parsed = PM.parse(Pathname.new('code_review.md'))
# Symbol or single word (treated as basename, .md appended)
parsed = PM.parse(:code_review) #=> parses code_review.md
parsed = PM.parse('code_review') #=> parses code_review.md
# String content
parsed = PM.parse("---\ntitle: Hello\n---\nContent here")When given a file path, parse adds directory, name, created_at, and modified_at to the metadata. Both forms run the full processing pipeline.
Given a file code_review.md:
---
title: Code Review
provider: openai
model: gpt-4
temperature: 0.3
parameters:
language: ruby
code: null
style_guide: ~/guides/default.md
---
Review the following <%= language %> code using the style guide
at <%= style_guide %>:
<%= code %>Parse it:
parsed = PM.parse('code_review.md')
parsed.metadata.parameters
#=> {'language' => 'ruby', 'code' => nil, 'style_guide' => '~/guides/default.md'}Build the prompt with to_s:
# Provide required params, accept defaults for the rest
parsed.to_s('code' => File.read('app.rb'))
# Override defaults
parsed.to_s('code' => File.read('app.py'), 'language' => 'python')
# All params have defaults — no arguments needed
parsed.to_s
# Missing a required parameter raises an error
parsed.to_s
#=> ArgumentError: Missing required parameters: codeParameters with a null default in the YAML are required. Parameters with any other default are optional.
Shell expansion
Shell references are expanded during parsing (when shell: true, the default):
---
title: Deploy Check
parameters:
environment: null
---
Current user: $USER
Home directory: ${HOME}
Date: $(date +%Y-%m-%d)
Git branch: $(git rev-parse --abbrev-ref HEAD)
Deploy to: <%= environment %>-
$ENVARand${ENVAR}are replaced with the environment variable's value. -
$(command)is executed and replaced with its stdout. - Missing environment variables are replaced with an empty string.
- Failed commands raise an error.
Shell expansion is also available directly:
PM.expand_shell(string)Including other prompt files
Use include in ERB to compose prompts from multiple files:
---
title: Full Review
parameters:
code: null
---
<%= include 'common/header.md' %>
Review this code:
<%= code %>
<%= include 'common/footer.md' %>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.
Nested includes work — A can include B which includes C. Circular includes raise an error.
Inserting raw file content
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:
---
title: Code Review
parameters:
feedback_style: null
---
Review this Ruby code:
```ruby
<%= insert 'app/models/user.rb' %>Use a <%= feedback_style %> tone.
Paths are resolved relative to the parent file's directory (same as `include`). Absolute paths work from any context. Missing files raise an error.
`insert` vs `include`:
| | `insert` | `include` |
|---|---|---|
| File types | Any | `.md` only |
| ERB in content | Preserved as literal text | Rendered |
| Shell expansion | Not applied | Applied |
| Recursion | None | Nested includes supported |
| Metadata tracking | None | `metadata.includes` tree |
After calling `to_s`, the parent's metadata has an `includes` key with a tree of what was included:
```ruby
parsed = PM.parse('full_review.md')
parsed.to_s('code' => File.read('app.rb'))
parsed.metadata.includes
#=> [
# {
# path: "/prompts/common/header.md",
# depth: 1,
# metadata: { title: "Header", ... },
# includes: []
# },
# {
# path: "/prompts/common/footer.md",
# depth: 1,
# metadata: { title: "Footer", ... },
# includes: []
# }
# ]
Custom directives
Block-based registration
Register custom methods available in ERB templates:
PM.register(:env) { |_ctx, key| ENV.fetch(key, '') }
PM.register(:run) { |_ctx, cmd| `#{cmd}`.chomp }Register multiple names for the same directive (aliases):
PM.register(:webpage, :website, :web) { |_ctx, url| fetch_page(url) }Class-based directives
For organized groups of directives, subclass PM::Directive:
class MyDirectives < PM::Directive
desc "Fetch environment variable"
def env(ctx, key)
ENV.fetch(key, '')
end
desc "Run a shell command"
def run(ctx, cmd)
`#{cmd}`.chomp
end
alias_method :exec, :run
end
# Register all directive subclasses with PM
PM::Directive.register_alldesc marks the next method as a directive. Methods without desc are helpers and won't be registered. alias_method aliases are detected automatically.
Override build_dispatch_block on your base class to customize how methods are called:
class MyDirectives < PM::Directive
class << self
def build_dispatch_block(inst, method_name)
proc { |_ctx, *args| inst.send(method_name, args) }
end
end
endRenderContext
The first argument to every directive block (or class method) is a PM::RenderContext:
-
ctx.directory— directory of the file being rendered -
ctx.params— merged parameter values -
ctx.metadata— the current file's metadata -
ctx.depth— include nesting depth -
ctx.included— Set of file paths already in the include chain
Duplicate detection and reset
Registering a name that already exists raises an error:
PM.register(:include) { |_ctx, path| path }
#=> RuntimeError: Directive already registered: includeReset to built-in directives only:
PM.reset_directives!
# Restores: include, insert, readDisabling processing stages
Set shell: false or erb: false in the metadata to skip those stages:
---
title: Raw Template
shell: false
erb: false
---
This $USER and <%= name %> content is preserved as-is.Both default to true when not specified. You can change these defaults globally via PM.configure (see Configuration). Per-file metadata always overrides the global setting.
HTML comment stripping
HTML comments are stripped before any other processing:
<!-- This comment will be removed -->
---
title: My Prompt
---
Content here. <!-- This too -->Comments are also available directly:
PM.strip_comments(string)Processing pipeline
- Strip HTML comments
- Extract YAML metadata and markdown content
- Shell expansion (
$ENVAR,$(command)) whenshell: true - ERB rendering on demand via
to_swhenerb: true
