efaimo 0.3.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -4,6 +4,98 @@ All notable changes to efaimo are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.5.0] - 2026-09-04
8
+
9
+ ### Changed
10
+
11
+ - **`efaimo mcp` is built on the 2.x SDK line and now satisfies the
12
+ specification this tool audits other servers against.** The 1.x line speaks
13
+ 2025-11-25; it cannot answer `server/discover` and cannot carry the SEP-2549
14
+ cache fields, so the one MCP server this project ships failed two MUST-level
15
+ items of the 2026-07-28 revision, and `check --mcp` said so about it on every
16
+ run. The skill this project publishes about that migration opens with "upgrade
17
+ the SDK first". This is that.
18
+
19
+ Measured against the built server, before and after, with the same command:
20
+
21
+ ```
22
+ before 2026-07-28 readiness 3 items to migrate E106, E118, E107
23
+ after 2026-07-28 readiness clean, nothing to migrate
24
+ ```
25
+
26
+ Read off the wire rather than from our own tool as well: a modern opening now
27
+ answers `server/discover` with `supportedVersions`, `capabilities`,
28
+ `resultType: "complete"`, `ttlMs` and `cacheScope`, and `tools/list` carries
29
+ the same three fields. A 2025-era client that opens with `initialize` is still
30
+ served, unchanged, because `serveStdio` pins the era per connection rather
31
+ than making the server pick one.
32
+
33
+ - **The client half moved with it**, from `@modelcontextprotocol/sdk` to
34
+ `@modelcontextprotocol/client`. Doing only the server half would have left
35
+ both generations installed, and E101 stops firing the moment any 2.x package
36
+ is present, so the half-migration would have silenced the rule that was
37
+ telling the truth.
38
+
39
+ ### Fixed
40
+
41
+ - **Two moderate advisories, by subtraction.** `qs` arrived through
42
+ `body-parser` through `express` through the 1.x SDK, which pulls a whole HTTP
43
+ and OAuth stack that a stdio server never speaks. `pnpm audit --prod` now
44
+ reports no known vulnerabilities, and production dependencies went from 30 to
45
+ 13.
46
+
47
+ ### Notes
48
+
49
+ - No rule changed, so no grade moved. `rulesVersion` is untouched and the
50
+ published skills index does not need regenerating.
51
+ - `check --skill`, `weigh`, `find` and `test` are byte-identical in behaviour;
52
+ the only thing that moved is what `efaimo mcp` puts on the wire.
53
+
54
+ ## [0.4.0] - 2026-09-03
55
+
56
+ ### Fixed
57
+
58
+ - **`check --skill <dir>` finds skills it used to walk past**.
59
+ Two causes, both silent. The walk never entered a directory whose name starts
60
+ with a dot, so `.claude/skills/<name>/`, which is where a project keeps its
61
+ own skills, was invisible; and its depth bound of 3 missed layouts one level
62
+ deeper such as `skills/custom_skills/<name>/`. On the public corpus this
63
+ found 34 skills where `scripts/skills-index.mjs` found 38, and both numbers
64
+ were reported with confidence.
65
+
66
+ Dot directories are now entered for skill discovery and nowhere else, with
67
+ `.git` still excluded. The depth bound is 6 and is measured rather than
68
+ picked: the deepest real skill in that corpus sits 4 directories below a
69
+ directory of repositories and 3 below a repository root, so six leaves two
70
+ levels of headroom while still keeping an accidental `check --skill /` from
71
+ walking a disk.
72
+
73
+ **And when the bound does bite, it says so.** `SkillSet.truncatedAt` lists
74
+ the directories the walk refused to enter, because a bound that truncates in
75
+ silence is the same failure as a check that examines nothing. On the public
76
+ corpus it fires 16 times, all of them XML schema trees inside the `xlsx` and
77
+ `pptx` skills, which is the bound doing its job audibly.
78
+
79
+ - **One walker instead of two**. `scripts/skills-index.mjs`
80
+ carried its own copy with different rules, which is why the two disagreed.
81
+ It now calls the CLI's discovery, so they cannot drift apart again, and it
82
+ passes the truncation warning through. The published July index regenerates
83
+ byte-identically under the shared walk, which is how this was checked.
84
+
85
+ ### Added
86
+
87
+ - **S107: a filename one capitalisation away from a skill**.
88
+ Set-level and warn-level, so it moves no grade, in the same way S103 treats
89
+ a collision that belongs to a pair rather than to either member.
90
+
91
+ The spec names `SKILL.md` exactly and discovery matches it exactly, so
92
+ `skill.md` is not a skill here on any platform. It is not nothing anywhere:
93
+ a case-insensitive filesystem, the default on macOS and Windows, may hand it
94
+ to a host that opens it by name, so the same repository can carry a working
95
+ skill on the author's laptop and no skill at all in Linux CI. Nothing in
96
+ this tool would previously have mentioned such a file, which is the worst
97
+ property a near miss can have. Five exist in the public corpus.
98
+
7
99
  ## [0.3.0] - 2026-09-03
8
100
 
9
101
  ### Added
@@ -34,9 +126,17 @@ All notable changes to efaimo are documented here. Format follows
34
126
  both runs scored lower.
35
127
 
36
128
  Measured on the public skills corpus, 2026-07-17 pinned against 2026-09-03
37
- HEAD, same ruleset both sides: metadata -0.8%, body +7.5%, referenced files
38
- +61.4% with one skill at 96.5% of that movement; grades 0 improved and 4
39
- worsened.
129
+ HEAD, same ruleset both sides, over the **32 skills `check --skill` pairs**:
130
+ metadata -0.8%, body +7.5%, referenced files +61.4% with one skill at 96.5%
131
+ of that movement; grades 0 improved and 4 worsened.
132
+
133
+ The population matters and is worth stating, because
134
+ `research/skills-delta-2026-09-03/REPORT.md` reports body **+7.0%** over
135
+ **36** paired skills for the same corpus on the same dates. Both are true.
136
+ `scripts/skills-index.mjs` finds four skills that `check --skill` does not,
137
+ for the walker reason in Known below, so the two are measuring slightly
138
+ different populations. Every other figure is identical between them; the
139
+ body percentage is the one the four missing skills move.
40
140
 
41
141
  - **`efaimo find` takes several servers**. Pass more than one
42
142
  and their catalogs are merged into one and measured together, each tool
@@ -355,6 +455,8 @@ First release.
355
455
  Documented rule set (`docs/RULES.md`), token methodology (`docs/METHODOLOGY.md`),
356
456
  and integration guide (`docs/INTEGRATIONS.md`).
357
457
 
458
+ [0.5.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.5.0
459
+ [0.4.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.4.0
358
460
  [0.3.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.3.0
359
461
  [0.2.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.2.0
360
462
  [0.1.2]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.2
package/README.md CHANGED
@@ -406,3 +406,7 @@ Built by [efaimo ai](https://efaimo.ai), open tooling for the space between host
406
406
  and tools. `efaimo` is the flagship CLI; capabilities grow as subcommands under one
407
407
  name. Apache-2.0. See [CONTRIBUTING.md](./CONTRIBUTING.md) and
408
408
  [docs/RULES.md](./docs/RULES.md) to add a rule.
409
+
410
+ EFAIMO AI is a registered trademark of the publisher (KR 40-2585820, class 42).
411
+ The lowercase `efaimo ai` used throughout is the same mark; the package name and
412
+ the commands stay lowercase.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: efaimo
3
3
  description: Audit MCP servers and Agent Skills for quality and context-window cost. Use when the user wants to check an MCP server's token cost, lint a skill, verify 2026-07-28 MCP spec readiness, find out whether a search can single out a server's tools under deferred tool loading, or add an efaimo badge or CI gate.
4
4
  license: Apache-2.0
5
5
  metadata:
6
- version: "0.3.0"
6
+ version: "0.5.0"
7
7
  homepage: "https://efaimo.ai"
8
8
  ---
9
9
 
@@ -85,5 +85,20 @@ export async function checkSkillSet(pathInput, label) {
85
85
  return { name, dir: skill.dir, report: buildReport("skill", name, findings, []) };
86
86
  });
87
87
  const setFindings = set.skills.flatMap((skill) => runRules(setRules, { skill, set, weigh }));
88
+ // S107 is emitted here rather than by a SkillRule because its subject is a
89
+ // file that is NOT a skill: no rule that takes a parsed skill could ever see
90
+ // it. Set level and warn level on purpose, so it moves no grade, matching
91
+ // how S103 treats a collision that belongs to a pair rather than to either
92
+ // member.
93
+ for (const file of set.miscasedSkillFiles ?? []) {
94
+ setFindings.push({
95
+ ruleId: "S107",
96
+ severity: "warn",
97
+ title: "filename is one capitalisation away from a skill",
98
+ message: `"${path.relative(set.root, file)}" is not loaded here, because the spec names SKILL.md exactly. A case-insensitive filesystem (the macOS and Windows default) may still hand it to a host, so this can work on a laptop and be missing entirely in Linux CI.`,
99
+ target: path.relative(set.root, file),
100
+ fixHint: "rename it to SKILL.md, or delete it if it is not a skill",
101
+ });
102
+ }
88
103
  return { label, root: set.root, perSkill, setFindings, weigh };
89
104
  }
@@ -1,8 +1,8 @@
1
1
  import crossSpawn from "cross-spawn";
2
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
2
+ import { Client } from "@modelcontextprotocol/client";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
5
+ import { SSEClientTransport } from "@modelcontextprotocol/client";
6
6
  import { introspectStateless } from "./stateless.js";
7
7
  import { minimalChildEnv } from "../util/childEnv.js";
8
8
  import { truncate, withTimeout } from "../util/misc.js";
@@ -339,6 +339,24 @@ export interface SkillInfo {
339
339
  export interface SkillSet {
340
340
  root: string;
341
341
  skills: SkillInfo[];
342
+ /**
343
+ * Directories the walk refused to enter because it hit its depth bound.
344
+ *
345
+ * Present only when the bound actually bit. A truncated search that reports
346
+ * a short list and no reason is the same failure as a check that examines
347
+ * nothing, so the caller is given the means to say what it did not look at.
348
+ */
349
+ truncatedAt?: string[];
350
+ /**
351
+ * Files one capitalisation away from being a skill, such as `skill.md`.
352
+ *
353
+ * The spec names `SKILL.md` exactly and discovery matches it exactly, so
354
+ * these are not skills here on any platform. They may still be skills to a
355
+ * host reading a case-insensitive filesystem, which is the default on macOS
356
+ * and Windows, so the same repository can work on a laptop and have no
357
+ * skills at all in Linux CI. Reported by S107.
358
+ */
359
+ miscasedSkillFiles?: string[];
342
360
  }
343
361
  export interface GradeInfo {
344
362
  score: number;
@@ -1,3 +1,3 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- export declare function buildMcpServer(): Server;
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ export declare function buildMcpServer(): McpServer;
3
3
  export declare function runMcpServer(): Promise<void>;
@@ -1,88 +1,94 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
+ import { z } from "zod";
4
4
  import { checkSkillSet } from "../check/check.js";
5
5
  import { weighSkills } from "../weigh/weigh.js";
6
6
  import { findSkills } from "../skills/parse.js";
7
7
  import { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "../reporters/markdown.js";
8
8
  import { VERSION } from "../version.js";
9
+ /**
10
+ * `efaimo mcp`: a small, read-only MCP server that exposes the skill checks to an
11
+ * agent, so it can lint or weigh a skill mid-session before committing it to
12
+ * context. Deliberately narrow: the tools only read files. They spawn no process
13
+ * and open no socket (unlike `check --mcp`, which connects to a live server), and
14
+ * `test` is not exposed at all because it spends tokens. That keeps the surface
15
+ * safe for an agent to call unattended.
16
+ *
17
+ * Built on the 2.x SDK line since 0.5.0. The 1.x line speaks 2025-11-25 and
18
+ * cannot answer `server/discover` or carry the SEP-2549 cache fields, so this
19
+ * server failed two MUST-level items of the specification the rest of this tool
20
+ * audits other servers against, and the skill this project publishes about that
21
+ * migration opens with "upgrade the SDK first". It was the only server we ship
22
+ * and the only one we had not migrated.
23
+ *
24
+ * `serveStdio` owns the era decision rather than this file: the opening exchange
25
+ * selects it, one instance is pinned for the connection, and a 2025-era client
26
+ * that opens with `initialize` is still served. Registering the tools once, on a
27
+ * factory that serves both eras, is the whole reason to use it instead of
28
+ * wiring a transport by hand.
29
+ */
9
30
  // Both tools only read local files, so the same hints apply: safe to call without
10
31
  // a confirmation, safe to repeat, and not reaching out to any external service.
11
32
  const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
12
- function requireString(args, key) {
13
- const v = args[key];
14
- if (typeof v !== "string" || v.trim() === "") {
15
- throw new Error(`"${key}" is required and must be a non-empty string`);
16
- }
17
- return v;
18
- }
19
- const pathSchema = (what) => ({
20
- type: "object",
21
- properties: { path: { type: "string", description: what } },
22
- required: ["path"],
23
- additionalProperties: false,
33
+ // One shared shape. `.min(1)` is the schema saying what `requireString` used to
34
+ // say at runtime: an empty path is not a path. Under the 2.x registration the
35
+ // SDK validates against this before the handler runs, so the check moved from
36
+ // our code into the contract, which is where a client can also see it.
37
+ const pathInput = (what) => z.object({ path: z.string().min(1).describe(what) });
38
+ const CHECK_DESCRIPTION = "Lint an Agent Skill, or a folder of skills, for spec compliance, trigger quality, " +
39
+ "context-window cost, reference integrity, and injection hygiene. Returns a grade from " +
40
+ "A to F and the findings. Read-only: reads files only.";
41
+ const WEIGH_DESCRIPTION = "Measure the context-window token cost of an Agent Skill: the metadata loaded into every " +
42
+ "session and the body loaded when the skill triggers. Returns token counts per skill. " +
43
+ "Read-only: reads files only.";
44
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
45
+ const failure = (e) => ({
46
+ content: [{ type: "text", text: `error: ${e instanceof Error ? e.message : String(e)}` }],
47
+ isError: true,
24
48
  });
25
- const TOOLS = [
26
- {
27
- name: "efaimo_check_skill",
28
- description: "Lint an Agent Skill, or a folder of skills, for spec compliance, trigger quality, " +
29
- "context-window cost, reference integrity, and injection hygiene. Returns a grade from " +
30
- "A to F and the findings. Read-only: reads files only.",
31
- inputSchema: pathSchema("Path to a SKILL.md file or a directory that contains skills."),
49
+ export function buildMcpServer() {
50
+ const server = new McpServer({ name: "efaimo", version: VERSION }, { capabilities: { tools: {} } });
51
+ server.registerTool("efaimo_check_skill", {
52
+ description: CHECK_DESCRIPTION,
53
+ inputSchema: pathInput("Path to a SKILL.md file or a directory that contains skills."),
32
54
  annotations: READ_ONLY,
33
- async run(args) {
34
- const p = requireString(args, "path");
35
- const res = await checkSkillSet(p, p);
55
+ }, async ({ path }) => {
56
+ try {
57
+ const res = await checkSkillSet(path, path);
36
58
  if (res.perSkill.length === 1 && res.setFindings.length === 0) {
37
- return renderCheckMarkdown(res.perSkill[0].report);
59
+ return text(renderCheckMarkdown(res.perSkill[0].report));
38
60
  }
39
- return renderSkillSetMarkdown(res);
40
- },
41
- },
42
- {
43
- name: "efaimo_weigh_skill",
44
- description: "Measure the context-window token cost of an Agent Skill: the metadata loaded into every " +
45
- "session and the body loaded when the skill triggers. Returns token counts per skill. " +
46
- "Read-only: reads files only.",
47
- inputSchema: pathSchema("Path to a SKILL.md file or a directory that contains skills."),
48
- annotations: READ_ONLY,
49
- async run(args) {
50
- const p = requireString(args, "path");
51
- const set = findSkills(p);
52
- if (!set.skills.length)
53
- throw new Error(`no SKILL.md found under "${p}"`);
54
- return renderWeighMarkdown(await weighSkills(set));
55
- },
56
- },
57
- ];
58
- export function buildMcpServer() {
59
- const server = new Server({ name: "efaimo", version: VERSION }, { capabilities: { tools: {} } });
60
- server.setRequestHandler(ListToolsRequestSchema, () => ({
61
- tools: TOOLS.map((t) => ({
62
- name: t.name,
63
- description: t.description,
64
- inputSchema: t.inputSchema,
65
- annotations: t.annotations,
66
- })),
67
- }));
68
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
69
- const tool = TOOLS.find((t) => t.name === req.params.name);
70
- if (!tool) {
71
- return { content: [{ type: "text", text: `unknown tool: ${req.params.name}` }], isError: true };
61
+ return text(renderSkillSetMarkdown(res));
72
62
  }
63
+ catch (e) {
64
+ return failure(e);
65
+ }
66
+ });
67
+ server.registerTool("efaimo_weigh_skill", {
68
+ description: WEIGH_DESCRIPTION,
69
+ inputSchema: pathInput("Path to a SKILL.md file or a directory that contains skills."),
70
+ annotations: READ_ONLY,
71
+ }, async ({ path }) => {
73
72
  try {
74
- const text = await tool.run(req.params.arguments ?? {});
75
- return { content: [{ type: "text", text }] };
73
+ const set = findSkills(path);
74
+ if (!set.skills.length)
75
+ throw new Error(`no SKILL.md found under "${path}"`);
76
+ return text(renderWeighMarkdown(await weighSkills(set)));
76
77
  }
77
78
  catch (e) {
78
- const message = e instanceof Error ? e.message : String(e);
79
- return { content: [{ type: "text", text: `error: ${message}` }], isError: true };
79
+ return failure(e);
80
80
  }
81
81
  });
82
82
  return server;
83
83
  }
84
84
  export async function runMcpServer() {
85
- const server = buildMcpServer();
86
- await server.connect(new StdioServerTransport());
85
+ // A factory, not an instance: `serveStdio` pins one per connection after the
86
+ // opening exchange tells it which era it is talking to.
87
+ serveStdio(() => buildMcpServer(), {
88
+ onerror: (e) => console.error(`efaimo mcp: ${e.message}`),
89
+ });
87
90
  console.error(`efaimo mcp v${VERSION}: read-only skill tools ready on stdio`);
91
+ // stdin holds the process open; there is nothing to await. Returning a promise
92
+ // that never settles would only make the shutdown path harder to read.
93
+ await new Promise(() => { });
88
94
  }
@@ -1,4 +1,10 @@
1
1
  import type { SkillInfo, SkillSet } from "../core/types.js";
2
2
  export declare function parseSkillFile(file: string): SkillInfo;
3
3
  /** Resolve a path (SKILL.md file, skill dir, or a directory of skills) into a SkillSet. */
4
+ /**
5
+ * How deep skill discovery walks. See the note inside `findSkills`: measured
6
+ * against the public corpus, where the deepest real skill is four directories
7
+ * below a directory of repositories.
8
+ */
9
+ export declare const SKILL_WALK_MAX_DEPTH = 6;
4
10
  export declare function findSkills(input: string): SkillSet;
@@ -129,6 +129,12 @@ export function parseSkillFile(file) {
129
129
  return base;
130
130
  }
131
131
  /** Resolve a path (SKILL.md file, skill dir, or a directory of skills) into a SkillSet. */
132
+ /**
133
+ * How deep skill discovery walks. See the note inside `findSkills`: measured
134
+ * against the public corpus, where the deepest real skill is four directories
135
+ * below a directory of repositories.
136
+ */
137
+ export const SKILL_WALK_MAX_DEPTH = 6;
132
138
  export function findSkills(input) {
133
139
  const abs = path.resolve(input);
134
140
  const stat = fs.statSync(abs);
@@ -139,11 +145,49 @@ export function findSkills(input) {
139
145
  if (fs.existsSync(direct)) {
140
146
  return { root: abs, skills: [parseSkillFile(direct)] };
141
147
  }
148
+ // Discovery is deliberate rather than incidental, because two walkers with
149
+ // different rules is what produced the 2026-09-03 defect: this found 34
150
+ // skills in a corpus where `scripts/skills-index.mjs` found 38, and both
151
+ // reported their own number with confidence.
152
+ //
153
+ // maxDepth 6 is measured, not picked. In the public corpus the deepest real
154
+ // skill sits 4 directories below a directory-of-repositories
155
+ // (`<corpus>/<repo>/skills/custom_skills/<name>/SKILL.md`) and 3 below a
156
+ // repository root. Six leaves two levels of headroom and keeps an accidental
157
+ // `check --skill /` from walking a disk. When the bound does bite it is
158
+ // reported rather than swallowed, in `truncatedAt`.
159
+ //
160
+ // Dot directories are entered here and nowhere else in this codebase:
161
+ // `.claude/skills/<name>/` is where a project keeps its own skills, so
162
+ // skipping them made this blind to the most common real layout. `.git` is
163
+ // still excluded by DEFAULT_SKIP_DIRS.
142
164
  const skills = [];
143
- for (const f of walkFiles(abs, { maxDepth: 3 })) {
144
- if (path.basename(f) === "SKILL.md")
165
+ const truncatedAt = [];
166
+ const miscased = [];
167
+ for (const f of walkFiles(abs, {
168
+ maxDepth: SKILL_WALK_MAX_DEPTH,
169
+ includeDotDirs: true,
170
+ onDepthLimit: (d) => truncatedAt.push(d),
171
+ })) {
172
+ const base = path.basename(f);
173
+ if (base === "SKILL.md")
145
174
  skills.push(parseSkillFile(f));
175
+ // A file that is only a capitalisation away from being a skill. The spec
176
+ // names SKILL.md exactly, and this comparison is exact, so `skill.md` is
177
+ // invisible here on every platform. It is not invisible everywhere: a
178
+ // case-insensitive filesystem, which is the default on macOS and Windows,
179
+ // may hand it to a host that opens it by name, so the same repository has
180
+ // a working skill on the author's laptop and no skill at all in Linux CI.
181
+ // Nothing else in this tool would ever mention it, which is the worst
182
+ // property a near miss can have.
183
+ else if (base.toLowerCase() === "skill.md")
184
+ miscased.push(f);
146
185
  }
147
186
  skills.sort((a, b) => a.file.localeCompare(b.file));
148
- return { root: abs, skills };
187
+ return {
188
+ root: abs,
189
+ skills,
190
+ ...(truncatedAt.length ? { truncatedAt } : {}),
191
+ ...(miscased.length ? { miscasedSkillFiles: miscased.sort() } : {}),
192
+ };
149
193
  }
@@ -3,10 +3,28 @@ export declare function parseCommandString(input: string): {
3
3
  command: string;
4
4
  args: string[];
5
5
  };
6
- export declare function walkFiles(root: string, opts?: {
6
+ export interface WalkOptions {
7
7
  maxDepth?: number;
8
8
  skipDirs?: Set<string>;
9
- }): Generator<string>;
9
+ /**
10
+ * Descend into directories whose name starts with a dot.
11
+ *
12
+ * Off by default, because most of them are caches and metadata. On for skill
13
+ * discovery, because `.claude/skills/<name>/` is where a project keeps its
14
+ * own skills and a tool that cannot see the most common real layout is not
15
+ * auditing anything. `DEFAULT_SKIP_DIRS` still excludes `.git`.
16
+ */
17
+ includeDotDirs?: boolean;
18
+ /**
19
+ * Called once for each directory not entered because `maxDepth` was reached.
20
+ *
21
+ * A bound that truncates in silence is the same failure as a check that
22
+ * examines nothing: the caller sees a short list and no reason for it. This
23
+ * lets the caller say so.
24
+ */
25
+ onDepthLimit?: (dir: string) => void;
26
+ }
27
+ export declare function walkFiles(root: string, opts?: WalkOptions): Generator<string>;
10
28
  export declare function readTextSafe(file: string, maxBytes?: number): string | undefined;
11
29
  export declare function isBinaryPath(file: string): boolean;
12
30
  export declare function extractVersion(s: string): number[] | undefined;
package/dist/util/misc.js CHANGED
@@ -59,9 +59,15 @@ export function* walkFiles(root, opts = {}) {
59
59
  for (const e of entries) {
60
60
  const full = path.join(dir, e.name);
61
61
  if (e.isDirectory()) {
62
- if (depth < maxDepth && !skip.has(e.name) && !e.name.startsWith(".")) {
63
- stack.push({ dir: full, depth: depth + 1 });
62
+ if (skip.has(e.name))
63
+ continue;
64
+ if (!opts.includeDotDirs && e.name.startsWith("."))
65
+ continue;
66
+ if (depth >= maxDepth) {
67
+ opts.onDepthLimit?.(full);
68
+ continue;
64
69
  }
70
+ stack.push({ dir: full, depth: depth + 1 });
65
71
  }
66
72
  else if (e.isFile()) {
67
73
  yield full;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by test/meta.test.ts. */
2
- export declare const VERSION = "0.3.0";
2
+ export declare const VERSION = "0.5.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by test/meta.test.ts. */
2
- export const VERSION = "0.3.0";
2
+ export const VERSION = "0.5.0";
package/docs/RULES.md CHANGED
@@ -65,7 +65,7 @@ never reused, so a gap is permanent and is better stated than smoothed over.
65
65
  | E128 | warn | total tool-definition tax over ~10k / ~25k tokens, estimated (needs `weigh` data); hard CI failure is left to the `--max-tokens`/`--diff` gate |
66
66
  | E130 | info | instruction-injection patterns in a tool description or server instructions (shallow heuristic, never a security verdict; use a dedicated scanner for depth). **Reported but never scored** |
67
67
 
68
- ## Skills (S101-S106)
68
+ ## Skills (S101-S107)
69
69
 
70
70
  | id | sev | what it catches | spec |
71
71
  |---|---|---|---|
@@ -75,6 +75,7 @@ never reused, so a gap is permanent and is better stated than smoothed over.
75
75
  | S104 | warn/info | metadata over ~100 tokens, body over ~5k tokens or 500 lines, or long body with no progressive disclosure | agentskills.io |
76
76
  | S105 | info | instruction-injection patterns in the skill body or description (shallow heuristic, never a security verdict). **Reported but never scored** | internal |
77
77
  | S106 | error/warn/info | referenced file missing, escaping the skill dir (`..`), or nested more than one level deep | agentskills.io |
78
+ | S107 | warn | a file one capitalisation away from a skill, such as `skill.md`. Set-level and never scored: the spec names `SKILL.md` exactly, so it is not a skill here on any platform, but a case-insensitive filesystem (the macOS and Windows default) may hand it to a host, which makes a repository work on a laptop and have no skill at all in Linux CI | internal |
78
79
 
79
80
  ## Findability (E141-E145)
80
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "efaimo",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Audit what your agent loads: quality and context cost for MCP servers and Agent Skills",
5
5
  "keywords": [
6
6
  "mcp",
@@ -65,12 +65,14 @@
65
65
  "prepublishOnly": "npm run typecheck && npm run build && npm run test"
66
66
  },
67
67
  "dependencies": {
68
- "@modelcontextprotocol/sdk": "^1.30.0",
68
+ "@modelcontextprotocol/client": "2.0.0",
69
+ "@modelcontextprotocol/server": "2.0.0",
69
70
  "commander": "^15.0.0",
70
71
  "cross-spawn": "^7.0.6",
71
72
  "gpt-tokenizer": "^3.4.0",
72
73
  "picocolors": "^1.1.1",
73
- "yaml": "^2.9.0"
74
+ "yaml": "^2.9.0",
75
+ "zod": "^4.5.4"
74
76
  },
75
77
  "devDependencies": {
76
78
  "@types/cross-spawn": "^6.0.6",