@nevermorelove/ompp 0.1.0 → 0.2.1

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 (3) hide show
  1. package/README.md +23 -11
  2. package/ompp.js +177 -106
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -8,32 +8,44 @@ ompp gives each kind of work a name and a folder. Launch a session in a mode and
8
8
 
9
9
  ## Install
10
10
 
11
+ ```sh
12
+ npm install -g @nevermorelove/ompp
13
+ ```
14
+
15
+ Node 18 or newer, that is the whole requirement. bun works too. The binary is
16
+ named `ompp` even though the package is scoped. You can also install from
17
+ source:
18
+
11
19
  ```sh
12
20
  git clone git@github.com:ForeverInLaw/ompp.git
13
21
  cd ompp
14
22
  npm install -g .
15
23
  ```
16
24
 
17
- Node 18 or newer, that is the whole requirement. bun works too. After install, `ompp` is on your PATH. To update, `git pull` and install again.
18
-
19
- ## Usage
20
-
21
25
  ```sh
22
26
  ompp arrow-key picker, then launches omp
23
27
  ompp pentest launch in a mode
24
28
  ompp writing -p "..." mode plus any omp flags
29
+ ompp create pentest make a new mode with placeholder files,
30
+ then open its folder in your file manager
25
31
  ompp list print mode names
26
32
  ompp pentest --dry-run show the omp command line instead of running it
27
33
  ```
28
34
 
29
35
  Before launch it prints one line to stderr, `[ompp] mode: pentest`, so you always know where you are.
30
36
 
31
- ## What a mode is
37
+ Modes are read from up to three places, and same-named modes from earlier
38
+ places win:
32
39
 
33
- A folder under `modes/`. Every file is optional, whatever exists gets wired into the launch flags.
40
+ 1. `OMPP_MODES_DIR`, if you set it
41
+ 2. `modes/` shipped next to the script (the repo checkout or the npm package)
42
+ 3. `~/.omp/ompp/modes/`, the user-level home for your own modes
43
+
44
+ `ompp create` always writes to `~/.omp/ompp/modes/`, so you never edit files
45
+ inside an installed package. The first `ompp create` makes the folder for you.
34
46
 
35
47
  ```
36
- modes/pentest/
48
+ ~/.omp/ompp/modes/pentest/
37
49
  config.yml settings overlay
38
50
  system.md full system prompt replacement
39
51
  append.md prompt addendum, used when system.md is absent
@@ -49,7 +61,9 @@ modes/pentest/
49
61
  | `append.md` | `--append-system-prompt` | Rides on top of your normal prompt. This is what most modes want. |
50
62
  | the folder itself | `--plugin-dir` | omp treats the mode folder as a plugin root, so `skills/` and `.mcp.json` inside it load too. |
51
63
 
52
- A new mode is `mkdir modes/<name>` plus files. No manifest, no code. The wrapper reads the folder, so it never needs to change when you add modes. `modes/general` is a working example to copy from.
64
+ A new mode is `ompp create <name>` (or plain `mkdir` plus files). No manifest,
65
+ no code. The wrapper reads the folder, so it never needs to change when you
66
+ add modes. `modes/general` in the repo is a working example.
53
67
 
54
68
  ## Precedence
55
69
 
@@ -65,9 +79,7 @@ MCP servers can be added by a mode but never removed. omp has no launch-time MCP
65
79
 
66
80
  Mode-local `skills/` are additive as well. To have fewer skills, set `skills.includeSkills` in the mode's `config.yml`. The allowlist filters every discovered skill, global ones included.
67
81
 
68
- The picker needs a TTY. Piped or otherwise headless stdin gets a numbered prompt instead.
69
-
70
82
  ## Environment
71
83
 
72
- - `OMPP_MODES_DIR`, where modes live. Default is `modes/` next to the script.
84
+ - `OMPP_MODES_DIR`, an extra modes directory that wins over the bundled one.
73
85
  - `OMPP_OMP_BIN`, which omp to launch. Default is omp from PATH.
package/ompp.js CHANGED
@@ -20,7 +20,20 @@ const fs = require("fs");
20
20
  const path = require("path");
21
21
  const { spawn } = require("child_process");
22
22
 
23
- const MODES_DIR = process.env.OMPP_MODES_DIR || path.join(__dirname, "modes");
23
+ const os = require("os");
24
+
25
+ // Modes come from up to three places, in priority order:
26
+ // 1. OMPP_MODES_DIR (explicit override)
27
+ // 2. modes/ next to this script (repo checkout or npm package)
28
+ // 3. ~/.omp/ompp/modes (user-level default where `ompp create` writes)
29
+ // Same-named modes from a higher-priority source win.
30
+ const DEFAULT_MODES_DIR = path.join(os.homedir(), ".omp", "ompp", "modes");
31
+ const BUNDLED_MODES_DIR = path.join(__dirname, "modes");
32
+ const SOURCES = [];
33
+ if (process.env.OMPP_MODES_DIR) SOURCES.push(process.env.OMPP_MODES_DIR);
34
+ SOURCES.push(BUNDLED_MODES_DIR);
35
+ SOURCES.push(DEFAULT_MODES_DIR);
36
+ const RESERVED_NAMES = ["create", "list", "help", "version"];
24
37
  const CONFIG_NAMES = ["config.yml", "config.yaml"];
25
38
  const PLUGIN_ARTIFACTS = [
26
39
  "skills",
@@ -32,6 +45,10 @@ const PLUGIN_ARTIFACTS = [
32
45
  "tools",
33
46
  ];
34
47
 
48
+ function modeDirOf(name, source) {
49
+ return path.join(source, name);
50
+ }
51
+
35
52
  function firstExisting(dir, names) {
36
53
  for (const n of names) {
37
54
  const p = path.join(dir, n);
@@ -40,27 +57,43 @@ function firstExisting(dir, names) {
40
57
  return null;
41
58
  }
42
59
 
43
- function discoverModes() {
44
- const modes = [];
45
- const skipped = [];
46
- if (!fs.existsSync(MODES_DIR)) return { modes, skipped };
60
+ function scanSource(source, modes, skipped, seen) {
61
+ if (!fs.existsSync(source)) return;
47
62
  const entries = fs
48
- .readdirSync(MODES_DIR, { withFileTypes: true })
63
+ .readdirSync(source, { withFileTypes: true })
49
64
  .filter((e) => e.isDirectory())
50
65
  .map((e) => e.name)
51
66
  .sort();
52
67
  for (const name of entries) {
53
68
  if (name.startsWith(".") || name.startsWith("_")) continue;
54
- const dir = path.join(MODES_DIR, name);
69
+ const dir = modeDirOf(name, source);
55
70
  const hasSomething = firstExisting(dir, [
56
71
  ...CONFIG_NAMES,
57
72
  "system.md",
58
73
  "append.md",
59
74
  ...PLUGIN_ARTIFACTS,
60
75
  ]);
61
- if (hasSomething) modes.push(name);
62
- else skipped.push(name);
76
+ if (!hasSomething) {
77
+ skipped.push(name);
78
+ continue;
79
+ }
80
+ if (seen.has(name)) {
81
+ // A same-named mode from a higher-priority source already won.
82
+ console.error(
83
+ `[ompp] mode "${name}" exists in several mode folders; using the one in ${modeDirOf(name, SOURCES[0])}`,
84
+ );
85
+ continue;
86
+ }
87
+ seen.add(name);
88
+ modes.push({ name, source });
63
89
  }
90
+ }
91
+
92
+ function discoverModes() {
93
+ const modes = [];
94
+ const skipped = [];
95
+ const seen = new Set();
96
+ for (const source of SOURCES) scanSource(source, modes, skipped, seen);
64
97
  return { modes, skipped };
65
98
  }
66
99
 
@@ -130,9 +163,13 @@ Usage:
130
163
  ompp pick a mode interactively, then launch omp
131
164
  ompp <mode> [args...] launch omp in a mode; everything after <mode>
132
165
  is passed to omp unchanged and wins over the mode
166
+ ompp create <name> create a new mode with placeholder files
167
+ and open its folder
133
168
  ompp list list available modes
134
169
 
135
- A mode is a folder under modes/. All files are optional:
170
+ Modes live in ~/.omp/ompp/modes/ (created for you by "ompp create") and,
171
+ if OMPP_MODES_DIR is set, in that folder too. Same-named modes from
172
+ OMPP_MODES_DIR win. All files are optional:
136
173
  config.yml settings overlay: model, thinking level,
137
174
  skills.includeSkills allowlist, approval mode, ...
138
175
  system.md replaces the system prompt entirely
@@ -147,124 +184,154 @@ Flags:
147
184
  -v, --version version
148
185
 
149
186
  Environment:
150
- OMPP_MODES_DIR modes directory (default: modes/ next to this script)
187
+ OMPP_MODES_DIR extra modes directory (repo checkout or custom)
151
188
  OMPP_OMP_BIN omp executable to launch (default: omp from PATH)`);
152
189
  }
153
190
 
154
- function pickInteractive(modes) {
155
- return new Promise((resolve, reject) => {
156
- const stdin = process.stdin;
157
- let idx = 0;
158
- let settled = false;
159
-
160
- const finish = (value) => {
161
- if (settled) return;
162
- settled = true;
163
- stdin.removeListener("data", onData);
164
- stdin.setRawMode(false);
165
- process.stdout.write("\u001b[?25h"); // show cursor
166
- if (value === null) reject(new Error("mode selection cancelled"));
167
- else resolve(value);
168
- };
169
-
170
- const render = (first) => {
171
- if (!first) process.stdout.write(`\u001b[${modes.length}A`);
172
- const body = modes
173
- .map((m, i) => (i === idx ? "> " + m : " " + m))
174
- .map((l) => "\u001b[2K" + l)
175
- .join("\n");
176
- process.stdout.write("\u001b[?25l" + body + "\n");
177
- };
178
-
179
- function onData(buf) {
180
- // A single read can carry several keys (arrow + Enter glued together,
181
- // a paste). Walk key by key instead of matching the whole chunk.
182
- const s = buf.toString();
183
- let i = 0;
184
- while (i < s.length) {
185
- const ch = s[i];
186
- if (ch === "\u001b") {
187
- const seq = s.slice(i);
188
- if (seq.startsWith("\u001b[A") || seq.startsWith("\u001b[B") ||
189
- seq.startsWith("\u001b[C") || seq.startsWith("\u001b[D")) {
190
- if (seq.startsWith("\u001b[A")) {
191
- idx = (idx - 1 + modes.length) % modes.length;
192
- render(false);
193
- } else if (seq.startsWith("\u001b[B")) {
194
- idx = (idx + 1) % modes.length;
195
- render(false);
196
- } // C/D: left/right, ignore
197
- i += 3;
198
- continue;
199
- }
200
- if (seq.length === 1) {
201
- // Bare escape with nothing after it in this chunk: cancel.
202
- finish(null);
203
- process.exit(0);
204
- }
205
- i++; // unknown or split sequence, skip the escape byte
206
- continue;
207
- }
208
- if (ch === "\r" || ch === "\n") return finish(modes[idx]);
209
- if (ch === "\u0003") {
210
- // Ctrl+C: restore the terminal and bail like a shell would.
211
- finish(null);
212
- process.exit(130);
213
- }
214
- if (ch === "q") {
215
- finish(null);
216
- process.exit(0);
217
- }
218
- if (ch === "j") {
219
- idx = (idx + 1) % modes.length;
220
- render(false);
221
- }
222
- if (ch === "k") {
223
- idx = (idx - 1 + modes.length) % modes.length;
224
- render(false);
225
- }
226
- i++;
227
- }
191
+ const CONFIG_TEMPLATE = `# Settings overlay for this mode. Every line is optional:
192
+ # uncomment what you need. See "omp config list" for all keys.
193
+
194
+ # modelRoles:
195
+ # default: anthropic/claude-sonnet-4-5
196
+ # defaultThinkingLevel: high
197
+ # tools:
198
+ # approvalMode: write
199
+
200
+ # Allowlist of skills this mode can see. Globs are allowed.
201
+ # Empty (or absent) means every discovered skill loads.
202
+ # skills:
203
+ # includeSkills:
204
+ # - tdd
205
+ # - diagnosing-bugs
206
+ `;
207
+
208
+ const APPEND_TEMPLATE = `# Extra instructions added on top of your normal system prompt.
209
+ # This file is plain text: delete these lines and write your own.
210
+
211
+ Answer directly and briefly. No preamble, no restating the question.
212
+ `;
213
+
214
+ const SYSTEM_TEMPLATE = `# Rename this file to system.md to REPLACE the whole system prompt
215
+ # instead of appending. Replacing drops omp's default instructions and
216
+ # your global ~/.omp/agent/SYSTEM.md, including tool policy: write
217
+ # what you need into the file itself.
218
+ `;
219
+
220
+ const MCP_TEMPLATE = `{
221
+ "mcpServers": {
222
+ "example": {
223
+ "type": "http",
224
+ "url": "https://mcp.example.com/mcp"
228
225
  }
226
+ }
227
+ }
228
+ `;
229
+
230
+ function revealInFileManager(dir) {
231
+ const cmd =
232
+ process.platform === "win32"
233
+ ? { bin: "explorer", args: [dir] }
234
+ : process.platform === "darwin"
235
+ ? { bin: "open", args: [dir] }
236
+ : { bin: "xdg-open", args: [dir] };
237
+ const child = spawn(cmd.bin, cmd.args, { stdio: "ignore", detached: true });
238
+ child.on("error", (err) => {
239
+ console.error(`[ompp] could not open a file manager: ${err.message}`);
240
+ });
241
+ child.unref();
242
+ }
229
243
 
230
- stdin.setRawMode(true);
231
- stdin.resume();
232
- stdin.on("data", onData);
233
- render(true);
244
+ function createMode(name) {
245
+ if (!name) {
246
+ console.error(`[ompp] usage: ompp create <name>`);
247
+ return 1;
248
+ }
249
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
250
+ console.error(
251
+ `[ompp] "${name}" is not a valid mode name. Use lowercase letters, digits, and dashes.`,
252
+ );
253
+ return 1;
254
+ }
255
+ if (RESERVED_NAMES.includes(name)) {
256
+ console.error(`[ompp] "${name}" is reserved for ompp commands.`);
257
+ return 1;
258
+ }
259
+
260
+ // Always create in the user-level default, regardless of OMPP_MODES_DIR.
261
+ const dir = modeDirOf(name, DEFAULT_MODES_DIR);
262
+ if (fs.existsSync(dir)) {
263
+ console.error(`[ompp] mode "${name}" already exists, opening its folder.`);
264
+ revealInFileManager(dir);
265
+ return 0;
266
+ }
267
+
268
+ fs.mkdirSync(path.join(dir, "skills"), { recursive: true });
269
+ fs.writeFileSync(path.join(dir, "config.yml"), CONFIG_TEMPLATE);
270
+ fs.writeFileSync(path.join(dir, "append.md"), APPEND_TEMPLATE);
271
+ fs.writeFileSync(path.join(dir, "system.md.example"), SYSTEM_TEMPLATE);
272
+ fs.writeFileSync(path.join(dir, ".mcp.json.example"), MCP_TEMPLATE);
273
+ fs.writeFileSync(path.join(dir, "skills", ".gitkeep"), "");
274
+
275
+ console.error(`[ompp] created mode "${name}" in ${dir}`);
276
+ revealInFileManager(dir);
277
+ console.error(`[ompp] edit the placeholders, then launch it: ompp ${name}`);
278
+ return 0;
279
+ }
280
+
281
+ async function pickMode(modeNames) {
282
+ const { select, isCancel, cancel } = require("@clack/prompts");
283
+ const chosen = await select({
284
+ message: "Pick a mode",
285
+ options: modeNames.map((name) => ({ value: name, label: name })),
234
286
  });
287
+ if (isCancel(chosen)) {
288
+ cancel("Cancelled");
289
+ process.exit(130);
290
+ }
291
+ return chosen;
235
292
  }
236
293
 
237
- function pickByNumber(modes) {
294
+ // Non-TTY stdin: clack needs an interactive terminal, so numbered input.
295
+ function pickModePiped(modeNames) {
238
296
  return new Promise((resolve, reject) => {
239
297
  const readline = require("readline");
240
298
  const rl = readline.createInterface({ input: process.stdin });
241
299
  process.stdout.write("Select a mode:\n");
242
- modes.forEach((m, i) => process.stdout.write(` ${i + 1}. ${m}\n`));
243
- process.stdout.write(`Choice [1-${modes.length}]: `);
300
+ modeNames.forEach((m, i) => process.stdout.write(` ${i + 1}. ${m}\n`));
301
+ process.stdout.write(`Choice [1-${modeNames.length}]: `);
302
+ let done = false;
244
303
  rl.once("line", (line) => {
304
+ done = true;
245
305
  rl.close();
246
306
  const n = parseInt(line.trim(), 10);
247
- if (n >= 1 && n <= modes.length) resolve(modes[n - 1]);
307
+ if (n >= 1 && n <= modeNames.length) resolve(modeNames[n - 1]);
248
308
  else reject(new Error(`"${line.trim()}" is not a valid choice`));
249
309
  });
250
310
  rl.once("close", () => {
251
- reject(new Error("no mode selected"));
311
+ if (!done) reject(new Error("no mode selected"));
252
312
  });
253
313
  });
254
314
  }
255
315
 
316
+
317
+
256
318
  async function main() {
257
319
  const argv = process.argv.slice(2);
258
320
 
321
+ if (argv[0] === "create") {
322
+ return createMode(argv[1]);
323
+ }
324
+
259
325
  if (argv[0] === "list") {
260
326
  const { modes, skipped } = discoverModes();
261
327
  if (!modes.length) {
262
328
  console.error(
263
- `[ompp] no modes found in ${MODES_DIR}. Create modes/<name>/ with at least one mode file.`,
329
+ `[ompp] no modes found. Run "ompp create <name>" to make one.`,
264
330
  );
331
+ for (const s of SOURCES) console.error(`[ompp] looked in: ${s}`);
265
332
  return 1;
266
333
  }
267
- for (const m of modes) console.log(m);
334
+ for (const m of modes) console.log(m.name);
268
335
  if (skipped.length) {
269
336
  console.error(
270
337
  `[ompp] skipped folders without recognized files: ${skipped.join(", ")}`,
@@ -293,46 +360,50 @@ async function main() {
293
360
  }
294
361
  if (!modes.length) {
295
362
  console.error(
296
- `[ompp] no modes found in ${MODES_DIR}. Create modes/<name>/ with at least one mode file.`,
363
+ `[ompp] no modes found. Run "ompp create <name>" to make one.`,
297
364
  );
365
+ for (const s of SOURCES) console.error(`[ompp] looked in: ${s}`);
298
366
  return 1;
299
367
  }
300
368
 
369
+ const modeNames = modes.map((m) => m.name);
301
370
  let mode = null;
302
371
  let userArgs = rest;
303
372
  if (rest.length && !rest[0].startsWith("-")) {
304
373
  const candidate = rest[0];
305
- if (modes.includes(candidate)) {
306
- mode = candidate;
374
+ const found = modes.find((m) => m.name === candidate);
375
+ if (found) {
376
+ mode = found;
307
377
  userArgs = rest.slice(1);
308
378
  } else {
309
379
  console.error(`[ompp] unknown mode "${candidate}"`);
310
- console.error(`[ompp] available: ${modes.join(", ")}`);
380
+ console.error(`[ompp] available: ${modeNames.join(", ")}`);
311
381
  return 1;
312
382
  }
313
383
  } else {
314
384
  try {
315
- mode = process.stdin.isTTY
316
- ? await pickInteractive(modes)
317
- : await pickByNumber(modes);
385
+ const name = process.stdin.isTTY
386
+ ? await pickMode(modeNames)
387
+ : await pickModePiped(modeNames);
388
+ mode = modes.find((m) => m.name === name);
318
389
  } catch (err) {
319
390
  console.error(`[ompp] ${err.message}`);
320
391
  return 1;
321
392
  }
322
393
  }
323
394
 
324
- const modeDir = path.join(MODES_DIR, mode);
395
+ const modeDir = modeDirOf(mode.name, mode.source);
325
396
  const args = buildArgv(modeDir, userArgs);
326
397
  const { bin, shell } = resolveBin();
327
398
 
328
399
  if (dryRun) {
329
- console.error(`[ompp] mode: ${mode}`);
400
+ console.error(`[ompp] mode: ${mode.name}`);
330
401
  console.error(`[ompp] omp: ${bin}${shell ? " (shell)" : ""}`);
331
402
  console.error(`[ompp] argv: ${JSON.stringify(args)}`);
332
403
  return 0;
333
404
  }
334
405
 
335
- console.error(`[ompp] mode: ${mode}`);
406
+ console.error(`[ompp] mode: ${mode.name}`);
336
407
  const child = shell
337
408
  ? spawn([winQuote(bin), ...args.map(winQuote)].join(" "), {
338
409
  stdio: "inherit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nevermorelove/ompp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Mode presets for the omp coding agent: one folder per mode, zero config code.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,5 +24,8 @@
24
24
  "cli"
25
25
  ],
26
26
  "author": "ForeverInLaw",
27
- "license": "MIT"
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@clack/prompts": "^1.8.1"
30
+ }
28
31
  }