diff --git a/lib/agents.js b/lib/agents.js index 4e87ce53..9b173d62 100644 --- a/lib/agents.js +++ b/lib/agents.js @@ -223,12 +223,56 @@ function discoverAllAgents() { return getAll(); } +// Read all named-agent definitions from a project-local .claude/agents/ directory. +// +// projectDir: absolute path to the project root (cwd). +// Returns: array of { name, slug, description } objects, sorted by name. +// Returns [] if the directory does not exist or is unreadable — callers treat +// an empty array as "no project-local agents" and degrade gracefully. +// +// This reads the filesystem directly (no SDK subprocess) — it is synchronous +// and cheap. The agents directory is small (typically 0–20 files). +function readProjectAgents(projectDir) { + if (!projectDir || typeof projectDir !== "string") return []; + var agentsDir = path.join(projectDir, ".claude", "agents"); + var entries; + try { + entries = fs.readdirSync(agentsDir); + } catch (e) { + // Directory absent or unreadable — not an error. + return []; + } + var results = []; + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + if (!entry || !entry.endsWith(".md")) continue; + var slug = entry.slice(0, -3); // strip .md + if (!slug) continue; + var filePath = path.join(agentsDir, entry); + var raw; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch (e) { + continue; + } + var parsed = parseFrontmatter(raw); + var name = (parsed && parsed.meta && parsed.meta.name) ? String(parsed.meta.name) : slug; + var description = (parsed && parsed.meta && parsed.meta.description) ? String(parsed.meta.description) : ""; + results.push({ name: name, slug: slug, description: description }); + } + results.sort(function (a, b) { + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); + return results; +} + module.exports = { AGENTS_SOURCE_DIR: AGENTS_SOURCE_DIR, PLUGINS_CACHE_DIR: PLUGINS_CACHE_DIR, parseFrontmatter: parseFrontmatter, slugifyAgentName: slugifyAgentName, readAgentToolsFromFile: readAgentToolsFromFile, + readProjectAgents: readProjectAgents, // New SDK-backed surface: refresh: refresh, getAll: getAll, diff --git a/lib/project-sessions.js b/lib/project-sessions.js index fe6cd640..cb5853b2 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -183,6 +183,17 @@ function attachSessions(ctx) { return true; } + // lr-c1a2 — get_agents returns agents installed in the project's + // .claude/agents/ directory (not the SDK global catalog). Used by the + // @ mention dropdown in the session input to offer project-local agents. + if (msg.type === "get_agents") { + var projectAgents = []; + try { projectAgents = agentsModule.readProjectAgents(cwd); } + catch (e) { console.error("[project-sessions] get_agents failed:", e && e.message ? e.message : e); } + sendTo(ws, { type: "project_agents_list", agents: projectAgents }); + return true; + } + // toggle_agent_favorite flips an agent's membership in chattable.json. // Required field: name. if (msg.type === "toggle_agent_favorite") { diff --git a/lib/public/css/at-agents.css b/lib/public/css/at-agents.css new file mode 100644 index 00000000..edb39cc6 --- /dev/null +++ b/lib/public/css/at-agents.css @@ -0,0 +1,68 @@ +/* ========================================================================== + @ Agent Autocomplete Dropdown (lr-c1a2) + ========================================================================== */ + +.at-agents-menu { + display: none; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + max-height: 220px; + overflow-y: auto; + background: var(--sidebar-bg); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 6px; + box-shadow: 0 4px 12px rgba(var(--shadow-rgb), 0.15); + z-index: 11; +} + +.at-agents-menu.visible { display: block; } + +.at-agents-item { + display: flex; + align-items: baseline; + padding: 8px 14px; + cursor: pointer; + gap: 8px; + border-bottom: 1px solid var(--border-subtle); +} + +.at-agents-item:last-child { border-bottom: none; } + +.at-agents-item:hover, +.at-agents-item.active { + background: var(--sidebar-active); +} + +.at-agents-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + flex-shrink: 0; +} + +.at-agents-badge { + font-size: 10px; + font-weight: 600; + color: var(--text-dimmer); + background: var(--border-subtle); + border-radius: 4px; + padding: 1px 5px; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + flex-shrink: 0; +} + +.at-agents-desc { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index e857150d..99333afd 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -15,6 +15,7 @@ import { updateDmBadge, renderSidebarPresence, setMentionActive, renderUserStrip import { refreshMobileChatSheet } from './sidebar-mobile.js'; import { handlePaletteSessionSwitch, setPaletteVersion } from './command-palette.js'; import { handleAgentsList, handleAgentFavoriteToggled } from './agent-picker.js'; +import { handleProjectAgentsList, requestProjectAgents } from './at-agents.js'; import { handleFindInSessionResults } from './session-search.js'; import { handleInputSync, autoResize, resetAutoResize, builtinCommands, setScheduleBtnDisabled } from './input.js'; import { startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, closeToolGroup, removeToolFromGroup, resetToolState, getTools, getPlanContent, setPlanContent, renderPlanBanner, renderPlanCard, getTodoTools, handleTodoWrite, handleTaskCreate, handleTaskUpdate, applyDeadSessionTodoCompaction, isPlanFilePath, enableMainInput, addTurnMeta, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, initSubagentStop, updateSubagentProgress, updateSubagentTaskStatus, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionCancelled, markPermissionResolved, renderElicitationRequest, markElicitationResolved } from './tools.js'; @@ -459,6 +460,10 @@ export function processMessage(msg) { handleAgentFavoriteToggled(msg); break; + case "project_agents_list": + handleProjectAgentsList(msg); + break; + case "session_presence": updateSessionPresence(msg.presence || {}); break; @@ -498,6 +503,10 @@ export function processMessage(msg) { break; case "session_switched": + // Prefetch project agents so the @ menu is ready without a round-trip + // the first time the user types @. Best-effort; at-agents.js handles + // an empty list gracefully. + requestProjectAgents(); hideHomeHub(); // Clear any stale replay indicator from a prior interrupted switch if (replayLoadingEl) replayLoadingEl.classList.add("hidden"); diff --git a/lib/public/modules/at-agents.js b/lib/public/modules/at-agents.js new file mode 100644 index 00000000..408d0adb --- /dev/null +++ b/lib/public/modules/at-agents.js @@ -0,0 +1,245 @@ +// at-agents.js — inline @ mention dropdown for project-local agents (lr-c1a2). +// +// When the user types @ in the session input this module shows a small inline +// autocomplete dropdown listing all agents defined in .claude/agents/ for the +// active project (from the server via the get_agents / project_agents_list WS +// round-trip). +// +// Selecting an agent starts a new session with that agentName — identical to +// what the "Agent Chat" button does in the sidebar. The @ text is cleared from +// the input after selection (the session switch replaces the conversation). +// +// The dropdown is hidden when the agent list is empty (no .claude/agents/). +// When no named agents are installed, the @ keystroke falls through to the +// mention menu (mention.js), which handles @user / @plain-vendor (including +// plain:codex) sends within an existing session. +// +// Integration with input.js: +// - input.js calls showAgentMenu(query) first; if the agent menu ends up +// empty (no matching agents) it falls through to showMentionMenu(query). +// - The two menus are mutually exclusive: if the agent menu is visible the +// mention menu is hidden, and vice versa. +// - input.js calls hideAgentMenu() on Escape, slash menu open, or send. +// - input.js checks isAgentMenuActive() before routing keydown events here. +// +// This module does NOT conflict with mention.js. Mention.js handles +// @user / @plain-vendor sends within an existing session. This module handles +// @agent, which *starts a new session* rather than sending a mention. Codex +// appears only in the mention menu (plain:codex) — it is NOT shown here so +// that the two entry points remain semantically distinct and Codex does not +// appear twice. + +import { escapeHtml } from './utils.js'; +import { getWs } from './ws-ref.js'; + +// --- Module state --- +var _ctx = null; +var _menuEl = null; +var _agents = []; // project-local agents from server: [{name, slug, description}] +var _items = []; // rendered items (filtered from _agents + codex entry) +var _activeIdx = -1; +var _menuVisible = false; +var _menuBound = false; +var _currentQuery = ""; + +// --- Public API --- + +export function initAtAgents(ctx) { + _ctx = ctx; + _ensureMenu(); + if (!_menuBound) { + _menuBound = true; + document.addEventListener("click", function (e) { + if (!_menuEl) return; + var item = e.target.closest(".at-agents-item"); + if (item) { + var idx = parseInt(item.dataset.idx, 10); + if (!isNaN(idx) && idx >= 0 && idx < _items.length) { + e.preventDefault(); + e.stopPropagation(); + _activateItem(_items[idx]); + } + return; + } + // Click outside the menu — hide it. + if (!_menuEl.contains(e.target)) { + hideAgentMenu(); + } + }); + } +} + +// Returns true when the @ agent menu is currently visible. +export function isAgentMenuActive() { + return _menuVisible; +} + +// Request a fresh project agent list from the server. Called once on WS open +// (or on session switch). The response arrives as project_agents_list and +// calls handleProjectAgentsList(). +export function requestProjectAgents() { + var ws = getWs(); + if (!ws || ws.readyState !== 1) return; + ws.send(JSON.stringify({ type: "get_agents" })); +} + +// Called from app-messages.js when project_agents_list arrives. +export function handleProjectAgentsList(msg) { + _agents = Array.isArray(msg.agents) ? msg.agents : []; + // Re-render if the menu is currently open. + if (_menuVisible) { + _render(_currentQuery); + } +} + +// Show the dropdown at the position of the input element. Filter by query. +// Called from input.js when @ is active. +export function showAgentMenu(query) { + _currentQuery = query || ""; + _ensureMenu(); + _render(_currentQuery); +} + +// Hide and clear the dropdown. +export function hideAgentMenu() { + if (!_menuVisible && !_menuEl) return; + _menuVisible = false; + _activeIdx = -1; + _items = []; + _currentQuery = ""; + if (_menuEl) { + _menuEl.classList.remove("visible"); + _menuEl.innerHTML = ""; + } +} + +// Keyboard navigation. Returns true if the event was consumed. +export function agentMenuKeydown(e) { + if (!_menuVisible || _items.length === 0) return false; + if (e.key === "ArrowDown") { + e.preventDefault(); + _setActive((_activeIdx + 1) % _items.length); + return true; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + _setActive((_activeIdx - 1 + _items.length) % _items.length); + return true; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { + e.preventDefault(); + if (_activeIdx >= 0 && _activeIdx < _items.length) { + _activateItem(_items[_activeIdx]); + } + return true; + } + if (e.key === "Escape") { + e.preventDefault(); + hideAgentMenu(); + return true; + } + return false; +} + +// --- Private helpers --- + +function _ensureMenu() { + if (_menuEl) return; + _menuEl = document.createElement("div"); + _menuEl.id = "at-agents-menu"; + _menuEl.className = "at-agents-menu"; + // Attach near the input area (same parent as slash menu / mention menu). + var inputArea = document.getElementById("input-area"); + if (inputArea) { + inputArea.appendChild(_menuEl); + } else { + document.body.appendChild(_menuEl); + } +} + +function _buildItems(query) { + var q = (query || "").toLowerCase().trim(); + var result = []; + + // Project-local named agents from .claude/agents/ only. + // Codex is intentionally excluded — it appears in the mention menu + // (plain:codex) where it has the correct in-session semantics. Showing it + // here as well would present two entry points with different behaviours. + for (var i = 0; i < _agents.length; i++) { + var a = _agents[i]; + if (!a || !a.name) continue; + if (q && a.name.toLowerCase().indexOf(q) === -1 && + (a.description || "").toLowerCase().indexOf(q) === -1) continue; + result.push(a); + } + + return result; +} + +function _render(query) { + _items = _buildItems(query); + if (_items.length === 0) { + hideAgentMenu(); + return; + } + _menuVisible = true; + _activeIdx = 0; + + var html = ""; + for (var i = 0; i < _items.length; i++) { + var item = _items[i]; + var desc = item.description ? escapeHtml(item.description) : ""; + html += + '
' + + '' + escapeHtml(item.name) + '' + + 'agent' + + (desc ? '' + desc + '' : '') + + '
'; + } + _menuEl.innerHTML = html; + _menuEl.classList.add("visible"); + + // Bind mouseenter for hover highlighting. + var itemEls = _menuEl.querySelectorAll(".at-agents-item"); + for (var j = 0; j < itemEls.length; j++) { + (function (el) { + el.addEventListener("mouseenter", function () { + _setActive(parseInt(el.dataset.idx, 10), true); + }); + })(itemEls[j]); + } +} + +function _setActive(idx, skipScroll) { + if (_items.length === 0) return; + if (idx < 0) idx = _items.length - 1; + if (idx >= _items.length) idx = 0; + _activeIdx = idx; + var els = _menuEl.querySelectorAll(".at-agents-item"); + for (var i = 0; i < els.length; i++) { + els[i].classList.toggle("active", i === idx); + } + if (!skipScroll && els[idx]) { + els[idx].scrollIntoView({ block: "nearest" }); + } +} + +function _activateItem(item) { + hideAgentMenu(); + // Clear the @ text from the input. + if (_ctx && _ctx.inputEl) { + var val = _ctx.inputEl.value; + // Remove everything from the last @ to the cursor. + var cursor = _ctx.inputEl.selectionStart; + var atIdx = val.lastIndexOf("@", cursor - 1); + if (atIdx !== -1) { + _ctx.inputEl.value = val.substring(0, atIdx) + val.substring(cursor); + _ctx.inputEl.selectionStart = _ctx.inputEl.selectionEnd = atIdx; + } + } + // Start (or switch to) the named agent session. + var ws = getWs(); + if (ws && ws.readyState === 1) { + ws.send(JSON.stringify({ type: "new_session", agentName: item.name })); + } +} diff --git a/lib/public/modules/input.js b/lib/public/modules/input.js index 3bd5d3fc..730441aa 100644 --- a/lib/public/modules/input.js +++ b/lib/public/modules/input.js @@ -2,6 +2,7 @@ import { iconHtml, refreshIcons } from './icons.js'; import { setRewindMode, isRewindMode } from './rewind.js'; import { renderPicker as renderContextPicker } from './context-sources.js'; import { checkForMention, showMentionMenu, hideMentionMenu, isMentionMenuVisible, mentionMenuKeydown, setMentionAtIdx, parseMentionFromInput, clearMentionState, stickyReapplyMention, sendUserMention, renderUserMention, removeMentionChip } from './mention.js'; +import { initAtAgents, showAgentMenu, hideAgentMenu, isAgentMenuActive, agentMenuKeydown, requestProjectAgents } from './at-agents.js'; import { store } from './store.js'; import { sendWsQuiet } from './ws-ref.js'; @@ -101,6 +102,7 @@ export function sendMessage() { if (!text && images.length === 0 && pendingPastes.length === 0 && pendingFiles.length === 0) return; if (uploadingCount > 0) return; // wait for uploads to finish hideSlashMenu(); + hideAgentMenu(); if (ctx.hideSuggestionChips) ctx.hideSuggestionChips(); if (text === "/clear") { @@ -738,6 +740,9 @@ function createFileInput(accept, capture, multiple) { export function initInput(_ctx) { ctx = _ctx; + // Initialise the @ agent menu with the input context. + initAtAgents(ctx); + if (!slashMenuBound && ctx.slashMenu) { slashMenuBound = true; ctx.slashMenu.addEventListener("click", function (e) { @@ -988,22 +993,35 @@ export function initInput(_ctx) { if (val.startsWith("/") && !val.includes(" ") && val.length > 1) { showSlashMenu(val.substring(1)); hideMentionMenu(); + hideAgentMenu(); } else if (val === "/") { showSlashMenu(""); hideMentionMenu(); + hideAgentMenu(); } else { hideSlashMenu(); - // Check for @mention — skip selectionStart read (forced layout) when - // there is no @ in the value at all. + // Check for @mention / @agent — skip selectionStart read (forced layout) + // when there is no @ in the value at all. if (val.indexOf("@") !== -1) { var mentionCheck = checkForMention(val, ctx.inputEl.selectionStart); if (mentionCheck.active) { setMentionAtIdx(mentionCheck.startIdx); - showMentionMenu(mentionCheck.query); + // Agent menu takes priority: show it first. If it ends up empty (no + // named agents match the query) fall through to the mention menu so + // @user / plain:codex are still reachable. The two menus are mutually + // exclusive — whichever is shown hides the other. + showAgentMenu(mentionCheck.query); + if (isAgentMenuActive()) { + hideMentionMenu(); + } else { + showMentionMenu(mentionCheck.query); + } } else { + hideAgentMenu(); hideMentionMenu(); } } else { + hideAgentMenu(); hideMentionMenu(); } } @@ -1017,7 +1035,11 @@ export function initInput(_ctx) { ctx.inputEl.addEventListener("compositionend", function () { isComposing = false; }); ctx.inputEl.addEventListener("keydown", function (e) { - // @Mention menu keyboard navigation + // @ Agent menu keyboard navigation — takes priority over mention menu. + if (isAgentMenuActive()) { + if (agentMenuKeydown(e)) return; + } + // @Mention menu keyboard navigation (user-to-user / plain vendor) if (isMentionMenuVisible()) { if (mentionMenuKeydown(e)) return; } diff --git a/lib/public/style.css b/lib/public/style.css index 8ce8d075..e49af362 100644 --- a/lib/public/style.css +++ b/lib/public/style.css @@ -30,6 +30,7 @@ @import url("css/command-palette.css"); @import url("css/agent-picker.css"); @import url("css/mention.css"); +@import url("css/at-agents.css"); @import url("css/debate.css"); @import url("css/notifications-center.css"); diff --git a/lib/ws-schema.js b/lib/ws-schema.js index cf20553e..8269cc4f 100644 --- a/lib/ws-schema.js +++ b/lib/ws-schema.js @@ -460,7 +460,17 @@ var schema = { "ralph_phase": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Current ralph wizard phase" }, "ralph_crafting_started": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "File crafting session started" }, "ralph_files_status": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Prompt/judge file readiness status" }, - "ralph_files_content": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Loop file contents (prompt and judge)" } + "ralph_files_content": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Loop file contents (prompt and judge)" }, + + // ----------------------------------------------------------------------- + // Agents (SDK catalog + project-local) + // ----------------------------------------------------------------------- + "list_agents": { direction: "c2s", handler: "lib/project-sessions.js", description: "Request SDK agent catalog with favorites and recents" }, + "agents_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full agent catalog with favorites and recents" }, + "toggle_agent_favorite": { direction: "c2s", handler: "lib/project-sessions.js", description: "Toggle an agent in/out of favorites" }, + "agent_favorite_toggled": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Favorite toggle confirmation" }, + "get_agents": { direction: "c2s", handler: "lib/project-sessions.js", description: "Request project-local agents from .claude/agents/ (lr-c1a2)" }, + "project_agents_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Project-local agent list for @ mention dropdown (lr-c1a2)" } }; module.exports = { schema: schema }; diff --git a/test/agents-read-project.test.js b/test/agents-read-project.test.js new file mode 100644 index 00000000..455dcca8 --- /dev/null +++ b/test/agents-read-project.test.js @@ -0,0 +1,232 @@ +// lr-c1a2 — regression tests for readProjectAgents +// +// Verifies that the helper correctly scans a project's .claude/agents/ directory +// and returns {name, slug, description}[] entries parsed from frontmatter. + +var test = require("node:test"); +var assert = require("node:assert"); +var fs = require("fs"); +var path = require("path"); +var os = require("os"); + +var agentsModule = require("../lib/agents"); +var { readProjectAgents } = agentsModule; + +// --- Helpers --- + +function makeTmpProjectDir() { + var base = os.tmpdir(); + var dir = path.join(base, "clagentic-test-lr-c1a2-" + Date.now() + "-" + Math.random().toString(36).slice(2)); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeAgentsDir(projectDir) { + var agentsDir = path.join(projectDir, ".claude", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + return agentsDir; +} + +function writeAgent(agentsDir, filename, content) { + fs.writeFileSync(path.join(agentsDir, filename), content, "utf8"); +} + +function rmDir(dir) { + // Node 16+ has fs.rmSync with recursive. + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* best-effort */ } +} + +// ============================================================ +// Guard: export exists +// ============================================================ + +test("readProjectAgents is exported from lib/agents.js", function () { + assert.strictEqual(typeof readProjectAgents, "function", + "readProjectAgents must be exported from lib/agents.js"); +}); + +// ============================================================ +// Input validation +// ============================================================ + +test("readProjectAgents: returns [] for null projectDir", function () { + var result = readProjectAgents(null); + assert.deepStrictEqual(result, []); +}); + +test("readProjectAgents: returns [] for empty string", function () { + var result = readProjectAgents(""); + assert.deepStrictEqual(result, []); +}); + +test("readProjectAgents: returns [] for non-string", function () { + assert.deepStrictEqual(readProjectAgents(42), []); + assert.deepStrictEqual(readProjectAgents({}), []); +}); + +// ============================================================ +// Missing directory — graceful degradation +// ============================================================ + +test("readProjectAgents: returns [] when .claude/agents/ does not exist", function () { + var dir = makeTmpProjectDir(); + try { + var result = readProjectAgents(dir); + assert.deepStrictEqual(result, []); + } finally { + rmDir(dir); + } +}); + +test("readProjectAgents: returns [] for nonexistent projectDir", function () { + var result = readProjectAgents("/tmp/zzz-lr-c1a2-does-not-exist-ever"); + assert.deepStrictEqual(result, []); +}); + +// ============================================================ +// Empty agents directory +// ============================================================ + +test("readProjectAgents: returns [] for empty .claude/agents/ directory", function () { + var dir = makeTmpProjectDir(); + makeAgentsDir(dir); + try { + var result = readProjectAgents(dir); + assert.deepStrictEqual(result, []); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Single agent with full frontmatter +// ============================================================ + +test("readProjectAgents: parses name and description from frontmatter", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "my-agent.md", [ + "---", + "name: My Custom Agent", + "description: Does something special", + "---", + "Agent body here.", + ].join("\n")); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, "My Custom Agent"); + assert.strictEqual(result[0].slug, "my-agent"); + assert.strictEqual(result[0].description, "Does something special"); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Agent with no frontmatter — slug used as name +// ============================================================ + +test("readProjectAgents: uses slug as name when no frontmatter name field", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "plain-agent.md", "Just a plain body, no frontmatter."); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, "plain-agent"); + assert.strictEqual(result[0].slug, "plain-agent"); + assert.strictEqual(result[0].description, ""); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Non-.md files are ignored +// ============================================================ + +test("readProjectAgents: ignores non-.md files in .claude/agents/", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "valid.md", "---\nname: Valid\n---\n"); + writeAgent(agentsDir, "ignored.txt", "should not appear"); + writeAgent(agentsDir, "also-ignored.json", '{"name":"json-agent"}'); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].slug, "valid"); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Multiple agents — sorted by name +// ============================================================ + +test("readProjectAgents: returns multiple agents sorted by name", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "zebra.md", "---\nname: Zebra Agent\n---\n"); + writeAgent(agentsDir, "alpha.md", "---\nname: Alpha Agent\n---\n"); + writeAgent(agentsDir, "beta.md", "---\nname: Beta Agent\n---\n"); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 3); + assert.strictEqual(result[0].name, "Alpha Agent"); + assert.strictEqual(result[1].name, "Beta Agent"); + assert.strictEqual(result[2].name, "Zebra Agent"); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Agent with frontmatter but no description field +// ============================================================ + +test("readProjectAgents: description defaults to empty string when absent", function () { + var dir = makeTmpProjectDir(); + var agentsDir = makeAgentsDir(dir); + writeAgent(agentsDir, "nodesc.md", "---\nname: No Description Agent\n---\nBody."); + try { + var result = readProjectAgents(dir); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].description, ""); + } finally { + rmDir(dir); + } +}); + +// ============================================================ +// Traversal-style projectDir — belt-and-suspenders safety +// ============================================================ + +test("readProjectAgents: returns [] for traversal-style path (e.g. ../../etc)", function () { + // The input is server-controlled, but this locks the contract: a path that + // looks like a traversal attempt must never throw and must return []. + // + // We use absolute paths to nonexistent directories so the test is + // deterministic regardless of cwd or what happens to exist on the host. + // path.join("/nonexistent-lr-c1a2-traversal/../../etc", ".claude", "agents") + // normalises to a path under a nonexistent root, so readdirSync throws ENOENT + // and readProjectAgents returns [] without propagating the error. + // path.join() normalises traversal segments before readdirSync sees the path, + // so these inputs exercise the traversal code path without any existing + // .claude/agents/ directory to accidentally read. All resolved targets are + // guaranteed non-existent by using a unique sentinel prefix. + var traversalPaths = [ + "/nonexistent-lr-c1a2-sentinel-xq7/../../nonexistent-lr-c1a2-sentinel-xq7", + "/nonexistent-lr-c1a2-sentinel-xq7/../../../nonexistent-lr-c1a2-sentinel-xq7", + ]; + for (var i = 0; i < traversalPaths.length; i++) { + var result; + var tpath = traversalPaths[i]; + assert.doesNotThrow(function () { + result = readProjectAgents(tpath); + }, "readProjectAgents must not throw for traversal-style path: " + tpath); + assert.deepStrictEqual(result, [], + "readProjectAgents must return [] for traversal-style path: " + tpath); + } +});