@nevermorelove/ompp 0.1.0 → 0.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.
Files changed (3) hide show
  1. package/README.md +23 -11
  2. package/ompp.js +161 -42
  3. package/package.json +1 -1
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,10 +184,100 @@ 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
 
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"
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
+ }
243
+
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
+
154
281
  function pickInteractive(modes) {
155
282
  return new Promise((resolve, reject) => {
156
283
  const stdin = process.stdin;
@@ -229,42 +356,27 @@ function pickInteractive(modes) {
229
356
 
230
357
  stdin.setRawMode(true);
231
358
  stdin.resume();
232
- stdin.on("data", onData);
233
359
  render(true);
234
360
  });
235
361
  }
236
362
 
237
- function pickByNumber(modes) {
238
- return new Promise((resolve, reject) => {
239
- const readline = require("readline");
240
- const rl = readline.createInterface({ input: process.stdin });
241
- 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}]: `);
244
- rl.once("line", (line) => {
245
- rl.close();
246
- const n = parseInt(line.trim(), 10);
247
- if (n >= 1 && n <= modes.length) resolve(modes[n - 1]);
248
- else reject(new Error(`"${line.trim()}" is not a valid choice`));
249
- });
250
- rl.once("close", () => {
251
- reject(new Error("no mode selected"));
252
- });
253
- });
254
- }
255
-
256
363
  async function main() {
257
364
  const argv = process.argv.slice(2);
258
365
 
366
+ if (argv[0] === "create") {
367
+ return createMode(argv[1]);
368
+ }
369
+
259
370
  if (argv[0] === "list") {
260
371
  const { modes, skipped } = discoverModes();
261
372
  if (!modes.length) {
262
373
  console.error(
263
- `[ompp] no modes found in ${MODES_DIR}. Create modes/<name>/ with at least one mode file.`,
374
+ `[ompp] no modes found. Run "ompp create <name>" to make one.`,
264
375
  );
376
+ for (const s of SOURCES) console.error(`[ompp] looked in: ${s}`);
265
377
  return 1;
266
378
  }
267
- for (const m of modes) console.log(m);
379
+ for (const m of modes) console.log(m.name);
268
380
  if (skipped.length) {
269
381
  console.error(
270
382
  `[ompp] skipped folders without recognized files: ${skipped.join(", ")}`,
@@ -293,46 +405,53 @@ async function main() {
293
405
  }
294
406
  if (!modes.length) {
295
407
  console.error(
296
- `[ompp] no modes found in ${MODES_DIR}. Create modes/<name>/ with at least one mode file.`,
408
+ `[ompp] no modes found. Run "ompp create <name>" to make one.`,
297
409
  );
410
+ for (const s of SOURCES) console.error(`[ompp] looked in: ${s}`);
298
411
  return 1;
299
412
  }
300
413
 
414
+ const modeNames = modes.map((m) => m.name);
301
415
  let mode = null;
302
416
  let userArgs = rest;
303
417
  if (rest.length && !rest[0].startsWith("-")) {
304
418
  const candidate = rest[0];
305
- if (modes.includes(candidate)) {
306
- mode = candidate;
419
+ const found = modes.find((m) => m.name === candidate);
420
+ if (found) {
421
+ mode = found;
307
422
  userArgs = rest.slice(1);
308
423
  } else {
309
424
  console.error(`[ompp] unknown mode "${candidate}"`);
310
- console.error(`[ompp] available: ${modes.join(", ")}`);
425
+ console.error(`[ompp] available: ${modeNames.join(", ")}`);
311
426
  return 1;
312
427
  }
313
428
  } else {
314
429
  try {
315
430
  mode = process.stdin.isTTY
316
- ? await pickInteractive(modes)
317
- : await pickByNumber(modes);
431
+ ? await pickInteractive(modeNames).then((n) =>
432
+ modes.find((m) => m.name === n),
433
+ )
434
+ : await pickByNumber(modeNames).then((n) =>
435
+ modes.find((m) => m.name === n),
436
+ );
318
437
  } catch (err) {
319
438
  console.error(`[ompp] ${err.message}`);
320
439
  return 1;
321
440
  }
322
441
  }
323
442
 
324
- const modeDir = path.join(MODES_DIR, mode);
443
+ const modeDir = modeDirOf(mode.name, mode.source);
325
444
  const args = buildArgv(modeDir, userArgs);
326
445
  const { bin, shell } = resolveBin();
327
446
 
328
447
  if (dryRun) {
329
- console.error(`[ompp] mode: ${mode}`);
448
+ console.error(`[ompp] mode: ${mode.name}`);
330
449
  console.error(`[ompp] omp: ${bin}${shell ? " (shell)" : ""}`);
331
450
  console.error(`[ompp] argv: ${JSON.stringify(args)}`);
332
451
  return 0;
333
452
  }
334
453
 
335
- console.error(`[ompp] mode: ${mode}`);
454
+ console.error(`[ompp] mode: ${mode.name}`);
336
455
  const child = shell
337
456
  ? spawn([winQuote(bin), ...args.map(winQuote)].join(" "), {
338
457
  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.0",
4
4
  "description": "Mode presets for the omp coding agent: one folder per mode, zero config code.",
5
5
  "repository": {
6
6
  "type": "git",