Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ TypeScript, installed as executables on `PATH`.
| [`torrent`](#torrent) | Make a torrent out of a directory, and get it seeded |
| [`codeburn`](#codeburn) | See where your AI spend goes, by task, tool, model and project |
| [`agenticjobs`](#agenticjobs) | Search, apply, post and hire on an agent-friendly job board |
| [`openmcp`](#openmcp) | The OpenMCP catalog of MCP relays: list, find a tool, call it, register your own |
| [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/<code>` |
| [`sysupdate`](#sysupdate) | Update this box: apt lists, apt packages, snaps |

Expand Down Expand Up @@ -67,9 +68,10 @@ One thing here is not a `PATH` command and does not need Node:
- **Node 22.13+ and `pnpm` or `npm`** — `codeburn` only: it is somebody else's
npm package, installed on first use, and upstream's engine floor is higher
than this repo's
- **Node 24+ and `pnpm` or `npm`** — `agenticjobs` only, for the same reason:
the board is an npm package installed on first use, and it asks for a newer
Node than anything else here
- **Node 24+ and `pnpm` or `npm`** — `agenticjobs` and `openmcp` only, for the
same reason: each is an npm package installed on first use, and both ask for
a newer Node than anything else here (`openmcp` keeps its catalog in
`node:sqlite`)

## Install

Expand Down Expand Up @@ -1350,6 +1352,57 @@ that script first if you want this wrapper to own the name.
Upstream wants **Node 24+**, which is higher than this repo's own floor of 22.18;
on an older one it says so and tries anyway, since that floor is theirs to move.

### `openmcp`

The OpenMCP catalog of MCP relays, from the terminal:
[@logicsrc/openmcp](https://www.npmjs.com/package/@logicsrc/openmcp), the
reference client and server for the [OpenMCP](https://logicsrc.com/openmcp)
spec, pointed by default at the live catalog at
[openmcp.logicsrc.com](https://openmcp.logicsrc.com).

```sh
openmcp relays # what the catalog lists, online and verified
openmcp find "fetch a page" # search every relay's tools
openmcp call obscura.openmcp.logicsrc.com fetch_page '{"url":"https://example.com"}'
openmcp add https://your.site # register a relay you operate (probed, then listed)
openmcp probe https://your.site # what a catalog would find, without one
openmcp serve # run a catalog of your own
openmcp --help # it is upstream's CLI: upstream's flags
```

Everything is handed through untouched, so upstream's docs are the docs. Two
flags are ours, spelled `--self-*` because every plain word belongs to them:

```sh
openmcp --self-update # reinstall the latest release
openmcp --self-where # which copy runs, and from where
```

**The first run installs it**, into `~/.local/share/cli-tools/vendor/openmcp`
rather than globally, for the reason [`agenticjobs`](#agenticjobs) spells out:
upstream's executable is also called `openmcp`, and a private prefix means the
name exists once. `OPENMCP_BIN` points at a copy you would rather run, and
`OPENMCP_SPEC` pins a version.

**Three plain words are intercepted, and only for the copy we installed.**
`update`, `uninstall` and `where` are upstream's, answered from the
`manifest.json` its own installer writes; a copy npm puts in our prefix has
none, so those three are answered here when the copy that would run is ours
and handed through when it is not.

Upstream's own installer is one line and brings its own Node 24 when the box
has none:

```sh
curl -fsSL https://openmcp.logicsrc.com/install.sh | sh
```

It writes a real script to `~/.local/bin/openmcp`, the directory this repo
links into, and `install-links.mjs` never takes over a real file even with
`--force`: on a box that ran it you will see `SKIP … is a real file` and keep
the copy you already had, which is the right outcome. Remove that script first
if you want this wrapper to own the name.

### `shorten`

Mints a short link on the Moshpit registry and prints it. `/f/<code>` answers a
Expand Down
158 changes: 158 additions & 0 deletions bin/openmcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* openmcp — the OpenMCP catalog from the terminal.
*
* A launcher for the `@logicsrc/openmcp` package, not a reimplementation of
* it. Everything you type is handed through untouched, so upstream's docs are
* the docs:
*
* openmcp relays what openmcp.logicsrc.com lists
* openmcp find "fetch a page" search every relay's tools
* openmcp call obscura.openmcp.logicsrc.com fetch_page '{"url":"https://example.com"}'
* openmcp add https://your.site register a relay you operate
* openmcp probe https://your.site what a catalog would find, without one
* openmcp serve run a catalog of your own
* openmcp --help all of it
*
* What this adds is that it is a command rather than an incantation: installed
* on first use into a prefix of ours, refreshed when you ask, and never
* fighting a global install for a name they both want. src/openmcp.ts says why
* that last one is not hypothetical.
*
* Ours, and therefore NOT passed through:
* --self-update reinstall the latest release
* --self-where say which copy would run, and from where
*
* Both are spelled --self-* because every plain word is upstream's to use.
*
* The exception is `update`, `uninstall` and `where`, and only when the copy
* that would run is the one we installed. Those three read a manifest written
* by upstream's curl installer, which a copy npm put in our prefix does not
* have, so upstream would answer "not installed by the installer" on a box
* where this command installed it and works.
*/

import { isMain } from '../src/is-main.ts';
import { spawnInherit } from '../src/codeburn.ts';
import {
MIN_NODE,
PACKAGE,
install,
installFailureMessage,
meetsNodeFloor,
nodeFloorMessage,
ownsInstallWord,
removeVendor,
resolveRunner,
vendorBin,
vendorRoot,
} from '../src/openmcp.ts';

const DESCRIBE: Record<string, string> = {
env: 'OPENMCP_BIN',
vendor: 'installed by cli-tools',
path: 'already on PATH',
missing: 'not installed yet',
};

/** Install, and say what happened. Shared by first run and every refresh. */
async function refresh(reason: string): Promise<string | null> {
const spec = process.env.OPENMCP_SPEC || `${PACKAGE}@latest`;
process.stderr.write(`openmcp: ${reason} ${spec}\n`);

const result = await install(spec);
if (!result.ok) {
process.stderr.write(`${installFailureMessage(vendorRoot())}\n`);
// An install that exited 0 and left the wrong version behind is the
// confusing case, so the reason goes out rather than just the failure.
if (result.note) process.stderr.write(` ${result.note}\n`);
return null;
}

process.stderr.write(
`openmcp: installed ${result.version ?? ''} with ${result.manager}\n`.replace(' ', ' '),
);
return vendorBin();
}

async function main(argv: string[]): Promise<number> {
const runner = resolveRunner();

if (argv[0] === '--self-where') {
process.stdout.write(
[
`${runner.file ?? '(none)'} ${DESCRIBE[runner.kind]}`,
`prefix: ${vendorRoot()}`,
`node: ${process.version}${meetsNodeFloor(process.version) ? '' : ` (below ${MIN_NODE})`}`,
'',
].join('\n'),
);
return 0;
}

// `where` against a copy we installed. Answered with the same lines as
// --self-where, because the honest answer to "where is it" is our prefix.
if (ownsInstallWord(runner.kind, argv[0]) && argv[0] === 'where') {
return main(['--self-where']);
}

if (ownsInstallWord(runner.kind, argv[0]) && argv[0] === 'uninstall') {
if (!argv.includes('--yes')) {
process.stdout.write(
[
`This will remove the copy cli-tools installed:`,
` ${vendorRoot()}`,
'',
'A catalog database you ran `openmcp serve` against is NOT touched.',
'',
'Run it for real with: openmcp uninstall --yes',
'',
].join('\n'),
);
return 0;
}
const removed = removeVendor();
process.stdout.write(
removed ? `Removed ${vendorRoot()}\n` : `Nothing to remove at ${vendorRoot()}\n`,
);
return 0;
}

const refreshing = argv[0] === '--self-update' || ownsInstallWord(runner.kind, argv[0]);
const args = refreshing ? argv.slice(1) : argv;

// The Node floor is a warning rather than a refusal. It is upstream's
// constraint, it may move, and being wrong about it should not be the thing
// that stops somebody using the tool.
if (!meetsNodeFloor(process.version)) {
process.stderr.write(`${nodeFloorMessage(process.version)}\n`);
}

let file = runner.file;

if (refreshing || runner.kind === 'missing') {
file = await refresh(refreshing ? 'updating' : 'first run, installing');
if (file === null) return 1;

// A bare `update` or `--self-update` is a maintenance run, not a launch.
if (refreshing && args.length === 0) return 0;
}

if (file === null) {
process.stderr.write(`${installFailureMessage(vendorRoot())}\n`);
return 1;
}

const code = await spawnInherit(file, args);
if (code === null) {
process.stderr.write(
`openmcp: could not start ${file}\n openmcp --self-update # reinstall it\n`,
);
return 1;
}
return code;
}

if (isMain(import.meta.url)) {
process.exit(await main(process.argv.slice(2)));
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.31.0",
"version": "0.32.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
Loading
Loading