moshcode 0.69.0 → 0.71.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/src/rates.mjs ADDED
@@ -0,0 +1,368 @@
1
+ // What an hour of agent time costs, written the way people say it out loud.
2
+ //
3
+ // /rate set acme $100/hour/agent/upto:4
4
+ //
5
+ // One line that carries four decisions: the price, the period it is charged
6
+ // for, the thing that is multiplied (an agent, a seat, a person), and the point
7
+ // past which you stop charging. Rate cards get written down as prose in a
8
+ // contract and then re-derived by hand at invoice time; this makes the prose
9
+ // itself the machine-readable form, so `/billing` does the arithmetic from the
10
+ // same words the client agreed to.
11
+ //
12
+ // Settlement currency is deliberately separate from the price. "$100/hour paid
13
+ // in USDC" is one rate with a preference, not two rates — the number in the
14
+ // contract does not change because the rail did.
15
+ import { loadBusiness, updateBusiness } from "./business-store.mjs";
16
+ import { resolveClient } from "./clients.mjs";
17
+ import { acid, ash, bone, err, info, ok, table } from "./ui.mjs";
18
+
19
+ /** Periods a rate can be charged per. `project` and `task` are flat fees. */
20
+ export const PERIODS = ["hour", "day", "week", "month", "project", "task"];
21
+
22
+ /** What gets multiplied. `flat` means the price is not per-anything. */
23
+ export const UNITS = ["agent", "seat", "person", "team", "flat"];
24
+
25
+ /** Hours in each period, for converting tracked time into billable units. */
26
+ export const PERIOD_HOURS = { hour: 1, day: 8, week: 40, month: 160 };
27
+
28
+ /** Currency symbols worth understanding, and the code each means. */
29
+ const SYMBOLS = { $: "USD", "€": "EUR", "£": "GBP", "¥": "JPY" };
30
+
31
+ /**
32
+ * Codes that are money in the ISO sense — the ones Intl can format.
33
+ *
34
+ * The list matters because everything *not* on it is formatted as a bare
35
+ * number and a ticker (`250 USDC`), which is how crypto amounts are read
36
+ * everywhere else. Intl.NumberFormat would happily accept "USDC" and then
37
+ * render "USDC 250.00", which is nobody's idea of a price.
38
+ */
39
+ const FIAT = new Set(["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "SEK", "NOK", "NZD"]);
40
+
41
+ /** Tickers we accept without a symbol. Not exhaustive — an unknown code is kept as typed. */
42
+ const KNOWN_CRYPTO = new Set(["SOL", "USDC", "USDT", "BTC", "ETH", "MATIC", "BNB", "XRP", "DOGE", "LTC", "AVAX", "ADA"]);
43
+
44
+ /**
45
+ * Stablecoins pegged 1:1 to the dollar.
46
+ *
47
+ * They matter because a gateway's invoice usually carries a fiat amount and a
48
+ * separate settlement ticker. "250 USDC" is a $250 invoice settled in USDC and
49
+ * can be expressed that way honestly; "1.5 SOL" is not $1.50 or $150 or any
50
+ * other number we know, and pretending otherwise would put a wrong figure in
51
+ * front of a client. So the peg is written down rather than assumed.
52
+ */
53
+ const DOLLAR_PEGGED = new Set(["USDC", "USDT", "DAI", "PYUSD", "USDP", "TUSD"]);
54
+
55
+ export function isFiat(code) {
56
+ return FIAT.has(String(code || "").toUpperCase());
57
+ }
58
+
59
+ export function isDollarPegged(code) {
60
+ return DOLLAR_PEGGED.has(String(code || "").toUpperCase());
61
+ }
62
+
63
+ /**
64
+ * Render an amount the way its currency is normally written.
65
+ *
66
+ * Fiat goes through Intl (symbol, grouping, two decimals); anything else is
67
+ * `<amount> <TICKER>`, trimmed of trailing zeros — 0.5 SOL is 0.5 SOL, not
68
+ * 0.50 SOL, and a USDC total of 1250 should not read as 1,250.00 USDC.
69
+ */
70
+ export function formatMoney(amount, currency = "USD") {
71
+ const code = String(currency || "USD").toUpperCase();
72
+ const n = Number(amount);
73
+ if (!Number.isFinite(n)) return `— ${code}`;
74
+ if (isFiat(code)) {
75
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(n);
76
+ }
77
+ // Up to 8 decimals so a BTC figure survives, but no padding: crypto amounts
78
+ // are read as quantities, and "0.10000000 BTC" hides the number in zeros.
79
+ const fixed = n.toFixed(8).replace(/\.?0+$/, "");
80
+ return `${fixed} ${code}`;
81
+ }
82
+
83
+ function parseAmount(token) {
84
+ const raw = String(token || "").trim();
85
+ if (!raw) return null;
86
+ // "$100", "100USD", "100 USD", "USDC250", "0.5 SOL" — one shape, read from
87
+ // both ends, because all four spellings turn up in the same conversation.
88
+ const symbol = SYMBOLS[raw[0]];
89
+ const body = symbol ? raw.slice(1) : raw;
90
+ const m = body.match(/^([a-z]{2,5})?\s*([0-9][0-9_,]*(?:\.[0-9]+)?)\s*([a-z]{2,5})?$/i);
91
+ if (!m) return null;
92
+ const amount = Number(m[2].replace(/[_,]/g, ""));
93
+ if (!Number.isFinite(amount) || amount < 0) return null;
94
+ const code = (m[1] || m[3] || "").toUpperCase();
95
+ if (code && !isFiat(code) && !KNOWN_CRYPTO.has(code) && !/^[A-Z]{3,5}$/.test(code)) return null;
96
+ return { amount, currency: code || symbol || "USD" };
97
+ }
98
+
99
+ /**
100
+ * Parse a rate spec into `{ amount, currency, per, unit, cap, min }`, or throw.
101
+ *
102
+ * The grammar is positional only in its first segment (the price); everything
103
+ * after it is recognised by what it says rather than where it sits, so
104
+ * `$100/agent/hour` and `$100/hour/agent` mean the same thing. People do not
105
+ * remember an order they were never told.
106
+ */
107
+ export function parseRate(spec) {
108
+ const text = String(spec ?? "").trim();
109
+ if (!text) throw new Error("a rate looks like $100/hour/agent/upto:4");
110
+ const parts = text.split("/").map((p) => p.trim()).filter(Boolean);
111
+ const price = parseAmount(parts.shift());
112
+ if (!price) throw new Error(`can't read a price out of ${JSON.stringify(text)} — try $100/hour/agent`);
113
+
114
+ const rate = { ...price, per: "hour", unit: "flat", cap: null, min: null };
115
+ let sawPeriod = false;
116
+ for (const part of parts) {
117
+ const [key, value] = part.split(":").map((s) => s.trim().toLowerCase());
118
+ if (["upto", "up-to", "max", "cap"].includes(key)) {
119
+ const n = Number(value);
120
+ if (!Number.isInteger(n) || n < 1) throw new Error(`upto: wants a whole number of units, got ${JSON.stringify(value ?? "")}`);
121
+ rate.cap = n;
122
+ continue;
123
+ }
124
+ if (["min", "minimum", "floor"].includes(key)) {
125
+ const n = Number(value);
126
+ if (!Number.isFinite(n) || n < 0) throw new Error(`min: wants a number, got ${JSON.stringify(value ?? "")}`);
127
+ rate.min = n;
128
+ continue;
129
+ }
130
+ const word = key.replace(/s$/, "");
131
+ if (PERIODS.includes(word)) { rate.per = word; sawPeriod = true; continue; }
132
+ if (word === "hr") { rate.per = "hour"; sawPeriod = true; continue; }
133
+ if (word === "mo") { rate.per = "month"; sawPeriod = true; continue; }
134
+ if (word === "yr" || word === "year") { rate.per = "month"; rate.amount /= 12; sawPeriod = true; continue; }
135
+ if (UNITS.includes(word)) { rate.unit = word; continue; }
136
+ if (word === "head" || word === "dev" || word === "engineer") { rate.unit = "person"; continue; }
137
+ throw new Error(`don't know what ${JSON.stringify(part)} means in a rate — periods: ${PERIODS.join("/")}, units: ${UNITS.join("/")}, or upto:N`);
138
+ }
139
+ // A flat fee with no period stated is a project fee, not an hourly one: "$5000
140
+ // for the project" is how it is written, and defaulting it to per-hour would
141
+ // silently multiply the invoice by every hour tracked.
142
+ if (!sawPeriod && rate.unit === "flat" && rate.cap === null) rate.per = "hour";
143
+ if (rate.cap !== null && rate.unit === "flat") {
144
+ throw new Error("upto: caps a unit, so say what it caps — $100/hour/agent/upto:4");
145
+ }
146
+ return rate;
147
+ }
148
+
149
+ /** The canonical spelling of a parsed rate — round-trips through parseRate. */
150
+ export function formatRate(rate) {
151
+ if (!rate) return "—";
152
+ const bits = [formatMoney(rate.amount, rate.currency), rate.per];
153
+ if (rate.unit && rate.unit !== "flat") bits.push(rate.unit);
154
+ if (rate.cap) bits.push(`upto:${rate.cap}`);
155
+ if (rate.min) bits.push(`min:${rate.min}`);
156
+ return bits.join("/");
157
+ }
158
+
159
+ /** How a rate reads in a sentence, for confirmations and invoices. */
160
+ export function describeRate(rate) {
161
+ if (!rate) return "no rate set";
162
+ const price = formatMoney(rate.amount, rate.currency);
163
+ const per = rate.per === "project" || rate.per === "task" ? `per ${rate.per}` : `per ${rate.per}`;
164
+ const unit = rate.unit && rate.unit !== "flat" ? ` per ${rate.unit}` : "";
165
+ const cap = rate.cap ? `, billing at most ${rate.cap} ${rate.unit}${rate.cap === 1 ? "" : "s"}` : "";
166
+ const settle = settlementNote(rate);
167
+ return `${price} ${per}${unit}${cap}${settle ? ` (${settle})` : ""}`;
168
+ }
169
+
170
+ /** "prefers SOL or USDC, fiat accepted" — or "" when nothing was stated. */
171
+ export function settlementNote(rate) {
172
+ const prefer = rate?.prefer || [];
173
+ const accept = rate?.accept || [];
174
+ if (!prefer.length && !accept.length) return "";
175
+ const bits = [];
176
+ if (prefer.length) bits.push(`prefers ${prefer.join(" or ")}`);
177
+ if (accept.length) bits.push(`${accept.join(", ")} accepted`);
178
+ return bits.join(", ");
179
+ }
180
+
181
+ /**
182
+ * The rate that applies to a client: their own, else the default, else null.
183
+ *
184
+ * A default rate is the common case for a solo shop — one number, everybody
185
+ * pays it — and a per-client override is what happens the first time somebody
186
+ * negotiates. Neither should require restating the other.
187
+ */
188
+ export function rateFor(business, clientId) {
189
+ const rates = business?.rates || {};
190
+ if (clientId && rates[clientId]) return { ...rates[clientId], source: clientId };
191
+ if (rates.default) return { ...rates.default, source: "default" };
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Which key a rate is filed under: `default`, or a real client id.
197
+ *
198
+ * Rates are looked up by client id, so a rate filed under a name that is not
199
+ * one — `/rate set acme …` when the client is `acme-inc` — is a rate that never
200
+ * applies to anything. It fails silently at exactly the wrong moment: the
201
+ * invoice comes out at the default rate and looks fine. So the target is
202
+ * resolved the same way `/timer on` and `/billing` resolve a client, and an
203
+ * unknown one is refused rather than filed somewhere nothing will read it.
204
+ */
205
+ export function resolveRateTarget(business, token) {
206
+ const want = String(token ?? "").trim().toLowerCase();
207
+ if (!want || want === "default" || want === "*") return { ok: true, id: "default" };
208
+ return resolveClient(business, want);
209
+ }
210
+
211
+ /** Words that are categories rather than tickers, and stay lowercase. */
212
+ const SETTLEMENT_WORDS = new Set(["fiat", "crypto", "stablecoin", "any", "cash"]);
213
+
214
+ /** Split `--prefer sol,usdc --accept fiat` off an argv, returning both halves. */
215
+ export function splitSettlement(argv) {
216
+ const rest = [];
217
+ const out = { prefer: [], accept: [] };
218
+ for (let i = 0; i < argv.length; i += 1) {
219
+ const arg = argv[i];
220
+ if (arg === "--prefer" || arg === "--accept") {
221
+ const key = arg.slice(2);
222
+ const value = argv[i + 1];
223
+ if (value && !value.startsWith("--")) {
224
+ out[key] = value.split(",").map((s) => s.trim()).filter(Boolean)
225
+ .map((s) => (SETTLEMENT_WORDS.has(s.toLowerCase()) ? s.toLowerCase() : s.toUpperCase()));
226
+ i += 1;
227
+ }
228
+ continue;
229
+ }
230
+ rest.push(arg);
231
+ }
232
+ return { argv: rest, settlement: out };
233
+ }
234
+
235
+ const USAGE = [
236
+ "usage: /rate set <client|default> <spec> [--prefer SOL,USDC] [--accept fiat]",
237
+ " /rate [list] [--json] · /rate show <client> · /rate rm <client>",
238
+ " spec: $100/hour/agent/upto:4 · 0.5 SOL/day · $5000/project · 250 USDC/task",
239
+ ];
240
+
241
+ /**
242
+ * `/rate` and `/rates`.
243
+ *
244
+ * `set:` with a colon is accepted because that is how it was first written down
245
+ * (`/rates set: $100/hour/agent/upto:4`), and refusing punctuation somebody
246
+ * already typed teaches them nothing.
247
+ */
248
+ export function rateCommand(argv = [], { write = console.log } = {}) {
249
+ const args = [...argv];
250
+ const json = args.includes("--json");
251
+ const positional = args.filter((a) => a !== "--json");
252
+ const verb = (positional[0] || "list").replace(/:$/, "").toLowerCase();
253
+
254
+ if (["list", "ls", ""].includes(verb) && positional.length <= 1) {
255
+ const { rates } = loadBusiness();
256
+ const names = Object.keys(rates).sort();
257
+ if (json) { write(JSON.stringify(rates, null, 2)); return 0; }
258
+ if (!names.length) {
259
+ write(info("no rates yet."));
260
+ write(` ${acid("/rate set default $100/hour/agent/upto:4")}`);
261
+ return 0;
262
+ }
263
+ write(table(
264
+ names.map((name) => [bone(name), acid(formatRate(rates[name])), ash(settlementNote(rates[name]))]),
265
+ { columns: ["who", "rate", "settlement"], indent: 2 },
266
+ ));
267
+ return 0;
268
+ }
269
+
270
+ if (verb === "set") {
271
+ const { argv: words, settlement } = splitSettlement(positional.slice(1));
272
+ if (!words.length) { USAGE.forEach(write); return 1; }
273
+ // `/rate set $100/hour` with no target is the default rate — the shape
274
+ // somebody types when they have exactly one price and no client list yet.
275
+ let target = "default";
276
+ let spec = words.join(" ");
277
+ if (words.length > 1 && !parseSafely(words[0])) {
278
+ const found = resolveRateTarget(loadBusiness(), words[0]);
279
+ if (!found.ok) {
280
+ write(err(`no client ${JSON.stringify(words[0])} — ${acid(`/client create ${words[0]}`)} first, or set the ${bone("default")} rate`));
281
+ return 1;
282
+ }
283
+ target = found.id;
284
+ spec = words.slice(1).join(" ");
285
+ }
286
+ let rate;
287
+ try { rate = parseRate(spec); }
288
+ catch (e) { write(err(String(e.message || e))); write(` ${ash(USAGE[2])}`); return 1; }
289
+ if (settlement.prefer.length) rate.prefer = settlement.prefer;
290
+ if (settlement.accept.length) rate.accept = settlement.accept;
291
+ updateBusiness((data) => { data.rates[target] = rate; });
292
+ write(ok(`${bone(target)} → ${acid(formatRate(rate))}`));
293
+ write(` ${ash(describeRate(rate))}`);
294
+ return 0;
295
+ }
296
+
297
+ if (["show", "get"].includes(verb)) {
298
+ const business = loadBusiness();
299
+ const found = resolveRateTarget(business, positional[1] || "default");
300
+ const who = found.ok ? found.id : String(positional[1]).toLowerCase();
301
+ const rate = rateFor(business, who);
302
+ if (!rate) { write(err(`no rate for ${JSON.stringify(who)} and no default — /rate set ${who} $100/hour/agent`)); return 1; }
303
+ if (json) { write(JSON.stringify(rate, null, 2)); return 0; }
304
+ write(` ${bone(who)} ${acid(formatRate(rate))}${rate.source !== who ? ash(` (from ${rate.source})`) : ""}`);
305
+ write(` ${ash(describeRate(rate))}`);
306
+ return 0;
307
+ }
308
+
309
+ if (["rm", "remove", "delete", "unset"].includes(verb)) {
310
+ if (!positional[1]) { write(err("usage: /rate rm <client|default>")); return 1; }
311
+ const found = resolveRateTarget(loadBusiness(), positional[1]);
312
+ // A rate can outlive the client it was filed under (`/client rm` drops the
313
+ // rate, but a hand-edited file need not have), so an unresolved name still
314
+ // gets to name a key here — this verb only ever removes.
315
+ const who = found.ok ? found.id : String(positional[1]).toLowerCase();
316
+ const existed = updateBusiness((data) => {
317
+ const had = Object.hasOwn(data.rates, who);
318
+ delete data.rates[who];
319
+ return had;
320
+ });
321
+ write(existed ? ok(`dropped the rate for ${bone(who)}`) : info(`no rate for ${JSON.stringify(who)}`));
322
+ return existed ? 0 : 1;
323
+ }
324
+
325
+ // A bare `/rate $100/hour` reads as "set my rate", and that is the only other
326
+ // thing the word can mean here.
327
+ if (parseSafely(positional.join(" "))) return rateCommand(["set", ...positional], { write });
328
+
329
+ write(err(`unknown /rate verb ${JSON.stringify(verb)}`));
330
+ USAGE.forEach(write);
331
+ return 1;
332
+ }
333
+
334
+ function parseSafely(spec) {
335
+ try { return parseRate(spec); }
336
+ catch { return null; }
337
+ }
338
+
339
+ /**
340
+ * What one tracked stretch of work is worth: `{ hours, units, amount, currency, flat }`.
341
+ *
342
+ * The arithmetic everybody does in their head and gets wrong once a quarter.
343
+ * Two things make it more than a multiplication:
344
+ *
345
+ * - the cap. `$100/hour/agent/upto:4` means four agents cost four hundred an
346
+ * hour and *so do six* — the cap is the promise that made the client sign,
347
+ * and it has to be applied here rather than remembered at invoice time.
348
+ * - the floor. `min:1` bills a fifteen-minute call as an hour, which is the
349
+ * other half of the same contract.
350
+ *
351
+ * A flat fee (`$5000/project`) returns `flat: true` and no amount: it is not
352
+ * earned per entry, so it is the invoice's job to add it once. Returning zero
353
+ * here would quietly bill nothing for a project that was fully delivered.
354
+ */
355
+ export function chargeFor({ seconds = 0, agents = 1 } = {}, rate) {
356
+ if (!rate) return null;
357
+ const currency = rate.currency || "USD";
358
+ if (rate.per === "project") return { hours: seconds / 3600, units: 1, amount: null, currency, flat: true, per: rate.per };
359
+ const units = rate.unit === "flat" ? 1 : Math.max(1, Math.min(Number(agents) || 1, rate.cap ?? Infinity));
360
+ if (rate.per === "task") {
361
+ return { hours: seconds / 3600, units, amount: rate.amount * units, currency, flat: false, per: rate.per };
362
+ }
363
+ const perHours = PERIOD_HOURS[rate.per] ?? 1;
364
+ let hours = seconds / 3600;
365
+ if (rate.min) hours = Math.max(hours, rate.min * perHours);
366
+ const periods = hours / perHours;
367
+ return { hours: seconds / 3600, billedHours: hours, units, amount: rate.amount * periods * units, currency, flat: false, per: rate.per };
368
+ }
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
- return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(claudeSkillsDir(), name)] };
53
- case "kimi":
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
- return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(kimiSkillsDir(), name)] };
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
- results.push({ key: item.key, status: ranOk(r) ? "installed" : "failed", code: r.code, signal: r.signal ?? null });
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
  }