@safetnsr/md-pipe 0.1.1 → 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,13 +23,94 @@ 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
@@ -40,23 +121,11 @@ triggers:
40
121
  path: "posts/**"
41
122
  frontmatter:
42
123
  status: publish
43
- run: "./scripts/deploy.sh $FILE"
44
-
45
- - name: reindex
46
- match:
47
- frontmatter_changed:
48
- - title
49
- - tags
50
- - category
51
- run: "./scripts/reindex.sh $FILE"
52
-
53
- - name: urgent-notify
54
- match:
55
- tags:
56
- - urgent
57
- run: "curl -X POST $WEBHOOK -d '{\"file\": \"$FILE\"}'"
124
+ run: "echo Publishing $FILE"
58
125
  ```
59
126
 
127
+ Triggers and pipelines can coexist in the same config.
128
+
60
129
  ## Commands
61
130
 
62
131
  | Command | Description |
@@ -64,7 +133,8 @@ triggers:
64
133
  | `md-pipe init` | Scaffold a `.md-pipe.yml` config file |
65
134
  | `md-pipe watch` | Start watching for changes and trigger actions |
66
135
  | `md-pipe once` | Run triggers against current files (CI/batch mode) |
67
- | `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 |
68
138
 
69
139
  ## Flags
70
140
 
@@ -73,105 +143,144 @@ triggers:
73
143
  | `--config, -c <path>` | Path to config file |
74
144
  | `--dry-run` | Show matches without executing actions |
75
145
  | `--json` | Output in JSON format |
76
- | `--verbose` | Show detailed output |
77
- | `--version, -v` | Show version |
78
- | `--help, -h` | Show help |
146
+ | `--verbose` | Show trigger + file + first line of command |
147
+ | `--debug` | Show full interpolated commands |
148
+ | `--state <path>` | State file for idempotent `once` mode |
79
149
 
80
150
  ## Trigger Matchers
81
151
 
152
+ All matchers work in both `triggers` (via `match:`) and `pipelines` (via `trigger:`).
153
+
82
154
  ### `path` — glob pattern matching
83
155
  ```yaml
84
- match:
85
- path: "posts/**/*.md"
156
+ path: "posts/**/*.md"
86
157
  ```
87
158
 
88
- ### `frontmatter` — match specific frontmatter values
159
+ ### `frontmatter` — match specific values
89
160
  ```yaml
90
- match:
91
- frontmatter:
92
- status: publish
93
- type: blog
161
+ frontmatter:
162
+ status: publish
163
+ type: blog
164
+ ```
165
+
166
+ ### Negation
167
+ ```yaml
168
+ frontmatter:
169
+ status: "!draft" # matches anything except draft
94
170
  ```
95
171
 
96
172
  ### `frontmatter_changed` — fires when specific fields change
97
173
  ```yaml
98
- match:
99
- frontmatter_changed:
100
- - title
101
- - tags
174
+ frontmatter_changed:
175
+ - title
176
+ - tags
102
177
  ```
103
- 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.
104
179
 
105
180
  ### `tags` — match files with specific tags
106
181
  ```yaml
107
- match:
108
- tags:
109
- - urgent
110
- - review
182
+ tags:
183
+ - urgent
111
184
  ```
112
185
 
113
- Matchers can be combinedall conditions must match for the trigger to fire.
186
+ ### `content` / `content_regex` — match body text
187
+ ```yaml
188
+ content: "TODO"
189
+ content_regex: "\\[ \\]" # unchecked checkboxes
190
+ ```
114
191
 
115
- ## Environment Variables
192
+ Matchers can be combined — all conditions must match.
116
193
 
117
- Scripts receive these env vars:
194
+ ## Step Details
118
195
 
119
- | Variable | Description |
120
- |---|---|
121
- | `$FILE` | Absolute path to the changed file |
122
- | `$DIR` | Directory containing the file |
123
- | `$BASENAME` | Filename without directory |
124
- | `$RELATIVE` | Path relative to watch directory |
125
- | `$FRONTMATTER` | JSON string of all frontmatter |
126
- | `$DIFF` | JSON string of changed frontmatter fields |
127
- | `$TAGS` | Comma-separated list of tags |
196
+ ### `update-frontmatter`
128
197
 
129
- ## Agent Interface (--json)
198
+ Write fields back to the source markdown file:
130
199
 
131
- ```bash
132
- md-pipe once --json
133
- ```
134
-
135
- ```json
136
- {
137
- "total": 42,
138
- "matched": 3,
139
- "actions": [
140
- {
141
- "triggerName": "publish",
142
- "filePath": "/docs/posts/hello.md",
143
- "command": "./scripts/deploy.sh /docs/posts/hello.md",
144
- "exitCode": 0,
145
- "stdout": "deployed",
146
- "stderr": "",
147
- "durationMs": 150
148
- }
149
- ],
150
- "errors": []
151
- }
200
+ ```yaml
201
+ - update-frontmatter:
202
+ published_at: "{{now}}"
203
+ published: "true" # coerced to boolean
204
+ url: "{{step.0.stdout}}" # from previous step
152
205
  ```
153
206
 
154
- ```bash
155
- md-pipe watch --json
156
- # streams one JSON object per triggered action
207
+ Values are coerced: `"true"` → `true`, `"false"` → `false`, numeric strings → numbers.
208
+
209
+ ### `copy`
210
+
211
+ ```yaml
212
+ - copy: { to: "./_site/posts" } # preserves directory structure
213
+ - copy: { to: "./_site", flatten: true } # flat copy
157
214
  ```
158
215
 
159
- ## Use Cases
216
+ ### `webhook`
217
+
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
+ ```
228
+
229
+ ### `template`
230
+
231
+ ```yaml
232
+ - template:
233
+ src: "./templates/post.html"
234
+ out: "./_site/{{slug}}.html"
235
+ ```
236
+
237
+ Template files use the same `{{variable}}` syntax.
238
+
239
+ ### Error Handling
240
+
241
+ ```yaml
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"
249
+ ```
160
250
 
161
- **Docs-as-code auto-deploy** — publish pages when `status: publish` is set in frontmatter
251
+ ## Environment Variables
162
252
 
163
- **Agent workspace automation** trigger reindexing when an agent updates `AGENTS.md` or `NOW.md`
253
+ Shell commands (`run` steps and legacy triggers) receive:
164
254
 
165
- **Obsidian vault pipeline** auto-archive notes, generate backlinks, push to git on tag changes
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 |
166
268
 
167
- **Digital garden** auto-generate RSS feed when new posts are added
269
+ ## Idempotent Mode (--state)
168
270
 
169
- **CI/CD for markdown** — `md-pipe once` in your CI pipeline to validate or transform docs
271
+ ```bash
272
+ md-pipe once --state .md-pipe-state.json
273
+ # Only processes files that changed since last run
274
+ ```
170
275
 
171
- ## Pair With
276
+ ## Use Cases
172
277
 
173
- - [`@safetnsr/md-kit`](https://github.com/safetnsr/md-kit) — markdown workspace toolkit (wikilinks, broken links, backlink maps)
174
- - [`@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
175
284
 
176
285
  ## License
177
286
 
package/dist/cli.js CHANGED
@@ -11,28 +11,34 @@ 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.1.1';
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)
28
30
  --dry-run Show matches without executing actions
29
31
  --json Output in JSON format
30
- --verbose Show detailed output
32
+ --verbose Show trigger + file + first line of command
33
+ --debug Show full interpolated commands
34
+ --state <path> State file for idempotent once mode
31
35
  --version, -v Show version
32
36
  --help, -h Show this help
33
37
 
34
38
  ${chalk_1.default.bold('Config (.md-pipe.yml):')}
35
39
  watch: ./docs
40
+
41
+ # Legacy triggers (simple match + run)
36
42
  triggers:
37
43
  - name: publish
38
44
  match:
@@ -40,14 +46,32 @@ ${chalk_1.default.bold('Config (.md-pipe.yml):')}
40
46
  frontmatter: { status: publish }
41
47
  run: "echo Publishing $FILE"
42
48
 
43
- ${chalk_1.default.bold('Env vars passed to scripts:')}
44
- $FILE Absolute path to the changed file
45
- $DIR Directory containing the file
46
- $BASENAME Filename without directory
47
- $RELATIVE Path relative to watch directory
48
- $FRONTMATTER JSON string of all frontmatter
49
- $DIFF JSON string of changed frontmatter fields
50
- $TAGS Comma-separated list of tags
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
51
75
 
52
76
  ${chalk_1.default.bold('More:')} https://github.com/safetnsr/md-pipe
53
77
  `);
@@ -55,10 +79,14 @@ ${chalk_1.default.bold('More:')} https://github.com/safetnsr/md-pipe
55
79
  function parseArgs(args) {
56
80
  let command = '';
57
81
  let file;
82
+ let pipelineName;
58
83
  let config;
59
84
  let dryRun = false;
60
85
  let json = false;
61
86
  let verbose = false;
87
+ let debug = false;
88
+ let state;
89
+ let positionals = [];
62
90
  for (let i = 0; i < args.length; i++) {
63
91
  const arg = args[i];
64
92
  if (arg === '--help' || arg === '-h') {
@@ -81,6 +109,14 @@ function parseArgs(args) {
81
109
  verbose = true;
82
110
  continue;
83
111
  }
112
+ if (arg === '--debug') {
113
+ debug = true;
114
+ continue;
115
+ }
116
+ if (arg === '--state') {
117
+ state = args[++i];
118
+ continue;
119
+ }
84
120
  if (arg === '--config' || arg === '-c') {
85
121
  config = args[++i];
86
122
  continue;
@@ -89,12 +125,17 @@ function parseArgs(args) {
89
125
  command = arg;
90
126
  continue;
91
127
  }
92
- if (command === 'test' && !file) {
93
- file = arg;
94
- continue;
95
- }
128
+ positionals.push(arg);
129
+ }
130
+ // Parse positionals based on command
131
+ if (command === 'run') {
132
+ pipelineName = positionals[0];
133
+ file = positionals[1];
134
+ }
135
+ else if (command === 'test') {
136
+ file = positionals[0];
96
137
  }
97
- return { command: command || 'help', file, config, dryRun, json, verbose };
138
+ return { command: command || 'help', file, pipelineName, config, dryRun, json, verbose, debug, state };
98
139
  }
99
140
  function getConfig(configPath) {
100
141
  const cwd = process.cwd();
@@ -105,6 +146,31 @@ function getConfig(configPath) {
105
146
  }
106
147
  return (0, config_js_1.loadConfig)(cfgPath);
107
148
  }
149
+ function formatCommand(command, debug) {
150
+ if (debug)
151
+ return command;
152
+ const firstLine = command.split('\n')[0].trim();
153
+ const truncated = firstLine.length > 80 ? firstLine.slice(0, 77) + '...' : firstLine;
154
+ return command.includes('\n') ? truncated + ' …' : truncated;
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
+ }
108
174
  async function cmdInit() {
109
175
  const target = (0, path_1.resolve)(process.cwd(), '.md-pipe.yml');
110
176
  if ((0, fs_1.existsSync)(target)) {
@@ -122,16 +188,22 @@ async function cmdWatch(opts) {
122
188
  console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
123
189
  process.exit(1);
124
190
  }
191
+ const totalTriggers = config.triggers.length + config.pipelines.length;
125
192
  watcher.on('ready', () => {
126
193
  console.log(chalk_1.default.green('✓') + ` Watching ${config.watch}`);
127
- 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)`));
198
+ if (config.debounce)
199
+ console.log(chalk_1.default.dim(` debounce: ${config.debounce}ms`));
128
200
  if (opts.dryRun)
129
201
  console.log(chalk_1.default.yellow(' [dry-run mode — actions will not execute]'));
130
202
  console.log(chalk_1.default.dim(' Press Ctrl+C to stop\n'));
131
203
  });
132
204
  watcher.on('match', (result) => {
133
205
  if (opts.json)
134
- return; // json mode outputs on action
206
+ return;
135
207
  const ts = new Date().toLocaleTimeString();
136
208
  console.log(chalk_1.default.dim(`[${ts}]`) + ' ' +
137
209
  chalk_1.default.cyan(`▸ ${result.trigger.name}`) + ' ' +
@@ -146,18 +218,25 @@ async function cmdWatch(opts) {
146
218
  const status = result.exitCode === 0
147
219
  ? chalk_1.default.green('✓')
148
220
  : chalk_1.default.red(`✗ exit ${result.exitCode}`);
149
- console.log(` ${status} ${chalk_1.default.dim(result.command)}`);
150
- if (result.stdout && opts.verbose) {
221
+ const cmdDisplay = formatCommand(result.command, opts.debug);
222
+ console.log(` ${status} ${chalk_1.default.dim(cmdDisplay)}`);
223
+ if (result.stdout && (opts.verbose || opts.debug)) {
151
224
  console.log(chalk_1.default.dim(' ' + result.stdout.replace(/\n/g, '\n ')));
152
225
  }
153
226
  if (result.stderr) {
154
227
  console.log(chalk_1.default.red(' ' + result.stderr.replace(/\n/g, '\n ')));
155
228
  }
156
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
+ });
157
237
  watcher.on('error', (err, filePath) => {
158
238
  console.error(chalk_1.default.red(`Error${filePath ? ` (${filePath})` : ''}: ${err.message}`));
159
239
  });
160
- // Graceful shutdown
161
240
  process.on('SIGINT', async () => {
162
241
  console.log(chalk_1.default.dim('\nStopping watcher...'));
163
242
  await watcher.stop();
@@ -171,13 +250,16 @@ function cmdOnce(opts) {
171
250
  console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
172
251
  process.exit(1);
173
252
  }
174
- const result = (0, once_js_1.runOnce)(config, opts.dryRun);
253
+ const result = (0, once_js_1.runOnce)(config, opts.dryRun, opts.state);
175
254
  if (opts.json) {
176
255
  console.log(JSON.stringify(result, null, 2));
177
256
  return;
178
257
  }
179
258
  console.log(chalk_1.default.bold('md-pipe once'));
180
- console.log(chalk_1.default.dim(` Scanned ${result.total} files, ${result.matched} trigger match(es)\n`));
259
+ let summary = ` Scanned ${result.total} files, ${result.matched} trigger match(es)`;
260
+ if (result.skipped > 0)
261
+ summary += `, ${result.skipped} skipped (unchanged)`;
262
+ console.log(chalk_1.default.dim(summary + '\n'));
181
263
  if (opts.dryRun) {
182
264
  console.log(chalk_1.default.yellow(' [dry-run mode — actions were not executed]\n'));
183
265
  }
@@ -185,8 +267,9 @@ function cmdOnce(opts) {
185
267
  const status = action.exitCode === 0
186
268
  ? chalk_1.default.green('✓')
187
269
  : chalk_1.default.red(`✗ exit ${action.exitCode}`);
188
- console.log(`${status} ${chalk_1.default.cyan(action.triggerName)} ${chalk_1.default.dim(action.command)}`);
189
- if (action.stdout && opts.verbose) {
270
+ const cmdDisplay = formatCommand(action.command, opts.debug);
271
+ console.log(`${status} ${chalk_1.default.cyan(action.triggerName)} ${chalk_1.default.dim(cmdDisplay)}`);
272
+ if (action.stdout && (opts.verbose || opts.debug)) {
190
273
  console.log(chalk_1.default.dim(' ' + action.stdout));
191
274
  }
192
275
  if (action.stderr) {
@@ -199,6 +282,29 @@ function cmdOnce(opts) {
199
282
  if (result.errors.length > 0)
200
283
  process.exit(1);
201
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
+ }
202
308
  function cmdTest(opts) {
203
309
  if (!opts.file) {
204
310
  console.error(chalk_1.default.red('Usage: md-pipe test <file>'));
@@ -214,11 +320,12 @@ function cmdTest(opts) {
214
320
  console.log(chalk_1.default.dim(` Frontmatter: ${JSON.stringify(result.frontmatter)}`));
215
321
  console.log(chalk_1.default.dim(` Tags: [${result.tags.join(', ')}]\n`));
216
322
  if (result.matches.length === 0) {
217
- 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.'));
218
324
  return;
219
325
  }
220
326
  for (const m of result.matches) {
221
- 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}`);
222
329
  }
223
330
  }
224
331
  async function main() {
@@ -235,6 +342,9 @@ async function main() {
235
342
  case 'once':
236
343
  cmdOnce(opts);
237
344
  break;
345
+ case 'run':
346
+ await cmdRun(opts);
347
+ break;
238
348
  case 'test':
239
349
  cmdTest(opts);
240
350
  break;
@@ -1,17 +1,24 @@
1
+ import type { PipelineDef } from './pipeline.js';
1
2
  export interface TriggerMatch {
2
3
  path?: string;
3
4
  frontmatter?: Record<string, unknown>;
4
5
  frontmatter_changed?: string[];
5
6
  tags?: string[];
7
+ content?: string;
8
+ content_regex?: string;
6
9
  }
7
10
  export interface TriggerDef {
8
11
  name: string;
9
12
  match: TriggerMatch;
10
13
  run: string;
14
+ cwd?: 'project' | 'file';
11
15
  }
12
16
  export interface MdPipeConfig {
13
17
  watch: string;
18
+ configDir: string;
14
19
  triggers: TriggerDef[];
20
+ pipelines: PipelineDef[];
21
+ debounce?: number;
15
22
  }
16
23
  export declare function findConfigFile(dir: string): string | null;
17
24
  export declare function loadConfig(configPath: string): MdPipeConfig;