moshcode 0.68.0 → 0.70.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 +36 -0
- package/bin/moshcode.mjs +5 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +37 -0
- package/src/completion.mjs +3 -3
- package/src/dns-filter-cli.mjs +407 -0
- package/src/dns-filter.mjs +500 -0
- package/src/dns.mjs +89 -2
- package/src/integrations.mjs +5 -2
- package/src/shorten.mjs +212 -0
- package/src/skills.mjs +78 -6
- package/src/tui.mjs +7 -0
package/src/shorten.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// `/shorten <url>` — mint a short link on the pit, and get `/f/<code>` back.
|
|
2
|
+
//
|
|
3
|
+
// The pit hands out long URLs constantly: a session mirror, an approval, a
|
|
4
|
+
// name's site, a release asset. The place they get pasted is a terminal, a chat
|
|
5
|
+
// line, a slide or a QR code, where a 140-character URL wraps and breaks in
|
|
6
|
+
// half. So this asks the registry for a short one and prints it.
|
|
7
|
+
//
|
|
8
|
+
// Everything here is one HTTP call to pit.moshcode.sh — the registry owns the
|
|
9
|
+
// codes, because a short link that only worked from the laptop that minted it
|
|
10
|
+
// would not be a link at all. The command is thin on purpose: parse, call,
|
|
11
|
+
// print, and be honest about what came back.
|
|
12
|
+
//
|
|
13
|
+
// Authenticated, always. An anonymous shortener is an open redirector with a
|
|
14
|
+
// database, which is the thing phishing kits are built out of; the account is
|
|
15
|
+
// what makes a link revocable and its owner findable.
|
|
16
|
+
|
|
17
|
+
import { loadCreds } from "./auth.mjs";
|
|
18
|
+
import { acid, ash, bone, err, info, ok } from "./ui.mjs";
|
|
19
|
+
|
|
20
|
+
/** Where the codes live. The registry, not the app — see the note above. */
|
|
21
|
+
export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh";
|
|
22
|
+
|
|
23
|
+
function registryBase(env = process.env) {
|
|
24
|
+
return String(env.MOSHPIT_REGISTRY || env.MOSHCODE_PIT || DEFAULT_REGISTRY_BASE).replace(/\/+$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The token `moshcode login` wrote, or one set in the environment. */
|
|
28
|
+
export function apiToken(env = process.env, creds = loadCreds) {
|
|
29
|
+
return env.MOSHCODE_API_KEY || creds()?.token || "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Split `/shorten` into what it was asked to do.
|
|
34
|
+
*
|
|
35
|
+
* A bare URL is the whole point of the command, so it needs no verb: `/shorten
|
|
36
|
+
* https://…` shortens, and only `list` and `rm` are spelled out. Flags are
|
|
37
|
+
* pulled out first so `--name` can sit anywhere, which is where people put it.
|
|
38
|
+
*
|
|
39
|
+
* @param {string[]} argv
|
|
40
|
+
*/
|
|
41
|
+
export function parseArgs(argv = []) {
|
|
42
|
+
const args = (Array.isArray(argv) ? argv : []).map(String);
|
|
43
|
+
const json = args.includes("--json");
|
|
44
|
+
let name = null;
|
|
45
|
+
const positional = [];
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
48
|
+
const arg = args[i];
|
|
49
|
+
if (arg === "--json") continue;
|
|
50
|
+
if (arg === "--name" || arg === "-n") { name = args[i + 1] ?? null; i += 1; continue; }
|
|
51
|
+
if (arg.startsWith("--name=")) { name = arg.slice("--name=".length); continue; }
|
|
52
|
+
positional.push(arg);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const first = (positional[0] || "").toLowerCase();
|
|
56
|
+
if (!positional.length) return { verb: "help", json, name };
|
|
57
|
+
if (first === "list" || first === "ls") return { verb: "list", json, name };
|
|
58
|
+
if (first === "rm" || first === "delete" || first === "del") {
|
|
59
|
+
return { verb: "rm", code: positional[1] || "", json, name };
|
|
60
|
+
}
|
|
61
|
+
// Anything else is the URL. Deliberately not validated here: the registry has
|
|
62
|
+
// the one implementation of what may be shortened (lib/moshpit-links.mjs),
|
|
63
|
+
// and a second, looser copy in the client is how the two drift apart.
|
|
64
|
+
return { verb: "shorten", url: positional[0], json, name };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One authenticated call to the registry, with the failures a person can act on.
|
|
69
|
+
*
|
|
70
|
+
* Every non-2xx is turned into `{ ok: false, error }` rather than thrown: this
|
|
71
|
+
* runs at a prompt someone is sitting in front of, and a stack trace over a
|
|
72
|
+
* 401 tells them nothing about the `moshcode login` that fixes it.
|
|
73
|
+
*/
|
|
74
|
+
async function call(path, { method = "GET", body = null, token, base, fetchImpl = fetch } = {}) {
|
|
75
|
+
if (!token) {
|
|
76
|
+
return { ok: false, needsAuth: true, error: "not logged in — run `/login` first" };
|
|
77
|
+
}
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await fetchImpl(`${base}${path}`, {
|
|
81
|
+
method,
|
|
82
|
+
headers: {
|
|
83
|
+
authorization: `Bearer ${token}`,
|
|
84
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
85
|
+
},
|
|
86
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { ok: false, error: `${base} unreachable: ${error.message}` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let payload = null;
|
|
93
|
+
try { payload = await response.json(); } catch { payload = null; }
|
|
94
|
+
|
|
95
|
+
if (response.status === 401) {
|
|
96
|
+
return { ok: false, needsAuth: true, error: "the registry rejected the credentials — run `/login`" };
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
return { ok: false, error: payload?.error || `the registry said ${response.status}` };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, status: response.status, body: payload ?? {} };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mint one. Returns the link the registry stored, existing or new. */
|
|
105
|
+
export async function shorten(url, {
|
|
106
|
+
name = null, env = process.env, token = apiToken(env), fetchImpl = fetch,
|
|
107
|
+
} = {}) {
|
|
108
|
+
return call("/api/moshpit/links", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
body: { url, ...(name ? { name } : {}) },
|
|
111
|
+
token,
|
|
112
|
+
base: registryBase(env),
|
|
113
|
+
fetchImpl,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What this account has minted. */
|
|
118
|
+
export async function listLinks({ env = process.env, token = apiToken(env), fetchImpl = fetch } = {}) {
|
|
119
|
+
return call("/api/moshpit/links", { token, base: registryBase(env), fetchImpl });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Take one down. */
|
|
123
|
+
export async function removeLink(code, { env = process.env, token = apiToken(env), fetchImpl = fetch } = {}) {
|
|
124
|
+
return call(`/api/moshpit/links/${encodeURIComponent(code)}`, {
|
|
125
|
+
method: "DELETE", token, base: registryBase(env), fetchImpl,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How to run this, spelled the way the caller reached it.
|
|
131
|
+
*
|
|
132
|
+
* The pit writes its verbs with a slash and the CLI does not, and printing the
|
|
133
|
+
* wrong one is a usage line that does not work when pasted back — `/games` does
|
|
134
|
+
* the same thing for the same reason.
|
|
135
|
+
*/
|
|
136
|
+
function usage(out, prefix) {
|
|
137
|
+
const lines = [
|
|
138
|
+
["<url>", "mint a short link — /f/<code> on the pit"],
|
|
139
|
+
["<url> --name <name>", "file it under a moshpit name you hold"],
|
|
140
|
+
["list", "every link you have minted, newest first"],
|
|
141
|
+
["rm <code>", "take one down"],
|
|
142
|
+
].map(([args, text]) => [`${prefix} ${args}`, text]);
|
|
143
|
+
// The column is measured rather than fixed: `moshcode shorten` is twice as
|
|
144
|
+
// wide as `/shorten`, and a hardcoded one leaves the longest line unaligned
|
|
145
|
+
// in whichever spelling was not the one it was chosen for.
|
|
146
|
+
const width = Math.max(...lines.map(([invocation]) => invocation.length)) + 2;
|
|
147
|
+
|
|
148
|
+
out(info("usage:"));
|
|
149
|
+
for (const [invocation, text] of lines) {
|
|
150
|
+
out(` ${acid(invocation)}${ash(" ".repeat(width - invocation.length) + text)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `/shorten` in the pit, and `moshcode shorten` on the command line.
|
|
156
|
+
*
|
|
157
|
+
* @param {string[]} argv
|
|
158
|
+
* @param {{out?: (s: string) => void, err?: (s: string) => void, env?: object,
|
|
159
|
+
* token?: string, prefix?: string, fetchImpl?: typeof fetch}} [io]
|
|
160
|
+
* @returns {Promise<number>} exit code
|
|
161
|
+
*/
|
|
162
|
+
export async function shortenCommand(argv = [], io = {}) {
|
|
163
|
+
const out = io.out || ((s) => console.log(s));
|
|
164
|
+
const say = io.err || ((s) => console.error(s));
|
|
165
|
+
const env = io.env || process.env;
|
|
166
|
+
const token = io.token ?? apiToken(env);
|
|
167
|
+
const prefix = io.prefix || "/shorten";
|
|
168
|
+
const fetchImpl = io.fetchImpl || fetch;
|
|
169
|
+
const parsed = parseArgs(argv);
|
|
170
|
+
|
|
171
|
+
if (parsed.verb === "help") {
|
|
172
|
+
usage(out, prefix);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (parsed.verb === "list") {
|
|
177
|
+
const result = await listLinks({ env, token, fetchImpl });
|
|
178
|
+
if (!result.ok) { say(err(result.error)); return 1; }
|
|
179
|
+
const links = result.body.links || [];
|
|
180
|
+
if (parsed.json) { out(JSON.stringify(links, null, 2)); return 0; }
|
|
181
|
+
if (!links.length) {
|
|
182
|
+
out(info(`no short links yet — ${prefix} <url> mints one`));
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
for (const link of links) {
|
|
186
|
+
const hits = `${link.hits} hit${link.hits === 1 ? "" : "s"}`;
|
|
187
|
+
out(` ${acid(link.short)} ${ash("→")} ${bone(link.url)}`);
|
|
188
|
+
out(` ${ash(`${hits}${link.name ? ` · ${link.name}` : ""}`)}`);
|
|
189
|
+
}
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (parsed.verb === "rm") {
|
|
194
|
+
if (!parsed.code) { say(err(`usage: ${prefix} rm <code>`)); return 1; }
|
|
195
|
+
const result = await removeLink(parsed.code, { env, token, fetchImpl });
|
|
196
|
+
if (!result.ok) { say(err(result.error)); return 1; }
|
|
197
|
+
if (parsed.json) { out(JSON.stringify(result.body, null, 2)); return 0; }
|
|
198
|
+
out(ok(`took down /f/${result.body.code ?? parsed.code}`));
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const result = await shorten(parsed.url, { name: parsed.name, env, token, fetchImpl });
|
|
203
|
+
if (!result.ok) { say(err(result.error)); return 1; }
|
|
204
|
+
if (parsed.json) { out(JSON.stringify(result.body, null, 2)); return 0; }
|
|
205
|
+
|
|
206
|
+
// Say when a code came back rather than being made. Shortening is idempotent
|
|
207
|
+
// per account, and someone who ran it twice should see why the code is the
|
|
208
|
+
// one they already have instead of wondering whether the second call worked.
|
|
209
|
+
out(ok(`${acid(result.body.short)} ${ash("→")} ${bone(result.body.url)}`));
|
|
210
|
+
if (result.body.created === false) out(info("already shortened — same code as last time"));
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
package/src/skills.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Install Agent Skills across every engine that has a skills primitive, from one
|
|
2
2
|
// source (a git URL or local path). Gemini installs natively; Claude clones the
|
|
3
3
|
// source into its personal skills dir. See prd/0003.
|
|
4
|
+
import fs from "node:fs";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
|
|
@@ -38,6 +39,61 @@ export function skillName(source, override) {
|
|
|
38
39
|
return named(sanitize(path.basename(path.resolve(raw)))) || "skill";
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* What a freshly cloned skill source actually contains.
|
|
44
|
+
*
|
|
45
|
+
* A repository is not always one skill. `SKILL.md` at the root is the common
|
|
46
|
+
* shape and the one this module assumed. But a repository can equally be a
|
|
47
|
+
* *collection* — subdirectories that each hold a `SKILL.md` — and every engine
|
|
48
|
+
* that discovers skills by scanning looks exactly one level deep. Cloning a
|
|
49
|
+
* collection whole therefore lands every skill one level too deep, where
|
|
50
|
+
* nothing will ever find them, while `git clone` still exits 0 and the install
|
|
51
|
+
* reports success. Detecting the shape is what makes that failure impossible.
|
|
52
|
+
*/
|
|
53
|
+
export function skillCollection(dir) {
|
|
54
|
+
if (!fs.existsSync(dir)) return { kind: "empty", names: [] };
|
|
55
|
+
if (fs.existsSync(path.join(dir, "SKILL.md"))) return { kind: "single", names: [] };
|
|
56
|
+
const names = fs
|
|
57
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
58
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
59
|
+
.filter((d) => fs.existsSync(path.join(dir, d.name, "SKILL.md")))
|
|
60
|
+
.map((d) => d.name)
|
|
61
|
+
.sort();
|
|
62
|
+
return names.length ? { kind: "collection", names } : { kind: "empty", names: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Settle a fresh clone into the shape the engine scans, and report what it was.
|
|
67
|
+
*
|
|
68
|
+
* `single` is left exactly as cloned. `collection` has each skill moved up
|
|
69
|
+
* beside its siblings and the wrapper removed — the wrapper holds the
|
|
70
|
+
* repository's own README, tooling and CI, none of which is a skill. `empty`
|
|
71
|
+
* removes the clone rather than leaving a directory that can never resolve.
|
|
72
|
+
*
|
|
73
|
+
* A skill whose name is already taken is left alone and reported in `kept`:
|
|
74
|
+
* this runs inside the user's real skills directory, so a name collision must
|
|
75
|
+
* never silently replace a skill they already had.
|
|
76
|
+
*/
|
|
77
|
+
export function settleSkillClone(dir) {
|
|
78
|
+
const { kind, names } = skillCollection(dir);
|
|
79
|
+
if (kind === "single") return { kind, installed: [path.basename(dir)], kept: [] };
|
|
80
|
+
if (kind === "empty") {
|
|
81
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
return { kind, installed: [], kept: [] };
|
|
83
|
+
}
|
|
84
|
+
const parent = path.dirname(dir);
|
|
85
|
+
const installed = [];
|
|
86
|
+
const kept = [];
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const dest = path.join(parent, name);
|
|
89
|
+
if (fs.existsSync(dest)) { kept.push(name); continue; }
|
|
90
|
+
fs.renameSync(path.join(dir, name), dest);
|
|
91
|
+
installed.push(name);
|
|
92
|
+
}
|
|
93
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
return { kind, installed, kept };
|
|
95
|
+
}
|
|
96
|
+
|
|
41
97
|
/**
|
|
42
98
|
* The install action for one engine: a spawnable { cmd, args } or a { skip }
|
|
43
99
|
* reason. `spec: { source, name }`.
|
|
@@ -47,13 +103,19 @@ export function skillInstallAction(key, spec) {
|
|
|
47
103
|
switch (key) {
|
|
48
104
|
case "gemini":
|
|
49
105
|
return { cmd: "gemini", args: ["skills", "install", source, "--scope", "user"] };
|
|
50
|
-
case "claude":
|
|
106
|
+
case "claude": {
|
|
51
107
|
// Claude has no `skill install`; clone the source into its skills dir.
|
|
52
|
-
|
|
53
|
-
|
|
108
|
+
// `settle` is the cloned path: a scanning engine needs the clone resolved
|
|
109
|
+
// into one-level-deep skills afterwards (see settleSkillClone).
|
|
110
|
+
const dir = path.join(claudeSkillsDir(), name);
|
|
111
|
+
return { cmd: "git", args: ["clone", "--depth", "1", source, dir], settle: dir };
|
|
112
|
+
}
|
|
113
|
+
case "kimi": {
|
|
54
114
|
// Kimi Code discovers skills by scanning directories, with no install
|
|
55
115
|
// command of its own — so clone into the one it scans, as Claude does.
|
|
56
|
-
|
|
116
|
+
const dir = path.join(kimiSkillsDir(), name);
|
|
117
|
+
return { cmd: "git", args: ["clone", "--depth", "1", source, dir], settle: dir };
|
|
118
|
+
}
|
|
57
119
|
default:
|
|
58
120
|
return { skip: "no skills primitive" };
|
|
59
121
|
}
|
|
@@ -81,13 +143,23 @@ export function planSkillInstall(spec, { installedSet } = {}) {
|
|
|
81
143
|
* [{ key, status: "installed"|"skipped"|"failed"|"not-installed", reason? }].
|
|
82
144
|
* `run` is injectable for tests.
|
|
83
145
|
*/
|
|
84
|
-
export async function runSkillInstall(plan, { run = runCmd } = {}) {
|
|
146
|
+
export async function runSkillInstall(plan, { run = runCmd, settle = settleSkillClone } = {}) {
|
|
85
147
|
const results = [];
|
|
86
148
|
for (const item of plan) {
|
|
87
149
|
if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
|
|
88
150
|
if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
|
|
89
151
|
const r = await run(item.cmd, item.args);
|
|
90
|
-
|
|
152
|
+
const base = { key: item.key, code: r.code, signal: r.signal ?? null };
|
|
153
|
+
if (!ranOk(r)) { results.push({ ...base, status: "failed" }); continue; }
|
|
154
|
+
if (!item.settle) { results.push({ ...base, status: "installed" }); continue; }
|
|
155
|
+
|
|
156
|
+
// The clone succeeded, which is not the same as a skill being installed.
|
|
157
|
+
const { kind, installed, kept } = settle(item.settle);
|
|
158
|
+
if (kind === "empty") {
|
|
159
|
+
results.push({ ...base, status: "failed", reason: "no SKILL.md at the root or in any subdirectory" });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
results.push({ ...base, status: "installed", kind, skills: installed, ...(kept.length ? { kept } : {}) });
|
|
91
163
|
}
|
|
92
164
|
return results;
|
|
93
165
|
}
|
package/src/tui.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } fr
|
|
|
11
11
|
import { TOOLS, resolveTool, toolStatus, openTool, readToolAliases, toolsWithAliases } from "./tools.mjs";
|
|
12
12
|
import { tradeArgs, tradeUsage } from "./trade.mjs";
|
|
13
13
|
import { postSocial, socialRoster } from "./socials.mjs";
|
|
14
|
+
import { shortenCommand } from "./shorten.mjs";
|
|
14
15
|
import { runUpgrade } from "./upgrade.mjs";
|
|
15
16
|
import { locate, tilde } from "./pwd.mjs";
|
|
16
17
|
import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
|
|
@@ -1110,6 +1111,12 @@ export async function tui() {
|
|
|
1110
1111
|
rl = mkrl();
|
|
1111
1112
|
continue;
|
|
1112
1113
|
}
|
|
1114
|
+
// `/shorten` renders in the pit rather than handing the terminal over: it
|
|
1115
|
+
// is one call to the registry and one line back, the same as `/stocks`.
|
|
1116
|
+
if (cmd === "shorten" || cmd === "short" || cmd === "link") {
|
|
1117
|
+
await shortenCommand(rest, { prefix: `/${cmd}` });
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1113
1120
|
if (cmd === "socials" || cmd === "social") {
|
|
1114
1121
|
printSocials();
|
|
1115
1122
|
continue;
|