@safetnsr/md-pipe 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # md-pipe
2
2
 
3
- Event-driven automation for markdown directories.
4
- `entr` watches files. `md-pipe` understands them.
3
+ Markdown content pipelines.
4
+ Write markdown auto-publish everywhere.
5
5
 
6
6
  12 lines of YAML → your markdown folder auto-publishes, auto-indexes, and auto-archives based on frontmatter changes. Works with any `.md` directory — Obsidian vaults, agent workspaces, docs-as-code repos, digital gardens.
7
7
 
@@ -23,17 +23,97 @@ npm install -g @safetnsr/md-pipe
23
23
  # 1. Create config
24
24
  md-pipe init
25
25
 
26
- # 2. Edit .md-pipe.yml with your triggers
26
+ # 2. Edit .md-pipe.yml with your pipelines
27
27
 
28
28
  # 3. Start watching
29
29
  md-pipe watch
30
30
  ```
31
31
 
32
- ## Config (.md-pipe.yml)
32
+ ## Pipelines (v0.3+)
33
+
34
+ Pipelines are multi-step content workflows that fire on frontmatter changes:
35
+
36
+ ```yaml
37
+ watch: ./docs
38
+
39
+ pipelines:
40
+ - name: publish-post
41
+ trigger:
42
+ path: "posts/**"
43
+ frontmatter: { status: publish }
44
+ frontmatter_changed: [status]
45
+ steps:
46
+ - run: "echo Publishing {{fm.title}}"
47
+ - copy: { to: "./_site/posts" }
48
+ - update-frontmatter:
49
+ published_at: "{{now}}"
50
+ published: "true"
51
+ - webhook:
52
+ url: "$WEBHOOK_URL"
53
+ body: { title: "{{fm.title}}", slug: "{{slug}}" }
54
+ ```
55
+
56
+ Steps execute in order. If one fails, the pipeline stops (unless `continue_on_error: true`).
57
+
58
+ ### Step Types
59
+
60
+ | Step | Description |
61
+ |---|---|
62
+ | `run` | Shell command. Supports template vars and `$ENV` vars |
63
+ | `update-frontmatter` | Write fields back to the source file's YAML frontmatter |
64
+ | `copy` | Copy file to a destination directory |
65
+ | `webhook` | POST JSON to a URL |
66
+ | `template` | Render a template file with context data |
67
+
68
+ ### Template Variables
69
+
70
+ Available in all step configs:
71
+
72
+ | Variable | Description |
73
+ |---|---|
74
+ | `{{now}}` | ISO timestamp |
75
+ | `{{date}}` | YYYY-MM-DD |
76
+ | `{{slug}}` | Filename without extension |
77
+ | `{{file}}` | Absolute path |
78
+ | `{{basename}}` | Filename |
79
+ | `{{relative}}` | Path relative to watch dir |
80
+ | `{{dir}}` | Parent directory |
81
+ | `{{tags}}` | Comma-separated tags |
82
+ | `{{fm.title}}` | Frontmatter field |
83
+ | `{{step.0.stdout}}` | Output from step 0 |
84
+
85
+ ### Pipeline Context
86
+
87
+ Each step's output is available to subsequent steps:
88
+
89
+ ```yaml
90
+ steps:
91
+ - run: "deploy.sh $FILE" # stdout captured
92
+ - update-frontmatter:
93
+ url: "{{step.0.stdout}}" # use output from step 0
94
+ deployed_at: "{{now}}"
95
+ ```
96
+
97
+ ### Manual Run
98
+
99
+ Test a pipeline without watching:
100
+
101
+ ```bash
102
+ md-pipe run publish-post posts/hello.md
103
+ ```
104
+
105
+ Run against all matching files:
106
+
107
+ ```bash
108
+ md-pipe run publish-post
109
+ ```
110
+
111
+ ## Legacy Triggers
112
+
113
+ Simple triggers with a single `run` command still work (backward compatible):
33
114
 
34
115
  ```yaml
35
116
  watch: ./docs
36
- debounce: 500 # ms, for watch mode (default: 200)
37
117
 
38
118
  triggers:
39
119
  - name: publish
@@ -41,24 +121,11 @@ triggers:
41
121
  path: "posts/**"
42
122
  frontmatter:
43
123
  status: publish
44
- cwd: project # run from config dir instead of file dir
45
- run: "./scripts/deploy.sh $FILE"
46
-
47
- - name: reindex
48
- match:
49
- frontmatter_changed:
50
- - title
51
- - tags
52
- - category
53
- run: "./scripts/reindex.sh $FILE"
54
-
55
- - name: urgent-notify
56
- match:
57
- tags:
58
- - urgent
59
- run: "curl -X POST $WEBHOOK -d '{\"file\": \"$FILE\"}'"
124
+ run: "echo Publishing $FILE"
60
125
  ```
61
126
 
127
+ Triggers and pipelines can coexist in the same config.
128
+
62
129
  ## Commands
63
130
 
64
131
  | Command | Description |
@@ -66,7 +133,8 @@ triggers:
66
133
  | `md-pipe init` | Scaffold a `.md-pipe.yml` config file |
67
134
  | `md-pipe watch` | Start watching for changes and trigger actions |
68
135
  | `md-pipe once` | Run triggers against current files (CI/batch mode) |
69
- | `md-pipe test <file>` | Show which triggers match a specific file |
136
+ | `md-pipe run <pipeline> [file]` | Manually trigger a pipeline |
137
+ | `md-pipe test <file>` | Show which triggers/pipelines match a file |
70
138
 
71
139
  ## Flags
72
140
 
@@ -78,184 +146,141 @@ triggers:
78
146
  | `--verbose` | Show trigger + file + first line of command |
79
147
  | `--debug` | Show full interpolated commands |
80
148
  | `--state <path>` | State file for idempotent `once` mode |
81
- | `--version, -v` | Show version |
82
- | `--help, -h` | Show help |
83
149
 
84
150
  ## Trigger Matchers
85
151
 
152
+ All matchers work in both `triggers` (via `match:`) and `pipelines` (via `trigger:`).
153
+
86
154
  ### `path` — glob pattern matching
87
155
  ```yaml
88
- match:
89
- path: "posts/**/*.md"
156
+ path: "posts/**/*.md"
90
157
  ```
91
158
 
92
- ### `frontmatter` — match specific frontmatter values
159
+ ### `frontmatter` — match specific values
93
160
  ```yaml
94
- match:
95
- frontmatter:
96
- status: publish
97
- type: blog
161
+ frontmatter:
162
+ status: publish
163
+ type: blog
98
164
  ```
99
165
 
100
- ### Negation — match anything except a value
166
+ ### Negation
101
167
  ```yaml
102
- match:
103
- frontmatter:
104
- status: "!draft" # matches publish, review, etc — NOT draft
168
+ frontmatter:
169
+ status: "!draft" # matches anything except draft
105
170
  ```
106
171
 
107
172
  ### `frontmatter_changed` — fires when specific fields change
108
173
  ```yaml
109
- match:
110
- frontmatter_changed:
111
- - title
112
- - tags
174
+ frontmatter_changed:
175
+ - title
176
+ - tags
113
177
  ```
114
- This is the key differentiator over generic file watchers. `md-pipe` tracks frontmatter state and only triggers when the fields you care about actually change.
178
+ This is the key differentiator. md-pipe tracks frontmatter state and only triggers when the fields you care about actually change.
115
179
 
116
180
  ### `tags` — match files with specific tags
117
181
  ```yaml
118
- match:
119
- tags:
120
- - urgent
121
- - review
182
+ tags:
183
+ - urgent
122
184
  ```
123
185
 
124
- ### `content` — match body text (substring)
186
+ ### `content` / `content_regex` — match body text
125
187
  ```yaml
126
- match:
127
- content: "TODO" # fires on files containing "TODO" in the body
188
+ content: "TODO"
189
+ content_regex: "\\[ \\]" # unchecked checkboxes
128
190
  ```
129
191
 
130
- ### `content_regex`match body text (regex)
131
- ```yaml
132
- match:
133
- content_regex: "\\[ \\]" # unchecked checkboxes
134
- ```
192
+ Matchers can be combined all conditions must match.
135
193
 
136
- Matchers can be combined — all conditions must match for the trigger to fire.
194
+ ## Step Details
137
195
 
138
- ## Trigger Options
196
+ ### `update-frontmatter`
197
+
198
+ Write fields back to the source markdown file:
139
199
 
140
- ### `cwd` — working directory for commands
141
200
  ```yaml
142
- triggers:
143
- - name: build
144
- match:
145
- path: "**"
146
- cwd: project # "project" = config dir, "file" = file dir (default)
147
- run: bash scripts/build.sh $FILE
201
+ - update-frontmatter:
202
+ published_at: "{{now}}"
203
+ published: "true" # coerced to boolean
204
+ url: "{{step.0.stdout}}" # from previous step
148
205
  ```
149
206
 
150
- ## Environment Variables
151
-
152
- Scripts receive these env vars:
153
-
154
- | Variable | Description |
155
- |---|---|
156
- | `$FILE` | Absolute path to the changed file |
157
- | `$DIR` | Directory containing the file |
158
- | `$BASENAME` | Filename without directory |
159
- | `$RELATIVE` | Path relative to watch directory |
160
- | `$FRONTMATTER` | JSON string of all frontmatter |
161
- | `$DIFF` | JSON string of changed frontmatter fields |
162
- | `$TAGS` | Comma-separated list of tags |
163
- | `$FM_<field>` | Direct access to frontmatter fields |
164
-
165
- ### `$FM_` variables
207
+ Values are coerced: `"true"` → `true`, `"false"` → `false`, numeric strings → numbers.
166
208
 
167
- Every frontmatter field is exposed as `$FM_<fieldname>`:
209
+ ### `copy`
168
210
 
169
211
  ```yaml
170
- ---
171
- title: "Hello World"
172
- status: publish
173
- version: "2.1"
174
- ---
212
+ - copy: { to: "./_site/posts" } # preserves directory structure
213
+ - copy: { to: "./_site", flatten: true } # flat copy
175
214
  ```
176
215
 
177
- ```bash
178
- echo $FM_title # → Hello World
179
- echo $FM_status # → publish
180
- echo $FM_version # → 2.1
181
- ```
182
-
183
- No more `node -pe "JSON.parse(...)"` to extract a single field!
184
-
185
- ## Idempotent Mode (--state)
216
+ ### `webhook`
186
217
 
187
- For CI/cron use, `--state` tracks which files have been processed:
218
+ ```yaml
219
+ - webhook:
220
+ url: "$WEBHOOK_URL" # env var expansion
221
+ method: POST # default
222
+ headers:
223
+ Authorization: "Bearer $API_KEY"
224
+ body:
225
+ title: "{{fm.title}}"
226
+ file: "{{relative}}"
227
+ ```
188
228
 
189
- ```bash
190
- # First run: processes all matching files
191
- md-pipe once --state .md-pipe-state.json
229
+ ### `template`
192
230
 
193
- # Second run: skips unchanged files
194
- md-pipe once --state .md-pipe-state.json
231
+ ```yaml
232
+ - template:
233
+ src: "./templates/post.html"
234
+ out: "./_site/{{slug}}.html"
195
235
  ```
196
236
 
197
- The state file tracks content hashes per trigger+file combo. Only fires when actual content changes.
198
-
199
- ## Watch Mode Debouncing
237
+ Template files use the same `{{variable}}` syntax.
200
238
 
201
- File saves can trigger multiple events. Configure debounce in your config:
239
+ ### Error Handling
202
240
 
203
241
  ```yaml
204
- debounce: 500 # milliseconds (default: 200)
205
- # or
206
- debounce: "500ms"
207
- debounce: "2s"
242
+ pipelines:
243
+ - name: resilient
244
+ continue_on_error: true # pipeline-level
245
+ steps:
246
+ - run: "might-fail.sh"
247
+ continue_on_error: true # step-level
248
+ - run: "always-runs.sh"
208
249
  ```
209
250
 
210
- ## Agent Interface (--json)
251
+ ## Environment Variables
211
252
 
212
- ```bash
213
- md-pipe once --json
214
- ```
253
+ Shell commands (`run` steps and legacy triggers) receive:
215
254
 
216
- ```json
217
- {
218
- "total": 42,
219
- "matched": 3,
220
- "skipped": 0,
221
- "actions": [
222
- {
223
- "triggerName": "publish",
224
- "filePath": "/docs/posts/hello.md",
225
- "command": "./scripts/deploy.sh /docs/posts/hello.md",
226
- "exitCode": 0,
227
- "stdout": "deployed",
228
- "stderr": "",
229
- "durationMs": 150
230
- }
231
- ],
232
- "errors": []
233
- }
234
- ```
255
+ | Variable | Description |
256
+ |---|---|
257
+ | `$FILE` | Absolute path |
258
+ | `$DIR` | Directory |
259
+ | `$BASENAME` | Filename |
260
+ | `$RELATIVE` | Relative path |
261
+ | `$SLUG` | Filename without extension |
262
+ | `$FRONTMATTER` | JSON string of all frontmatter |
263
+ | `$DIFF` | JSON of changed fields |
264
+ | `$TAGS` | Comma-separated tags |
265
+ | `$FM_<field>` | Direct frontmatter access |
266
+ | `$STEP_OUTPUT` | stdout from the previous step |
267
+ | `$STEP_N_STDOUT` | stdout from step N |
268
+
269
+ ## Idempotent Mode (--state)
235
270
 
236
271
  ```bash
237
- md-pipe watch --json
238
- # streams one JSON object per triggered action
272
+ md-pipe once --state .md-pipe-state.json
273
+ # Only processes files that changed since last run
239
274
  ```
240
275
 
241
276
  ## Use Cases
242
277
 
243
- **Docs-as-code auto-deploy** — publish pages when `status: publish` is set in frontmatter
244
-
245
- **Agent workspace automation** — trigger reindexing when an agent updates `AGENTS.md` or `NOW.md`
246
-
247
- **Obsidian vault pipeline** — auto-archive notes, generate backlinks, push to git on tag changes
248
-
249
- **Digital garden** — auto-generate RSS feed when new posts are added
250
-
251
- **CI/CD for markdown** — `md-pipe once --state .state.json` in your CI pipeline for idempotent processing
252
-
253
- **TODO tracking** — find files with `content: "TODO"` or `content_regex: "\\[ \\]"` for unchecked tasks
254
-
255
- ## Pair With
256
-
257
- - [`@safetnsr/md-kit`](https://github.com/safetnsr/md-kit) — markdown workspace toolkit (wikilinks, broken links, backlink maps)
258
- - [`@safetnsr/ai-ready`](https://github.com/safetnsr/ai-ready) — AI-compatibility score for your codebase
278
+ - **Content pipeline** write markdown, auto-publish to static site + CMS
279
+ - **Docs-as-code** — deploy pages when `status: publish` is set
280
+ - **Agent workspace** — trigger reindexing when agents update files
281
+ - **Obsidian vault** — auto-archive, generate backlinks, push to git
282
+ - **Digital garden** — auto-generate RSS, index pages, deploy
283
+ - **CI/CD** — `md-pipe once --state .state.json` for idempotent processing
259
284
 
260
285
  ## License
261
286
 
package/dist/cli.js CHANGED
@@ -11,17 +11,19 @@ const config_js_1 = require("./core/config.js");
11
11
  const watcher_js_1 = require("./core/watcher.js");
12
12
  const once_js_1 = require("./core/once.js");
13
13
  const test_file_js_1 = require("./core/test-file.js");
14
- const VERSION = '0.2.0';
14
+ const run_command_js_1 = require("./core/run-command.js");
15
+ const VERSION = '0.3.0';
15
16
  function printHelp() {
16
17
  console.log(`
17
- ${chalk_1.default.bold('md-pipe')} — event-driven automation for markdown directories
18
- ${chalk_1.default.dim('entr watches files. md-pipe understands them.')}
18
+ ${chalk_1.default.bold('md-pipe')} — markdown content pipelines
19
+ ${chalk_1.default.dim('Write markdown auto-publish everywhere.')}
19
20
 
20
21
  ${chalk_1.default.bold('Usage:')}
21
- md-pipe init Scaffold a .md-pipe.yml config file
22
- md-pipe watch Start watching for changes and trigger actions
23
- md-pipe once Run triggers against current files (CI/batch mode)
24
- md-pipe test <file> Show which triggers match a specific file
22
+ md-pipe init Scaffold a .md-pipe.yml config file
23
+ md-pipe watch Start watching for changes
24
+ md-pipe once Run triggers against current files (CI/batch)
25
+ md-pipe run <pipeline> [file] Manually trigger a pipeline on a file
26
+ md-pipe test <file> Show which triggers/pipelines match a file
25
27
 
26
28
  ${chalk_1.default.bold('Flags:')}
27
29
  --config, -c <path> Path to config file (default: .md-pipe.yml)
@@ -35,26 +37,41 @@ ${chalk_1.default.bold('Flags:')}
35
37
 
36
38
  ${chalk_1.default.bold('Config (.md-pipe.yml):')}
37
39
  watch: ./docs
38
- debounce: 500 # ms, for watch mode (default: 200)
40
+
41
+ # Legacy triggers (simple match + run)
39
42
  triggers:
40
43
  - name: publish
41
44
  match:
42
45
  path: "posts/**"
43
46
  frontmatter: { status: publish }
44
- content: "TODO" # body substring
45
- content_regex: "\\\\[\\\\s\\\\]" # body regex
46
- cwd: project # "project" or "file" (default)
47
47
  run: "echo Publishing $FILE"
48
48
 
49
- ${chalk_1.default.bold('Env vars passed to scripts:')}
50
- $FILE Absolute path to the changed file
51
- $DIR Directory containing the file
52
- $BASENAME Filename without directory
53
- $RELATIVE Path relative to watch directory
54
- $FRONTMATTER JSON string of all frontmatter
55
- $DIFF JSON string of changed frontmatter fields
56
- $TAGS Comma-separated list of tags
57
- $FM_<field> Direct access to frontmatter (e.g. $FM_title, $FM_status)
49
+ # Pipelines (v0.3+) multi-step content pipelines
50
+ pipelines:
51
+ - name: publish-post
52
+ trigger:
53
+ path: "posts/**"
54
+ frontmatter: { status: publish }
55
+ frontmatter_changed: [status]
56
+ steps:
57
+ - run: "echo Publishing {{fm.title}}"
58
+ - update-frontmatter: { published_at: "{{now}}" }
59
+ - copy: { to: "./_site/posts" }
60
+ - webhook: { url: "$WEBHOOK_URL" }
61
+
62
+ ${chalk_1.default.bold('Step types:')}
63
+ run Shell command
64
+ update-frontmatter Write back to source file frontmatter
65
+ webhook POST JSON to a URL
66
+ copy Copy file to destination directory
67
+ template Render a template with file data
68
+
69
+ ${chalk_1.default.bold('Template variables:')}
70
+ {{now}} ISO timestamp
71
+ {{date}} YYYY-MM-DD
72
+ {{slug}} Filename without extension
73
+ {{fm.title}} Frontmatter field
74
+ {{step.0.stdout}} Output from step 0
58
75
 
59
76
  ${chalk_1.default.bold('More:')} https://github.com/safetnsr/md-pipe
60
77
  `);
@@ -62,12 +79,14 @@ ${chalk_1.default.bold('More:')} https://github.com/safetnsr/md-pipe
62
79
  function parseArgs(args) {
63
80
  let command = '';
64
81
  let file;
82
+ let pipelineName;
65
83
  let config;
66
84
  let dryRun = false;
67
85
  let json = false;
68
86
  let verbose = false;
69
87
  let debug = false;
70
88
  let state;
89
+ let positionals = [];
71
90
  for (let i = 0; i < args.length; i++) {
72
91
  const arg = args[i];
73
92
  if (arg === '--help' || arg === '-h') {
@@ -106,12 +125,17 @@ function parseArgs(args) {
106
125
  command = arg;
107
126
  continue;
108
127
  }
109
- if (command === 'test' && !file) {
110
- file = arg;
111
- continue;
112
- }
128
+ positionals.push(arg);
129
+ }
130
+ // Parse positionals based on command
131
+ if (command === 'run') {
132
+ pipelineName = positionals[0];
133
+ file = positionals[1];
113
134
  }
114
- return { command: command || 'help', file, config, dryRun, json, verbose, debug, state };
135
+ else if (command === 'test') {
136
+ file = positionals[0];
137
+ }
138
+ return { command: command || 'help', file, pipelineName, config, dryRun, json, verbose, debug, state };
115
139
  }
116
140
  function getConfig(configPath) {
117
141
  const cwd = process.cwd();
@@ -122,7 +146,6 @@ function getConfig(configPath) {
122
146
  }
123
147
  return (0, config_js_1.loadConfig)(cfgPath);
124
148
  }
125
- /** Format command for display: verbose shows first line, debug shows full */
126
149
  function formatCommand(command, debug) {
127
150
  if (debug)
128
151
  return command;
@@ -130,6 +153,24 @@ function formatCommand(command, debug) {
130
153
  const truncated = firstLine.length > 80 ? firstLine.slice(0, 77) + '...' : firstLine;
131
154
  return command.includes('\n') ? truncated + ' …' : truncated;
132
155
  }
156
+ function formatPipelineResult(result, debug) {
157
+ const overallStatus = result.success
158
+ ? chalk_1.default.green('✓')
159
+ : chalk_1.default.red('✗');
160
+ console.log(` ${overallStatus} Pipeline ${chalk_1.default.cyan(result.pipelineName)} ` +
161
+ chalk_1.default.dim(`(${result.durationMs}ms)`));
162
+ for (let i = 0; i < result.steps.length; i++) {
163
+ const step = result.steps[i];
164
+ const status = step.success
165
+ ? chalk_1.default.green(' ✓')
166
+ : chalk_1.default.red(' ✗');
167
+ const typeLabel = chalk_1.default.dim(`[${step.type}]`);
168
+ console.log(` ${status} ${typeLabel} ${chalk_1.default.dim(step.stdout.split('\n')[0].slice(0, 80))}`);
169
+ if (step.stderr) {
170
+ console.log(chalk_1.default.red(` ${step.stderr.split('\n')[0]}`));
171
+ }
172
+ }
173
+ }
133
174
  async function cmdInit() {
134
175
  const target = (0, path_1.resolve)(process.cwd(), '.md-pipe.yml');
135
176
  if ((0, fs_1.existsSync)(target)) {
@@ -147,9 +188,13 @@ async function cmdWatch(opts) {
147
188
  console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
148
189
  process.exit(1);
149
190
  }
191
+ const totalTriggers = config.triggers.length + config.pipelines.length;
150
192
  watcher.on('ready', () => {
151
193
  console.log(chalk_1.default.green('✓') + ` Watching ${config.watch}`);
152
- console.log(chalk_1.default.dim(` ${config.triggers.length} trigger(s) loaded`));
194
+ if (config.triggers.length > 0)
195
+ console.log(chalk_1.default.dim(` ${config.triggers.length} trigger(s)`));
196
+ if (config.pipelines.length > 0)
197
+ console.log(chalk_1.default.dim(` ${config.pipelines.length} pipeline(s)`));
153
198
  if (config.debounce)
154
199
  console.log(chalk_1.default.dim(` debounce: ${config.debounce}ms`));
155
200
  if (opts.dryRun)
@@ -182,10 +227,16 @@ async function cmdWatch(opts) {
182
227
  console.log(chalk_1.default.red(' ' + result.stderr.replace(/\n/g, '\n ')));
183
228
  }
184
229
  });
230
+ watcher.on('pipeline', (result) => {
231
+ if (opts.json) {
232
+ console.log(JSON.stringify(result));
233
+ return;
234
+ }
235
+ formatPipelineResult(result, opts.debug);
236
+ });
185
237
  watcher.on('error', (err, filePath) => {
186
238
  console.error(chalk_1.default.red(`Error${filePath ? ` (${filePath})` : ''}: ${err.message}`));
187
239
  });
188
- // Graceful shutdown
189
240
  process.on('SIGINT', async () => {
190
241
  console.log(chalk_1.default.dim('\nStopping watcher...'));
191
242
  await watcher.stop();
@@ -231,6 +282,29 @@ function cmdOnce(opts) {
231
282
  if (result.errors.length > 0)
232
283
  process.exit(1);
233
284
  }
285
+ async function cmdRun(opts) {
286
+ if (!opts.pipelineName) {
287
+ console.error(chalk_1.default.red('Usage: md-pipe run <pipeline-name> [file]'));
288
+ console.error(chalk_1.default.dim(' Run a specific pipeline manually.'));
289
+ process.exit(1);
290
+ }
291
+ const config = getConfig(opts.config);
292
+ const result = await (0, run_command_js_1.runPipelineCommand)(config, opts.pipelineName, opts.file, opts.dryRun);
293
+ if (opts.json) {
294
+ console.log(JSON.stringify(result, null, 2));
295
+ return;
296
+ }
297
+ console.log(chalk_1.default.bold(`md-pipe run ${opts.pipelineName}`) + (opts.file ? ` ${opts.file}` : ''));
298
+ console.log();
299
+ for (const pr of result.results) {
300
+ formatPipelineResult(pr, opts.debug);
301
+ }
302
+ for (const err of result.errors) {
303
+ console.error(chalk_1.default.red(err));
304
+ }
305
+ if (!result.success)
306
+ process.exit(1);
307
+ }
234
308
  function cmdTest(opts) {
235
309
  if (!opts.file) {
236
310
  console.error(chalk_1.default.red('Usage: md-pipe test <file>'));
@@ -246,11 +320,12 @@ function cmdTest(opts) {
246
320
  console.log(chalk_1.default.dim(` Frontmatter: ${JSON.stringify(result.frontmatter)}`));
247
321
  console.log(chalk_1.default.dim(` Tags: [${result.tags.join(', ')}]\n`));
248
322
  if (result.matches.length === 0) {
249
- console.log(chalk_1.default.yellow(' No triggers matched this file.'));
323
+ console.log(chalk_1.default.yellow(' No triggers or pipelines matched this file.'));
250
324
  return;
251
325
  }
252
326
  for (const m of result.matches) {
253
- console.log(chalk_1.default.green('') + ` ${chalk_1.default.cyan(m.triggerName)}: ${m.reason}`);
327
+ const label = m.type === 'pipeline' ? chalk_1.default.magenta('[pipeline]') : chalk_1.default.blue('[trigger]');
328
+ console.log(chalk_1.default.green(' ✓') + ` ${label} ${chalk_1.default.cyan(m.triggerName)}: ${m.reason}`);
254
329
  }
255
330
  }
256
331
  async function main() {
@@ -267,6 +342,9 @@ async function main() {
267
342
  case 'once':
268
343
  cmdOnce(opts);
269
344
  break;
345
+ case 'run':
346
+ await cmdRun(opts);
347
+ break;
270
348
  case 'test':
271
349
  cmdTest(opts);
272
350
  break;
@@ -1,3 +1,4 @@
1
+ import type { PipelineDef } from './pipeline.js';
1
2
  export interface TriggerMatch {
2
3
  path?: string;
3
4
  frontmatter?: Record<string, unknown>;
@@ -16,6 +17,7 @@ export interface MdPipeConfig {
16
17
  watch: string;
17
18
  configDir: string;
18
19
  triggers: TriggerDef[];
20
+ pipelines: PipelineDef[];
19
21
  debounce?: number;
20
22
  }
21
23
  export declare function findConfigFile(dir: string): string | null;