@syengup/friday-channel-next 0.1.38 → 0.1.39

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.
@@ -17,12 +17,19 @@
17
17
  * in another agent's workspace (main is just another agent, not a shared pool)
18
18
  * - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
19
19
  * - built-in : bundled core skills (`<openclaw>/skills`)
20
- * - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
21
- * gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
22
- * `skills.load.extraDirs` mirrors ControlUI's "EXTRA" bucket
23
- * (core tags extension skills `source: "extension"`).
20
+ * - extra : everything ControlUI buckets as "EXTRA" —
21
+ * `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
22
+ * plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
23
+ * `<workspace>/.agents/skills` : project-level agents skills (target agent)
24
+ * • `~/.agents/skills` : personal agents skills
25
+ * • config `skills.load.extraDirs`
26
+ * • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
27
+ * gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
28
+ * plugin-skills, kept as a belt-and-suspenders fallback
24
29
  *
25
30
  * Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
31
+ * The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
32
+ * this the entire plugin-provided set is invisible.
26
33
  *
27
34
  * A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
28
35
  * the containing dir name) — NOT the dir name itself, which often differs (e.g. the
@@ -17,12 +17,19 @@
17
17
  * in another agent's workspace (main is just another agent, not a shared pool)
18
18
  * - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
19
19
  * - built-in : bundled core skills (`<openclaw>/skills`)
20
- * - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
21
- * gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
22
- * `skills.load.extraDirs` mirrors ControlUI's "EXTRA" bucket
23
- * (core tags extension skills `source: "extension"`).
20
+ * - extra : everything ControlUI buckets as "EXTRA" —
21
+ * `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
22
+ * plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
23
+ * `<workspace>/.agents/skills` : project-level agents skills (target agent)
24
+ * • `~/.agents/skills` : personal agents skills
25
+ * • config `skills.load.extraDirs`
26
+ * • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
27
+ * gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
28
+ * plugin-skills, kept as a belt-and-suspenders fallback
24
29
  *
25
30
  * Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
31
+ * The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
32
+ * this the entire plugin-provided set is invisible.
26
33
  *
27
34
  * A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
28
35
  * the containing dir name) — NOT the dir name itself, which often differs (e.g. the
@@ -36,6 +43,7 @@
36
43
  * `skills[]` config, already returned separately by the config view.
37
44
  */
38
45
  import fs from "node:fs";
46
+ import os from "node:os";
39
47
  import path from "node:path";
40
48
  import { createRequire } from "node:module";
41
49
  import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
@@ -100,9 +108,24 @@ function collectSkills(root, source, out, depth = 0) {
100
108
  return; // a skill is a leaf — don't treat its internals as nested skills
101
109
  }
102
110
  for (const e of entries) {
103
- if (e.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
104
- collectSkills(path.join(root, e.name), source, out, depth + 1);
111
+ if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name))
112
+ continue;
113
+ const full = path.join(root, e.name);
114
+ // Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
115
+ // `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
116
+ // skills as symlinks under `<configDir>/plugin-skills/` — so without this the
117
+ // entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
118
+ let isDir = e.isDirectory();
119
+ if (!isDir && e.isSymbolicLink()) {
120
+ try {
121
+ isDir = fs.statSync(full).isDirectory();
122
+ }
123
+ catch {
124
+ isDir = false; // broken/unreadable symlink
125
+ }
105
126
  }
127
+ if (isDir)
128
+ collectSkills(full, source, out, depth + 1);
106
129
  }
107
130
  }
108
131
  let cachedOpenClawRoot;
@@ -216,24 +239,44 @@ export function discoverAvailableSkills(cfg, agentId) {
216
239
  // leaked main's skills into every other agent's catalog.
217
240
  try {
218
241
  const ws = resolveWs(cfg, agentId);
219
- if (ws)
242
+ if (ws) {
220
243
  sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
244
+ // Project-level agents skills: `<workspace>/.agents/skills` (core source
245
+ // `agents-skills-project`), scoped to this same target agent's workspace.
246
+ sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
247
+ }
221
248
  }
222
249
  catch {
223
250
  // skip unresolvable workspace
224
251
  }
225
- // Managed skills dir: `<configDir>/skills`. It is agent-independent; anchor it off the
226
- // DEFAULT agent's workspace parent (the default workspace lives directly under configDir,
227
- // whereas non-default workspaces may be nested under it).
252
+ // Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
253
+ // agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
254
+ // lives directly under configDir, whereas non-default workspaces may be nested under it).
228
255
  try {
229
256
  const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
230
- if (defaultWs)
231
- sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
257
+ if (defaultWs) {
258
+ const configDir = path.dirname(defaultWs);
259
+ sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
260
+ // OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
261
+ // `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
262
+ // and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
263
+ // extensions, so plugin-provided skills were missing entirely.
264
+ sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
265
+ }
232
266
  }
233
267
  catch {
234
268
  // skip unresolvable managed dir
235
269
  }
236
270
  }
271
+ // Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
272
+ try {
273
+ const home = os.homedir();
274
+ if (home)
275
+ sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
276
+ }
277
+ catch {
278
+ // home dir unresolvable on this platform — skip
279
+ }
237
280
  const extraDirs = c?.skills?.load?.extraDirs;
238
281
  if (Array.isArray(extraDirs)) {
239
282
  for (const d of extraDirs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,19 +12,6 @@
12
12
  "tsconfig.json",
13
13
  "openclaw.plugin.json"
14
14
  ],
15
- "scripts": {
16
- "build": "tsc -p tsconfig.json",
17
- "lint": "eslint .",
18
- "lint:fix": "eslint . --fix",
19
- "format": "prettier --write .",
20
- "format:check": "prettier --check .",
21
- "prepublishOnly": "pnpm build && rm -rf dist/attachments",
22
- "test": "npm run test:unit && npm run test:e2e",
23
- "test:unit": "vitest run",
24
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
25
- "test:smoke": "node scripts/e2e-smoke.mjs",
26
- "test:msg-live": "node scripts/message-roundtrip-live.mjs"
27
- },
28
15
  "bin": {
29
16
  "friday-channel-next": "install.js"
30
17
  },
@@ -75,5 +62,17 @@
75
62
  "typescript-eslint": "^8.61.1",
76
63
  "vitest": "^4.1.5",
77
64
  "zod": "^4.3.6"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.json",
68
+ "lint": "eslint .",
69
+ "lint:fix": "eslint . --fix",
70
+ "format": "prettier --write .",
71
+ "format:check": "prettier --check .",
72
+ "test": "npm run test:unit && npm run test:e2e",
73
+ "test:unit": "vitest run",
74
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
75
+ "test:smoke": "node scripts/e2e-smoke.mjs",
76
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
78
77
  }
79
- }
78
+ }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -22,6 +22,8 @@ function makeSkills(parent: string, ids: string[]): void {
22
22
 
23
23
  describe("discoverAvailableSkills", () => {
24
24
  let root: string;
25
+ let emptyHome: string;
26
+ let savedHome: string | undefined;
25
27
 
26
28
  function wire(configRoot: string, cfg: unknown): void {
27
29
  setFridayAgentForwardRuntime({
@@ -39,10 +41,21 @@ describe("discoverAvailableSkills", () => {
39
41
  } as never);
40
42
  }
41
43
 
44
+ beforeEach(() => {
45
+ // Isolate `~/.agents/skills` (a real discovery source) from the dev machine's
46
+ // home so exact-match assertions don't pick up the operator's actual skills.
47
+ savedHome = process.env.HOME;
48
+ emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "friday-home-"));
49
+ process.env.HOME = emptyHome;
50
+ });
51
+
42
52
  afterEach(() => {
53
+ if (savedHome === undefined) delete process.env.HOME;
54
+ else process.env.HOME = savedHome;
43
55
  resetFridayAgentForwardRuntimeForTest();
44
56
  resetOpenClawRootCacheForTest();
45
57
  if (root) fs.rmSync(root, { recursive: true, force: true });
58
+ if (emptyHome) fs.rmSync(emptyHome, { recursive: true, force: true });
46
59
  });
47
60
 
48
61
  it("scans only the target agent's own workspace (not the default agent's), plus managed + extra dirs, deduped and sorted", () => {
@@ -118,6 +131,42 @@ describe("discoverAvailableSkills", () => {
118
131
  expect(result.find((s) => s.id === "self-improvement")?.description).toBe("x");
119
132
  });
120
133
 
134
+ it("discovers plugin-skills (symlinks), plus project + personal agents skills as 'extra'", () => {
135
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
136
+ const configRoot = path.join(root, "configdir");
137
+ const operatorWs = path.join(configRoot, "workspace", "agents", "operator");
138
+
139
+ // target agent's own workspace skills
140
+ makeSkills(path.join(operatorWs, "skills"), ["own-skill"]);
141
+ // project-level agents skills: <workspace>/.agents/skills
142
+ makeSkills(path.join(operatorWs, ".agents", "skills"), ["project-skill"]);
143
+ // personal agents skills: <home>/.agents/skills (HOME isolated in beforeEach)
144
+ makeSkills(path.join(emptyHome, ".agents", "skills"), ["personal-skill"]);
145
+
146
+ // plugin-skills: real plugin skill dirs SYMLINKED into <configDir>/plugin-skills/,
147
+ // exactly how OpenClaw publishes third-party plugin skills (e.g. the miloco-* set).
148
+ const pluginPkg = path.join(root, "miloco-openclaw-plugin", "skills");
149
+ makeSkills(pluginPkg, ["miloco-devices", "miloco-notify"]);
150
+ const pluginSkillsDir = path.join(configRoot, "plugin-skills");
151
+ fs.mkdirSync(pluginSkillsDir, { recursive: true });
152
+ for (const id of ["miloco-devices", "miloco-notify"]) {
153
+ fs.symlinkSync(path.join(pluginPkg, id), path.join(pluginSkillsDir, id), "dir");
154
+ }
155
+
156
+ const cfg = { agents: { list: [{ id: "main", default: true }, { id: "operator" }] } };
157
+ wire(configRoot, cfg);
158
+
159
+ const result = discoverAvailableSkills(cfg, "operator");
160
+ const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
161
+ expect(bySource).toEqual({
162
+ "own-skill": "workspace",
163
+ "project-skill": "extra",
164
+ "personal-skill": "extra",
165
+ "miloco-devices": "extra", // followed through the symlink — the miloco regression
166
+ "miloco-notify": "extra",
167
+ });
168
+ });
169
+
121
170
  it("returns [] without throwing when nothing is resolvable", () => {
122
171
  resetFridayAgentForwardRuntimeForTest();
123
172
  expect(discoverAvailableSkills({}, "main")).toEqual([]);
@@ -17,12 +17,19 @@
17
17
  * in another agent's workspace (main is just another agent, not a shared pool)
18
18
  * - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
19
19
  * - built-in : bundled core skills (`<openclaw>/skills`)
20
- * - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
21
- * gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
22
- * `skills.load.extraDirs` mirrors ControlUI's "EXTRA" bucket
23
- * (core tags extension skills `source: "extension"`).
20
+ * - extra : everything ControlUI buckets as "EXTRA" —
21
+ * `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
22
+ * plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
23
+ * `<workspace>/.agents/skills` : project-level agents skills (target agent)
24
+ * • `~/.agents/skills` : personal agents skills
25
+ * • config `skills.load.extraDirs`
26
+ * • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
27
+ * gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
28
+ * plugin-skills, kept as a belt-and-suspenders fallback
24
29
  *
25
30
  * Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
31
+ * The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
32
+ * this the entire plugin-provided set is invisible.
26
33
  *
27
34
  * A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
28
35
  * the containing dir name) — NOT the dir name itself, which often differs (e.g. the
@@ -37,6 +44,7 @@
37
44
  */
38
45
 
39
46
  import fs from "node:fs";
47
+ import os from "node:os";
40
48
  import path from "node:path";
41
49
  import { createRequire } from "node:module";
42
50
  import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
@@ -107,9 +115,21 @@ function collectSkills(
107
115
  return; // a skill is a leaf — don't treat its internals as nested skills
108
116
  }
109
117
  for (const e of entries) {
110
- if (e.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
111
- collectSkills(path.join(root, e.name), source, out, depth + 1);
118
+ if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name)) continue;
119
+ const full = path.join(root, e.name);
120
+ // Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
121
+ // `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
122
+ // skills as symlinks under `<configDir>/plugin-skills/` — so without this the
123
+ // entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
124
+ let isDir = e.isDirectory();
125
+ if (!isDir && e.isSymbolicLink()) {
126
+ try {
127
+ isDir = fs.statSync(full).isDirectory();
128
+ } catch {
129
+ isDir = false; // broken/unreadable symlink
130
+ }
112
131
  }
132
+ if (isDir) collectSkills(full, source, out, depth + 1);
113
133
  }
114
134
  }
115
135
 
@@ -227,22 +247,42 @@ export function discoverAvailableSkills(cfg: unknown, agentId: string): Discover
227
247
  // leaked main's skills into every other agent's catalog.
228
248
  try {
229
249
  const ws = resolveWs(cfg, agentId);
230
- if (ws) sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
250
+ if (ws) {
251
+ sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
252
+ // Project-level agents skills: `<workspace>/.agents/skills` (core source
253
+ // `agents-skills-project`), scoped to this same target agent's workspace.
254
+ sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
255
+ }
231
256
  } catch {
232
257
  // skip unresolvable workspace
233
258
  }
234
- // Managed skills dir: `<configDir>/skills`. It is agent-independent; anchor it off the
235
- // DEFAULT agent's workspace parent (the default workspace lives directly under configDir,
236
- // whereas non-default workspaces may be nested under it).
259
+ // Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
260
+ // agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
261
+ // lives directly under configDir, whereas non-default workspaces may be nested under it).
237
262
  try {
238
263
  const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
239
- if (defaultWs)
240
- sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
264
+ if (defaultWs) {
265
+ const configDir = path.dirname(defaultWs);
266
+ sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
267
+ // OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
268
+ // `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
269
+ // and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
270
+ // extensions, so plugin-provided skills were missing entirely.
271
+ sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
272
+ }
241
273
  } catch {
242
274
  // skip unresolvable managed dir
243
275
  }
244
276
  }
245
277
 
278
+ // Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
279
+ try {
280
+ const home = os.homedir();
281
+ if (home) sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
282
+ } catch {
283
+ // home dir unresolvable on this platform — skip
284
+ }
285
+
246
286
  const extraDirs = (
247
287
  (c?.skills as Record<string, unknown> | undefined)?.load as Record<string, unknown> | undefined
248
288
  )?.extraDirs;