Skip to content

Commit 2910699

Browse files
ithiria894claude
andcommitted
refactor: extract effective logic into shared module (v0.14.0)
New: src/effective.mjs — pure functions shared between dashboard and tests. Contains: EFFECTIVE_RULES, hasEffectiveRule, getAncestorScopes, computeEffectiveSets, getEffectiveItems. - app.js: imports from window.Effective (served as IIFE by server) - Unit tests: import directly from effective.mjs (ES module) - Same code path for production and tests — no re-implementation - server.mjs: serves effective.mjs with export keywords stripped + IIFE wrapped - Fixed: renderRuleBar used wrong variable name (category → cat) 35 unit tests pass, all testing the actual production module. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 23cde9d commit 2910699

7 files changed

Lines changed: 290 additions & 250 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcpware/claude-code-organizer",
3-
"version": "0.13.16",
3+
"version": "0.14.0",
44
"description": "Organize all your Claude Code memories, skills, MCP servers, commands, agents, rules, and hooks — see what loads globally vs per-project, then move items between scopes",
55
"type": "module",
66
"files": [

src/effective.mjs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* effective.mjs — Per-category effective resolution logic.
3+
*
4+
* Pure functions. No DOM, no UI, no side effects.
5+
* Shared between the dashboard frontend (app.js) and unit tests.
6+
*
7+
* Claude Code does not use one universal scope model. Each category
8+
* has its own official rules for availability, precedence, and inheritance.
9+
* This module implements those rules.
10+
*/
11+
12+
/**
13+
* Categories that have an official effective rule.
14+
* Only these participate in "Show Effective" mode.
15+
*/
16+
export const EFFECTIVE_RULES = {
17+
skill: "Available from Personal (~/.claude/skills), Project (.claude/skills), and installed Plugins",
18+
mcp: "Resolved by local > project > user — same-name servers use the narrower scope",
19+
command: "Available from User and Project — same-name conflicts are not supported",
20+
agent: "Project-level agents override same-name User agents",
21+
config: "Resolved by precedence: managed > CLI > project local > project shared > user",
22+
hook: "Configured in settings files — resolved by settings precedence",
23+
memory: "Global memories are available in all projects; project memories are specific to this project",
24+
};
25+
26+
/**
27+
* Returns true if a category participates in Show Effective.
28+
*/
29+
export function hasEffectiveRule(category) {
30+
return category in EFFECTIVE_RULES;
31+
}
32+
33+
/**
34+
* Find scopes whose repoDir is a path ancestor of the given scope.
35+
* e.g. /work/company is an ancestor of /work/company/repo-a.
36+
*
37+
* @param {string} scopeId - The scope to find ancestors for
38+
* @param {Array} scopes - All scopes
39+
* @returns {Array} Ancestor scopes (deepest first)
40+
*/
41+
export function getAncestorScopes(scopeId, scopes) {
42+
const scope = scopes.find(s => s.id === scopeId);
43+
if (!scope?.repoDir) return [];
44+
return scopes.filter(s =>
45+
s.repoDir &&
46+
s.id !== scopeId &&
47+
s.id !== "global" &&
48+
scope.repoDir.startsWith(s.repoDir + "/")
49+
);
50+
}
51+
52+
/**
53+
* Compute which items are shadowed, conflicted, or from ancestor scopes.
54+
*
55+
* @param {string} scopeId - Currently selected project scope
56+
* @param {Array} allItems - All items from all scopes
57+
* @param {Array} scopes - All scopes
58+
* @param {Function} keyFn - Function to generate a unique key for an item
59+
* @returns {{ shadowedKeys: Set, conflictKeys: Set, ancestorKeys: Set }}
60+
*/
61+
export function computeEffectiveSets(scopeId, allItems, scopes, keyFn) {
62+
const shadowedKeys = new Set();
63+
const conflictKeys = new Set();
64+
const ancestorKeys = new Set();
65+
66+
if (!scopeId || scopeId === "global") {
67+
return { shadowedKeys, conflictKeys, ancestorKeys };
68+
}
69+
70+
const projectItems = allItems.filter(i => i.scopeId === scopeId);
71+
const globalItems = allItems.filter(i => i.scopeId === "global");
72+
73+
// MCP & Agents: narrower scope (project) wins same-name
74+
for (const cat of ["mcp", "agent"]) {
75+
const projectNames = new Set(
76+
projectItems.filter(i => i.category === cat).map(i => i.name)
77+
);
78+
for (const gi of globalItems.filter(i => i.category === cat)) {
79+
if (projectNames.has(gi.name)) shadowedKeys.add(keyFn(gi));
80+
}
81+
}
82+
83+
// Commands: both levels available but same-name conflicts are officially unsupported
84+
const projCmdNames = new Set(projectItems.filter(i => i.category === "command").map(i => i.name));
85+
const globalCmdNames = new Set(globalItems.filter(i => i.category === "command").map(i => i.name));
86+
for (const name of projCmdNames) {
87+
if (!globalCmdNames.has(name)) continue;
88+
for (const i of [...projectItems, ...globalItems].filter(i => i.category === "command" && i.name === name)) {
89+
conflictKeys.add(keyFn(i));
90+
}
91+
}
92+
93+
// Ancestor scopes: parent directories whose config/memory items are relevant
94+
const ancestors = getAncestorScopes(scopeId, scopes);
95+
for (const as of ancestors) {
96+
for (const i of allItems.filter(i => i.scopeId === as.id && (i.category === "config" || i.category === "memory"))) {
97+
ancestorKeys.add(keyFn(i));
98+
}
99+
}
100+
101+
return { shadowedKeys, conflictKeys, ancestorKeys };
102+
}
103+
104+
/**
105+
* Get the effective items for a project scope.
106+
* Includes: project items + global items (for participating categories) + ancestor items.
107+
*
108+
* @param {string} scopeId - Currently selected scope
109+
* @param {Array} allItems - All items from all scopes
110+
* @param {Array} scopes - All scopes
111+
* @returns {Array} Effective items
112+
*/
113+
export function getEffectiveItems(scopeId, allItems, scopes) {
114+
const projectItems = allItems.filter(i => i.scopeId === scopeId);
115+
116+
if (scopeId === "global") return projectItems;
117+
118+
// Global items only for categories with official rules
119+
const effectiveGlobal = allItems.filter(
120+
i => i.scopeId === "global" && hasEffectiveRule(i.category)
121+
);
122+
123+
// Ancestor scope items (config + memory from path-parent scopes)
124+
const ancestorItems = [];
125+
for (const as of getAncestorScopes(scopeId, scopes)) {
126+
ancestorItems.push(
127+
...allItems.filter(i => i.scopeId === as.id && (i.category === "config" || i.category === "memory"))
128+
);
129+
}
130+
131+
return [...projectItems, ...effectiveGlobal, ...ancestorItems];
132+
}
133+
134+
// Browser: attach to window so app.js can use it via <script> tag
135+
if (typeof window !== "undefined") {
136+
window.Effective = { EFFECTIVE_RULES, hasEffectiveRule, getAncestorScopes, computeEffectiveSets, getEffectiveItems };
137+
}

src/server.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,16 @@ async function handleRequest(req, res) {
858858
if (path === "/app.js") {
859859
return serveFile(res, join(UI_DIR, "app.js"));
860860
}
861+
if (path === "/effective.mjs") {
862+
// Serve as browser-compatible IIFE: strip ES exports, wrap in scope
863+
// Node.js unit tests import the .mjs directly; browser uses window.Effective
864+
try {
865+
const content = await readFile(join(import.meta.dirname, "effective.mjs"), "utf-8");
866+
const browserCode = "(function(){\n" + content.replace(/^export /gm, "") + "\n})();";
867+
res.writeHead(200, { "Content-Type": "application/javascript" });
868+
return res.end(browserCode);
869+
} catch { res.writeHead(404); return res.end(); }
870+
}
861871

862872
// Suppress favicon 404
863873
if (path === "/favicon.ico") {

src/ui/app.js

Lines changed: 44 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
* move, delete, and undo behaviors.
77
*/
88

9+
// Effective resolution logic — loaded from /effective.mjs before app.js
10+
const { EFFECTIVE_RULES, hasEffectiveRule, getAncestorScopes: _getAncestorScopes,
11+
computeEffectiveSets: _computeEffectiveSets, getEffectiveItems } = window.Effective;
12+
13+
// Helper: get effectiveRule for a category (returns string or undefined)
14+
function getEffectiveRule(category) { return EFFECTIVE_RULES[category] || null; }
15+
916
let data = null;
1017
let activeFilters = new Set();
1118
let selectedItem = null;
@@ -37,28 +44,17 @@ const uiState = {
3744
const CATEGORY_ORDER = ["skill", "memory", "mcp", "command", "agent", "plan", "rule", "config", "hook", "plugin", "session"];
3845

3946
const CATEGORIES = {
40-
memory: { icon: "🧠", label: "Memories", filterLabel: "Memories", group: "memory",
41-
effectiveRule: "Global memories are available in all projects; project memories are specific to this project" },
42-
skill: { icon: "⚡", label: "Skills", filterLabel: "Skills", group: "skill",
43-
effectiveRule: "Available from Personal (~/.claude/skills), Project (.claude/skills), and installed Plugins" },
44-
session:{ icon: "💬", label: "Sessions", filterLabel: "Sessions", group: null,
45-
effectiveRule: null }, // project-only, no inheritance
46-
mcp: { icon: "🔌", label: "MCP Servers", filterLabel: "MCP", group: "mcp",
47-
effectiveRule: "Resolved by local > project > user — same-name servers use the narrower scope" },
48-
command:{ icon: "▶️", label: "Commands", filterLabel: "Commands", group: "command",
49-
effectiveRule: "Available from User and Project — same-name conflicts are not supported" },
50-
agent: { icon: "🤖", label: "Agents", filterLabel: "Agents", group: "agent",
51-
effectiveRule: "Project-level agents override same-name User agents" },
52-
plan: { icon: "📐", label: "Plans", filterLabel: "Plans", group: "plan",
53-
effectiveRule: null }, // no clear official scope rule
54-
rule: { icon: "📏", label: "Rules", filterLabel: "Rules", group: null,
55-
effectiveRule: null }, // no clear official scope rule
56-
config: { icon: "⚙️", label: "Config", filterLabel: "Config", group: null,
57-
effectiveRule: "Resolved by precedence: managed > CLI > project local > project shared > user" },
58-
hook: { icon: "🪝", label: "Hooks", filterLabel: "Hooks", group: null,
59-
effectiveRule: "Configured in settings files — resolved by settings precedence" },
60-
plugin: { icon: "🧩", label: "Plugins", filterLabel: "Plugins", group: null,
61-
effectiveRule: null }, // global-only, no inheritance rule
47+
memory: { icon: "🧠", label: "Memories", filterLabel: "Memories", group: "memory" },
48+
skill: { icon: "⚡", label: "Skills", filterLabel: "Skills", group: "skill" },
49+
session:{ icon: "💬", label: "Sessions", filterLabel: "Sessions", group: null },
50+
mcp: { icon: "🔌", label: "MCP Servers", filterLabel: "MCP", group: "mcp" },
51+
command:{ icon: "▶️", label: "Commands", filterLabel: "Commands", group: "command" },
52+
agent: { icon: "🤖", label: "Agents", filterLabel: "Agents", group: "agent" },
53+
plan: { icon: "📐", label: "Plans", filterLabel: "Plans", group: "plan" },
54+
rule: { icon: "📏", label: "Rules", filterLabel: "Rules", group: null },
55+
config: { icon: "⚙️", label: "Config", filterLabel: "Config", group: null },
56+
hook: { icon: "🪝", label: "Hooks", filterLabel: "Hooks", group: null },
57+
plugin: { icon: "🧩", label: "Plugins", filterLabel: "Plugins", group: null },
6258
};
6359

6460
const ITEM_ICONS = {
@@ -696,7 +692,7 @@ function renderPills() {
696692
: data?.items || [];
697693
if (showEffective && selectedScopeId && selectedScopeId !== "global") {
698694
const globalItems = (data?.items || []).filter(
699-
(i) => i.scopeId === "global" && Boolean(CATEGORIES[i.category]?.effectiveRule)
695+
(i) => i.scopeId === "global" && Boolean(getEffectiveRule(i.category))
700696
);
701697
scopeItems = [...scopeItems, ...globalItems];
702698
}
@@ -712,13 +708,13 @@ function renderPills() {
712708
{ key: "all", label: "All", icon: "◌", count: scopeTotal, tip: null },
713709
...CATEGORY_ORDER.map((category) => {
714710
const config = CATEGORIES[category] || { icon: "📄", filterLabel: capitalize(category) };
715-
const hasRule = Boolean(config.effectiveRule);
711+
const hasRule = Boolean(getEffectiveRule(category));
716712
return {
717713
key: category,
718714
label: config.filterLabel,
719715
icon: config.icon,
720716
count: scopeCounts[category] || 0,
721-
tip: config.effectiveRule || (showEffective ? NO_RULE_TIP : null),
717+
tip: getEffectiveRule(category) || (showEffective ? NO_RULE_TIP : null),
722718
noRule: showEffective && !hasRule,
723719
};
724720
}),
@@ -777,7 +773,7 @@ function renderRuleBar() {
777773
const rows = CATEGORY_ORDER.map(cat => {
778774
const config = CATEGORIES[cat];
779775
if (!config) return "";
780-
const rule = config.effectiveRule;
776+
const rule = getEffectiveRule(cat);
781777
const noRule = !rule;
782778
return `<div class="rule-row${noRule ? " rule-none" : ""}">
783779
<span class="rule-cat">${config.icon} ${esc(config.filterLabel)}</span>
@@ -1096,7 +1092,7 @@ function renderEffectiveBehavior(item) {
10961092
: "This memory is stored in this project's memory directory.";
10971093
break;
10981094
default:
1099-
why = CATEGORIES[item.category]?.effectiveRule || "";
1095+
why = getEffectiveRule(item.category) || "";
11001096
}
11011097

11021098
if (!why) { wrap.classList.add("hidden"); return; }
@@ -2510,82 +2506,37 @@ function findVisibleScopeInTree(scope) {
25102506
* and which items have unresolvable name conflicts (commands).
25112507
* Called whenever showEffective toggles or scope changes.
25122508
*/
2509+
// getAncestorScopes — delegated to shared effective.mjs (_getAncestorScopes)
2510+
25132511
/**
2514-
* Returns scopes whose repoDir is a path ancestor of the given scope's repoDir.
2515-
* e.g. if scopeId is /home/user/company/repo, returns the scope for /home/user/company if it exists.
2512+
* Wrapper: delegates to shared effective.mjs module for computation,
2513+
* then stores results in app-level state (effectiveShadowedKeys etc).
25162514
*/
2517-
function getAncestorScopes(scopeId) {
2518-
const scope = getScopeById(scopeId);
2519-
if (!scope?.repoDir) return [];
2520-
return (data?.scopes || []).filter(s =>
2521-
s.repoDir &&
2522-
s.id !== scopeId &&
2523-
s.id !== "global" &&
2524-
scope.repoDir.startsWith(s.repoDir + "/")
2525-
);
2526-
}
2527-
25282515
function computeEffectiveSets(scopeId) {
2529-
effectiveShadowedKeys = new Set();
2530-
effectiveConflictKeys = new Set();
2531-
effectiveAncestorKeys = new Set();
2532-
if (!showEffective || !scopeId || scopeId === "global") return;
2533-
2534-
const projectItems = getItemsForScope(scopeId);
2535-
const globalItems = getItemsForScope("global");
2536-
2537-
// MCP & Agents: narrower scope (project) wins same-name
2538-
for (const cat of ["mcp", "agent"]) {
2539-
const projectNames = new Set(
2540-
projectItems.filter(i => i.category === cat).map(i => i.name)
2541-
);
2542-
for (const gi of globalItems.filter(i => i.category === cat)) {
2543-
if (projectNames.has(gi.name)) effectiveShadowedKeys.add(itemKey(gi));
2544-
}
2545-
}
2546-
2547-
// Commands: both levels available but same-name conflicts are officially unsupported
2548-
const projCmdNames = new Set(projectItems.filter(i => i.category === "command").map(i => i.name));
2549-
const globalCmdNames = new Set(globalItems.filter(i => i.category === "command").map(i => i.name));
2550-
for (const name of projCmdNames) {
2551-
if (!globalCmdNames.has(name)) continue;
2552-
for (const i of [...projectItems, ...globalItems].filter(i => i.category === "command" && i.name === name)) {
2553-
effectiveConflictKeys.add(itemKey(i));
2554-
}
2555-
}
2556-
2557-
// Ancestor scopes: scopes whose repoDir is a path parent of this project.
2558-
// Their config items (especially CLAUDE.md) are ancestor-loaded by Claude Code.
2559-
const ancestorScopes = getAncestorScopes(scopeId);
2560-
for (const as of ancestorScopes) {
2561-
for (const i of getItemsForScope(as.id).filter(i => i.category === "config" || i.category === "memory")) {
2562-
effectiveAncestorKeys.add(itemKey(i));
2563-
}
2516+
if (!showEffective || !scopeId || scopeId === "global") {
2517+
effectiveShadowedKeys = new Set();
2518+
effectiveConflictKeys = new Set();
2519+
effectiveAncestorKeys = new Set();
2520+
return;
25642521
}
2522+
const result = _computeEffectiveSets(scopeId, data?.items || [], data?.scopes || [], itemKey);
2523+
effectiveShadowedKeys = result.shadowedKeys;
2524+
effectiveConflictKeys = result.conflictKeys;
2525+
effectiveAncestorKeys = result.ancestorKeys;
25652526
}
25662527

25672528
function getVisibleItemsForScope(scopeId) {
25682529
const ownItems = getItemsForScope(scopeId).filter((item) => itemVisibleInMain(item));
25692530
if (!showEffective || scopeId === "global") return ownItems;
25702531

2571-
// Only add global items for categories that have an official effective rule
2572-
const globalItems = getItemsForScope("global").filter((item) => {
2573-
if (!itemMatchesFilters(item) || !itemMatchesSearch(item)) return false;
2574-
return Boolean(CATEGORIES[item.category]?.effectiveRule);
2575-
});
2576-
2577-
// Add ancestor scope items (CLAUDE.md + memories from path-parent projects)
2578-
const ancestorItems = [];
2579-
for (const as of getAncestorScopes(scopeId)) {
2580-
for (const item of getItemsForScope(as.id)) {
2581-
if ((item.category === "config" || item.category === "memory") &&
2582-
itemMatchesFilters(item) && itemMatchesSearch(item)) {
2583-
ancestorItems.push(item);
2584-
}
2585-
}
2586-
}
2587-
2588-
return [...ownItems, ...globalItems, ...ancestorItems];
2532+
// Use shared module for effective resolution, then apply UI filters
2533+
const allEffective = getEffectiveItems(scopeId, data?.items || [], data?.scopes || []);
2534+
// Filter to non-own items that pass current UI filters
2535+
const ownKeys = new Set(ownItems.map(i => itemKey(i)));
2536+
const extra = allEffective.filter(i =>
2537+
!ownKeys.has(itemKey(i)) && itemMatchesFilters(i) && itemMatchesSearch(i)
2538+
);
2539+
return [...ownItems, ...extra];
25892540
}
25902541

25912542
function itemVisibleInMain(item) {

src/ui/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ <h3>Delete Item</h3>
239239
// Server auto-shuts down when all tabs close.
240240
new EventSource("/heartbeat");
241241
</script>
242+
<script src="/effective.mjs"></script>
242243
<script src="/app.js"></script>
243244
</body>
244245
</html>

0 commit comments

Comments
 (0)