moshcode 0.69.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -253,7 +253,7 @@ export async function mcpCommand(tokens, { run, installedSet } = {}) {
253
253
  }
254
254
 
255
255
  /** Run `/skill …`. `tokens` are the words after `skill`. `run`/`installedSet` are injectable for tests. */
256
- export async function skillCommand(tokens, { run, installedSet } = {}) {
256
+ export async function skillCommand(tokens, { run, installedSet, settle } = {}) {
257
257
  const verb = tokens[0];
258
258
  if (!verb || verb === "list") { printSkillTargets(tokens.slice(1).includes("--json")); return 0; }
259
259
  if (verb !== "install") {
@@ -287,7 +287,10 @@ export async function skillCommand(tokens, { run, installedSet } = {}) {
287
287
 
288
288
  const spec = { source, name: skillName(source, name) };
289
289
  console.log(info(`installing skill ${bone(spec.name)} → ${ash(source)} across skills engines…`));
290
- const results = await runSkillInstall(planSkillInstall(spec, { installedSet }), run ? { run } : {});
290
+ const results = await runSkillInstall(planSkillInstall(spec, { installedSet }), {
291
+ ...(run ? { run } : {}),
292
+ ...(settle ? { settle } : {}),
293
+ });
291
294
  summarize(results);
292
295
  return anyFailed(results) ? 1 : 0;
293
296
  }
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
  }