@yeaft/webchat-agent 0.1.1075 → 0.1.1077

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": "@yeaft/webchat-agent",
3
- "version": "0.1.1075",
3
+ "version": "0.1.1077",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { existsSync, readFileSync } from 'fs';
23
+ import { homedir } from 'os';
23
24
  import { join } from 'path';
24
25
  import { DEFAULT_YEAFT_DIR } from './init.js';
25
26
  import { getModelEffortOptions, getThinkingCapability, modelSupportsEffort, resolveModel, parseModelRef, normalizeProviderModels, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
@@ -513,13 +514,16 @@ export function loadConfig(overrides = {}) {
513
514
  /**
514
515
  * Load MCP server configuration.
515
516
  *
516
- * Merges two tiers, in priority order (highest first):
517
- * 1. global — ~/.yeaft authoritative config: config.json "mcpServers"
517
+ * Merges compatibility tiers, in priority order (highest first):
518
+ * 1. yeaft-global — ~/.yeaft authoritative config: config.json "mcpServers"
518
519
  * array, else standalone ~/.yeaft/mcp.json ({ servers: [...] }).
519
- * 2. project `<workDir>/.mcp.json` (Claude Code standard location), so a
520
- * project that integrates Claude Code works out of the box. Supplementary:
521
- * a project server whose name collides with a global one is dropped (the
522
- * explicit ~/.yeaft config wins).
520
+ * 2. claude-user ~/.claude.json (Claude Code user-scope MCP config).
521
+ * 3. codex-user — ~/.codex/config.toml (Codex user-scope MCP config).
522
+ * 4. project `<workDir>/.mcp.json` (Claude Code project location)
523
+ * plus `<workDir>/.codex/config.toml` (Codex project location).
524
+ *
525
+ * Higher tiers win. Borrowed Claude/Codex configs supplement Yeaft, but never
526
+ * override explicit ~/.yeaft settings.
523
527
  *
524
528
  * @param {string} yeaftDir
525
529
  * @param {object} [jsonConfig] — Already-parsed config.json (optional, avoids re-read)
@@ -527,24 +531,23 @@ export function loadConfig(overrides = {}) {
527
531
  * @returns {{ servers: object[], skipped: { name: string, reason: string, source: string }[] }}
528
532
  */
529
533
  export function loadMCPConfig(yeaftDir, jsonConfig, workDir) {
530
- // ── Global tier: ~/.yeaft authoritative config ──
531
- const globalServers = loadGlobalMCPServers(yeaftDir, jsonConfig);
532
-
533
- // ── Project tier: <workDir>/.mcp.json (Claude Code standard) ──
534
+ const yeaftGlobal = loadGlobalMCPServers(yeaftDir, jsonConfig);
535
+ const externalUser = loadExternalUserMCPServers();
534
536
  const project = workDir
535
537
  ? loadProjectMCPServers(workDir)
536
538
  : { servers: [], skipped: [] };
537
539
 
538
- // Dedup: global (~/.yeaft explicit config) wins over project supplements.
539
- const seen = new Set(globalServers.map(s => s.name));
540
- const servers = [...globalServers];
541
- for (const s of project.servers) {
542
- if (seen.has(s.name)) continue;
543
- seen.add(s.name);
544
- servers.push(s);
540
+ const servers = [];
541
+ const seen = new Set();
542
+ for (const tier of [yeaftGlobal, externalUser.servers, project.servers]) {
543
+ for (const s of tier) {
544
+ if (!s?.name || seen.has(s.name)) continue;
545
+ seen.add(s.name);
546
+ servers.push(s);
547
+ }
545
548
  }
546
549
 
547
- return { servers, skipped: project.skipped };
550
+ return { servers, skipped: [...externalUser.skipped, ...project.skipped] };
548
551
  }
549
552
 
550
553
  /**
@@ -579,35 +582,27 @@ function loadGlobalMCPServers(yeaftDir, jsonConfig) {
579
582
  }
580
583
  }
581
584
 
582
- /**
583
- * Parse a project's Claude Code MCP config at `<workDir>/.mcp.json`.
584
- *
585
- * Format (Claude Code standard):
586
- * { "mcpServers": { "<name>": { command, args?, env?, url?, type? } } }
587
- *
588
- * Only stdio servers (those with a `command`) are adapted into yeaft's
589
- * { name, command, args?, env? } shape. SSE/HTTP servers (url/type, no
590
- * command) cannot be spawned by the current MCPManager, so they're reported
591
- * in `skipped` with reason 'unsupported-transport' rather than silently
592
- * dropped or surfaced later as spawn failures.
593
- *
594
- * Robust by design: a missing file, malformed JSON, or a non-object
595
- * `mcpServers` field all return gracefully with empty arrays — a broken
596
- * project `.mcp.json` must never fail session creation.
597
- *
598
- * @param {string} workDir — project working directory
599
- * @returns {{ servers: object[], skipped: { name: string, reason: string, source: string }[] }}
600
- */
601
- export function loadProjectMCPServers(workDir) {
602
- const empty = { servers: [], skipped: [] };
603
- if (!workDir || typeof workDir !== 'string') return empty;
585
+ function normaliseStdioMCPServer(name, raw, source) {
586
+ if (!name || !raw || typeof raw !== 'object') return { server: null, skipped: null };
587
+ if (typeof raw.command === 'string' && raw.command.length > 0) {
588
+ const server = { name, command: raw.command };
589
+ if (Array.isArray(raw.args)) server.args = raw.args;
590
+ if (raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env)) server.env = raw.env;
591
+ return { server, skipped: null };
592
+ }
593
+ if (typeof raw.url === 'string' || typeof raw.type === 'string') {
594
+ return { server: null, skipped: { name, reason: 'unsupported-transport', source } };
595
+ }
596
+ return { server: null, skipped: { name, reason: 'invalid-config', source } };
597
+ }
604
598
 
605
- const mcpPath = join(workDir, '.mcp.json');
606
- if (!existsSync(mcpPath)) return empty;
599
+ function loadClaudeMCPJsonFile(filePath, source) {
600
+ const empty = { servers: [], skipped: [] };
601
+ if (!filePath || !existsSync(filePath)) return empty;
607
602
 
608
603
  let parsed;
609
604
  try {
610
- parsed = JSON.parse(readFileSync(mcpPath, 'utf8'));
605
+ parsed = JSON.parse(readFileSync(filePath, 'utf8'));
611
606
  } catch {
612
607
  return empty;
613
608
  }
@@ -620,21 +615,189 @@ export function loadProjectMCPServers(workDir) {
620
615
  const servers = [];
621
616
  const skipped = [];
622
617
  for (const [name, raw] of Object.entries(mcpServers)) {
623
- if (!name || !raw || typeof raw !== 'object') continue;
624
- if (typeof raw.command === 'string' && raw.command.length > 0) {
625
- // stdio server → adapt to yeaft shape
626
- const server = { name, command: raw.command };
627
- if (Array.isArray(raw.args)) server.args = raw.args;
628
- if (raw.env && typeof raw.env === 'object') server.env = raw.env;
629
- servers.push(server);
630
- } else if (typeof raw.url === 'string' || typeof raw.type === 'string') {
631
- // SSE/HTTP transport not spawnable by current MCPManager.
632
- skipped.push({ name, reason: 'unsupported-transport', source: '.mcp.json' });
618
+ const normalised = normaliseStdioMCPServer(name, raw, source);
619
+ if (normalised.server) servers.push(normalised.server);
620
+ if (normalised.skipped) skipped.push(normalised.skipped);
621
+ }
622
+ return { servers, skipped };
623
+ }
624
+
625
+ function stripTomlInlineComment(raw) {
626
+ const text = String(raw || '');
627
+ let inSingle = false;
628
+ let inDouble = false;
629
+ let escaped = false;
630
+ let depth = 0;
631
+
632
+ for (let i = 0; i < text.length; i++) {
633
+ const ch = text[i];
634
+ if (inDouble) {
635
+ if (escaped) {
636
+ escaped = false;
637
+ } else if (ch === '\\') {
638
+ escaped = true;
639
+ } else if (ch === '"') {
640
+ inDouble = false;
641
+ }
642
+ continue;
643
+ }
644
+ if (inSingle) {
645
+ if (ch === "'") inSingle = false;
646
+ continue;
647
+ }
648
+ if (ch === '"') {
649
+ inDouble = true;
650
+ continue;
651
+ }
652
+ if (ch === "'") {
653
+ inSingle = true;
654
+ continue;
655
+ }
656
+ if (ch === '[' || ch === '{') {
657
+ depth++;
658
+ continue;
659
+ }
660
+ if ((ch === ']' || ch === '}') && depth > 0) {
661
+ depth--;
662
+ continue;
663
+ }
664
+ if (ch === '#' && depth === 0) return text.slice(0, i).trim();
665
+ }
666
+ return text.trim();
667
+ }
668
+
669
+ function parseTomlValue(raw) {
670
+ const value = stripTomlInlineComment(raw);
671
+ if (value.startsWith('"') && value.endsWith('"')) {
672
+ try { return JSON.parse(value); } catch { return value.slice(1, -1); }
673
+ }
674
+ if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
675
+ if (value.startsWith('[') && value.endsWith(']')) {
676
+ try { return JSON.parse(value.replace(/'/g, '"')); } catch { return undefined; }
677
+ }
678
+ if (value.startsWith('{') && value.endsWith('}')) {
679
+ const obj = {};
680
+ const inner = value.slice(1, -1).trim();
681
+ if (!inner) return obj;
682
+ for (const part of inner.split(',')) {
683
+ const eq = part.indexOf('=');
684
+ if (eq <= 0) return undefined;
685
+ const key = part.slice(0, eq).trim().replace(/^['"]|['"]$/g, '');
686
+ const parsed = parseTomlValue(part.slice(eq + 1));
687
+ if (!key || parsed === undefined) return undefined;
688
+ obj[key] = parsed;
689
+ }
690
+ return obj;
691
+ }
692
+ if (value === 'true') return true;
693
+ if (value === 'false') return false;
694
+ return value;
695
+ }
696
+
697
+ function parseCodexMCPServersToml(content, source) {
698
+ const rawServers = {};
699
+ let current = null;
700
+ for (const line of String(content || '').split(/\r?\n/)) {
701
+ const trimmed = line.trim();
702
+ if (!trimmed || trimmed.startsWith('#')) continue;
703
+ const section = trimmed.match(/^\[mcp_servers\.("[^"]+"|'[^']+'|[^\].]+)(?:\.(env))?\]$/);
704
+ if (section) {
705
+ const name = section[1].replace(/^['"]|['"]$/g, '');
706
+ rawServers[name] ||= {};
707
+ current = { name, env: section[2] === 'env' };
708
+ continue;
709
+ }
710
+ if (!current) continue;
711
+ const eq = trimmed.indexOf('=');
712
+ if (eq <= 0) continue;
713
+ const key = trimmed.slice(0, eq).trim();
714
+ const value = parseTomlValue(trimmed.slice(eq + 1));
715
+ if (value === undefined) continue;
716
+ if (current.env) {
717
+ rawServers[current.name].env ||= {};
718
+ rawServers[current.name].env[key] = String(value);
719
+ } else if (key === 'env' && value && typeof value === 'object' && !Array.isArray(value)) {
720
+ rawServers[current.name].env = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, String(v)]));
633
721
  } else {
634
- // No command and no url/type — malformed entry.
635
- skipped.push({ name, reason: 'invalid-config', source: '.mcp.json' });
722
+ rawServers[current.name][key] = value;
636
723
  }
637
724
  }
638
725
 
726
+ const servers = [];
727
+ const skipped = [];
728
+ for (const [name, raw] of Object.entries(rawServers)) {
729
+ const normalised = normaliseStdioMCPServer(name, raw, source);
730
+ if (normalised.server) servers.push(normalised.server);
731
+ if (normalised.skipped) skipped.push(normalised.skipped);
732
+ }
733
+ return { servers, skipped };
734
+ }
735
+
736
+ function loadCodexMCPConfigFile(filePath, source) {
737
+ const empty = { servers: [], skipped: [] };
738
+ if (!filePath || !existsSync(filePath)) return empty;
739
+ try {
740
+ return parseCodexMCPServersToml(readFileSync(filePath, 'utf8'), source);
741
+ } catch {
742
+ return empty;
743
+ }
744
+ }
745
+
746
+ function mergeMCPConfigResults(results) {
747
+ const servers = [];
748
+ const skipped = [];
749
+ const seen = new Set();
750
+ for (const result of results) {
751
+ for (const s of result.servers || []) {
752
+ if (!s?.name || seen.has(s.name)) continue;
753
+ seen.add(s.name);
754
+ servers.push(s);
755
+ }
756
+ skipped.push(...(result.skipped || []));
757
+ }
639
758
  return { servers, skipped };
640
759
  }
760
+
761
+ function loadExternalUserMCPServers() {
762
+ const home = homedir();
763
+ if (!home) return { servers: [], skipped: [] };
764
+ return mergeMCPConfigResults([
765
+ loadClaudeMCPJsonFile(join(home, '.claude.json'), '~/.claude.json'),
766
+ loadCodexMCPConfigFile(join(home, '.codex', 'config.toml'), '~/.codex/config.toml'),
767
+ ]);
768
+ }
769
+
770
+ /**
771
+ * Parse project-level borrowed MCP configs from `<workDir>/.mcp.json` (Claude
772
+ * Code) and `<workDir>/.codex/config.toml` (Codex).
773
+ *
774
+ * Claude Code format:
775
+ * { "mcpServers": { "<name>": { command, args?, env?, url?, type? } } }
776
+ *
777
+ * Codex format subset:
778
+ * [mcp_servers.<name>]
779
+ * command = "..."
780
+ * args = ["..."]
781
+ *
782
+ * Only stdio servers (those with a `command`) are adapted into yeaft's
783
+ * { name, command, args?, env? } shape. SSE/HTTP servers (url/type, no
784
+ * command) cannot be spawned by the current MCPManager, so they're reported
785
+ * in `skipped` with reason 'unsupported-transport' rather than silently
786
+ * dropped or surfaced later as spawn failures.
787
+ *
788
+ * Robust by design: missing files, malformed JSON/TOML, or non-object
789
+ * `mcpServers` fields all return gracefully with empty arrays — broken borrowed
790
+ * configs must never fail session creation.
791
+ *
792
+ * @param {string} workDir — project working directory
793
+ * @returns {{ servers: object[], skipped: { name: string, reason: string, source: string }[] }}
794
+ */
795
+ export function loadProjectMCPServers(workDir) {
796
+ const empty = { servers: [], skipped: [] };
797
+ if (!workDir || typeof workDir !== 'string') return empty;
798
+
799
+ return mergeMCPConfigResults([
800
+ loadClaudeMCPJsonFile(join(workDir, '.mcp.json'), '.mcp.json'),
801
+ loadCodexMCPConfigFile(join(workDir, '.codex', 'config.toml'), '.codex/config.toml'),
802
+ ]);
803
+ }
package/yeaft/skills.js CHANGED
@@ -31,17 +31,23 @@
31
31
  * tier 1 (bundled): wherever yeaft-skills is installed on disk — typically
32
32
  * ~/.claude/skills/yeaft-skills/skills/. Read-only — `save()` and
33
33
  * `remove()` never target this tier.
34
- * tier 2 (user): <yeaftDir>/skills (e.g. ~/.yeaft/skills). User edits
34
+ * tier 2 (user-claude): ~/.claude/skills. User-level Claude Code assets,
35
+ * loaded read-only for cross-tool compatibility.
36
+ * tier 3 (user-codex): ~/.codex/skills. User-level Codex assets, loaded
37
+ * read-only for cross-tool compatibility.
38
+ * tier 4 (user): <yeaftDir>/skills (e.g. ~/.yeaft/skills). User edits
35
39
  * land here. `save()` writes here. `init.js` seeds it from tier 1
36
40
  * on first boot so users start with the full bundled set.
37
- * tier 3 (project-claude): <workDir>/.claude/skills (if provided). Claude
41
+ * tier 5 (project-claude): <workDir>/.claude/skills (if provided). Claude
38
42
  * Code project assets, loaded so a Claude-Code-integrated project
39
43
  * works out of the box. Higher than user (project-local beats
40
44
  * user-global), lower than the yeaft-native project tier.
41
- * tier 4 (project): <workDir>/.yeaft/skills (if provided). Highest
45
+ * tier 6 (project-codex): <workDir>/.agents/skills (if provided). Codex
46
+ * project assets, loaded with the same project-local precedence.
47
+ * tier 7 (project): <workDir>/.yeaft/skills (if provided). Highest
42
48
  * priority — a project can pin a skill version without affecting
43
49
  * the user's other projects, and overrides a borrowed
44
- * `.claude/skills` skill of the same name.
50
+ * `.claude/skills` / `.agents/skills` skill of the same name.
45
51
  *
46
52
  * Reference: yeaft-yeaft-design.md §8, yeaft-yeaft-core-systems.md
47
53
  */
@@ -207,8 +213,19 @@ function listSubdirFiles(dir) {
207
213
  * @param {string} [subPath] — relative path from root (for category derivation)
208
214
  * @returns {{ skills: Skill[], errors: string[] }}
209
215
  */
210
- function discoverSkills(rootDir, subPath = '') {
216
+ function pathIsInside(childPath, parentPath) {
217
+ const child = resolve(childPath);
218
+ const parent = resolve(parentPath);
219
+ return child === parent || child.startsWith(parent + sep);
220
+ }
221
+
222
+ function shouldIgnorePath(candidatePath, ignorePaths) {
223
+ return ignorePaths.some(ignorePath => pathIsInside(candidatePath, ignorePath));
224
+ }
225
+
226
+ function discoverSkills(rootDir, subPath = '', opts = {}) {
211
227
  const dir = subPath ? join(rootDir, subPath) : rootDir;
228
+ const ignorePaths = Array.isArray(opts.ignorePaths) ? opts.ignorePaths : [];
212
229
  const skills = [];
213
230
  const errors = [];
214
231
 
@@ -226,6 +243,8 @@ function discoverSkills(rootDir, subPath = '') {
226
243
  const entryPath = join(dir, entry.name);
227
244
  const relPath = subPath ? join(subPath, entry.name) : entry.name;
228
245
 
246
+ if (shouldIgnorePath(entryPath, ignorePaths)) continue;
247
+
229
248
  if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
230
249
  // Single-file skill (legacy format)
231
250
  try {
@@ -272,7 +291,7 @@ function discoverSkills(rootDir, subPath = '') {
272
291
  }
273
292
  } else {
274
293
  // No SKILL.md — treat as category directory, recurse
275
- const sub = discoverSkills(rootDir, relPath);
294
+ const sub = discoverSkills(rootDir, relPath, opts);
276
295
  skills.push(...sub.skills);
277
296
  errors.push(...sub.errors);
278
297
  }
@@ -362,17 +381,22 @@ export class SkillManager {
362
381
  /** @type {Map<string, string>} — dir path → tier label */
363
382
  #tierByDir;
364
383
 
384
+ /** @type {Map<string, string[]>} — dir path → resolved ignore paths */
385
+ #ignorePathsByDir;
386
+
365
387
  /**
366
388
  * @param {string | string[]} dirs — single directory (back-compat) or array of
367
389
  * directories in priority order (lowest → highest). Falsy entries are
368
390
  * filtered out so callers can write `[bundled, user, projectOrNull]`.
369
- * @param {{ userDir?: string, tierByDir?: Record<string, string> }} [opts]
391
+ * @param {{ userDir?: string, tierByDir?: Record<string, string>, ignorePathsByDir?: Record<string, string[]> }} [opts]
370
392
  * userDir: directory where `save()` and `remove()` write. Defaults to the
371
393
  * last entry in `dirs` (typical case: user dir is highest priority that
372
394
  * isn't a per-project layer).
373
395
  * tierByDir: optional label map — dir path → 'bundled' | 'user' | 'project'.
374
396
  * Decorates each discovered Skill with `_tier` for diagnostics (Settings
375
397
  * UI uses this to show "where this skill came from").
398
+ * ignorePathsByDir: optional map of scan dir → subtrees to skip while
399
+ * recursively discovering skills in that dir.
376
400
  */
377
401
  constructor(dirs, opts = {}) {
378
402
  const list = Array.isArray(dirs)
@@ -393,6 +417,13 @@ export class SkillManager {
393
417
  }
394
418
  }
395
419
  }
420
+ this.#ignorePathsByDir = new Map();
421
+ if (opts && opts.ignorePathsByDir && typeof opts.ignorePathsByDir === 'object') {
422
+ for (const [d, ignorePaths] of Object.entries(opts.ignorePathsByDir)) {
423
+ if (typeof d !== 'string' || !Array.isArray(ignorePaths)) continue;
424
+ this.#ignorePathsByDir.set(d, ignorePaths.filter(p => typeof p === 'string' && p.length > 0).map(p => resolve(p)));
425
+ }
426
+ }
396
427
  }
397
428
 
398
429
  /** The user-writable skills directory (save/remove target). */
@@ -425,7 +456,7 @@ export class SkillManager {
425
456
 
426
457
  for (const dir of this.#skillsDirs) {
427
458
  if (!existsSync(dir)) continue;
428
- const { skills, errors } = discoverSkills(dir);
459
+ const { skills, errors } = discoverSkills(dir, '', { ignorePaths: this.#ignorePathsByDir.get(dir) || [] });
429
460
  const tier = this.#tierByDir.get(dir) || basename(dir);
430
461
  for (const skill of skills) {
431
462
  // Platform filtering at load time
@@ -701,14 +732,19 @@ export class SkillManager {
701
732
  * Tier order (lowest → highest priority):
702
733
  * 1. bundled — the yeaft-skills package on disk, located via
703
734
  * `bundledYeaftSkillsDir()` (typically ~/.claude/skills/yeaft-skills/skills/).
704
- * 2. user `<yeaftDir>/skills` (e.g. ~/.yeaft/skills). User edits + saves.
705
- * 3. project-claude `<workDir>/.claude/skills` when a workDir is provided.
735
+ * 2. user-claude `~/.claude/skills`, read-only borrowed Claude Code assets.
736
+ * 3. user-codex `~/.codex/skills`, read-only borrowed Codex assets.
737
+ * 4. user — `<yeaftDir>/skills` (e.g. ~/.yeaft/skills). User edits + saves.
738
+ * 5. project-claude — `<workDir>/.claude/skills` when a workDir is provided.
706
739
  * Claude Code project assets, loaded so a project that integrates Claude
707
740
  * Code works out of the box. Ranks above `user` (project-local beats
708
741
  * user-global) but below the yeaft-native project tier.
709
- * 4. project — `<workDir>/.yeaft/skills` when a workDir is provided.
742
+ * 6. project-codex — `<workDir>/.agents/skills` when a workDir is provided.
743
+ * Codex project assets, loaded so Codex-integrated repositories work out
744
+ * of the box. Same precedence band as project Claude Code assets.
745
+ * 7. project — `<workDir>/.yeaft/skills` when a workDir is provided.
710
746
  * Highest priority: a yeaft-native skill pinned in the project overrides
711
- * a borrowed `.claude/skills` skill of the same name.
747
+ * a borrowed `.claude/skills` / `.agents/skills` skill of the same name.
712
748
  *
713
749
  * `save()` / `remove()` always target the USER tier, matching Claude Code.
714
750
  *
@@ -718,18 +754,32 @@ export class SkillManager {
718
754
  */
719
755
  export function createSkillManager(yeaftDir, workDir) {
720
756
  const bundled = bundledYeaftSkillsDir();
757
+ const home = homedir();
758
+ const claudeUserDir = home ? join(home, '.claude', 'skills') : null;
759
+ const codexUserDir = home ? join(home, '.codex', 'skills') : null;
721
760
  const userDir = join(yeaftDir, 'skills');
722
761
  const claudeProjectDir = workDir ? join(workDir, '.claude', 'skills') : null;
762
+ const codexProjectDir = workDir ? join(workDir, '.agents', 'skills') : null;
723
763
  const projectDir = workDir ? join(workDir, '.yeaft', 'skills') : null;
724
764
 
725
- const dirs = [bundled, userDir, claudeProjectDir, projectDir].filter(Boolean);
765
+ const dirs = [bundled, claudeUserDir, codexUserDir, userDir, claudeProjectDir, codexProjectDir, projectDir].filter(Boolean);
726
766
  const tierByDir = {};
727
767
  if (bundled) tierByDir[bundled] = 'bundled';
768
+ if (claudeUserDir) tierByDir[claudeUserDir] = 'user-claude';
769
+ if (codexUserDir) tierByDir[codexUserDir] = 'user-codex';
728
770
  tierByDir[userDir] = 'user';
729
771
  if (claudeProjectDir) tierByDir[claudeProjectDir] = 'project-claude';
772
+ if (codexProjectDir) tierByDir[codexProjectDir] = 'project-codex';
730
773
  if (projectDir) tierByDir[projectDir] = 'project';
731
774
 
732
- const manager = new SkillManager(dirs, { userDir, tierByDir });
775
+ const ignorePathsByDir = {};
776
+ if (claudeUserDir && bundled && pathIsInside(bundled, claudeUserDir)) {
777
+ // Borrowed ~/.claude/skills must not recurse into the Yeaft bundled plugin
778
+ // package; bundled skills have their own lower-priority tier/provenance.
779
+ ignorePathsByDir[claudeUserDir] = [join(claudeUserDir, 'yeaft-skills')];
780
+ }
781
+
782
+ const manager = new SkillManager(dirs, { userDir, tierByDir, ignorePathsByDir });
733
783
  manager.load();
734
784
  return manager;
735
785
  }