diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index d67017d..e135c60 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -177,33 +177,20 @@ impl AgentAdapter for ClaudeAdapter { fn global_rules_files(&self) -> Vec { let mut files = vec![self.base_dir().join("CLAUDE.md")]; // Also scan ~/.claude/rules/*.md - let rules_dir = self.base_dir().join("rules"); - if let Ok(entries) = std::fs::read_dir(&rules_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } + files.extend(super::files_with_ext(&self.base_dir().join("rules"), "md")); files } fn global_memory_files(&self) -> Vec { + // ~/.claude/projects/*/memory/*.md — outer level iterates project + // dirs (no extension filter); inner level reuses the helper. let projects_dir = self.base_dir().join("projects"); let mut files = Vec::new(); if let Ok(entries) = std::fs::read_dir(&projects_dir) { for entry in entries.flatten() { let memory_dir = entry.path().join("memory"); - if memory_dir.is_dir() - && let Ok(mem_entries) = std::fs::read_dir(&memory_dir) - { - for mem_entry in mem_entries.flatten() { - let p = mem_entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } + if memory_dir.is_dir() { + files.extend(super::files_with_ext(&memory_dir, "md")); } } } @@ -218,25 +205,9 @@ impl AgentAdapter for ClaudeAdapter { self.base_dir().join("keybindings.json"), ]; // ~/.claude/commands/*.md (legacy, still functional) - let commands_dir = self.base_dir().join("commands"); - if let Ok(entries) = std::fs::read_dir(&commands_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } + files.extend(super::files_with_ext(&self.base_dir().join("commands"), "md")); // ~/.claude/output-styles/*.md - let styles_dir = self.base_dir().join("output-styles"); - if let Ok(entries) = std::fs::read_dir(&styles_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } + files.extend(super::files_with_ext(&self.base_dir().join("output-styles"), "md")); files } diff --git a/crates/hk-core/src/adapter/codex.rs b/crates/hk-core/src/adapter/codex.rs index a27fbf6..7f4612c 100644 --- a/crates/hk-core/src/adapter/codex.rs +++ b/crates/hk-core/src/adapter/codex.rs @@ -86,17 +86,12 @@ impl AgentAdapter for CodexAdapter { } fn global_memory_files(&self) -> Vec { - let mut files = Vec::new(); - let memories_dir = self.base_dir().join("memories"); - if let Ok(entries) = std::fs::read_dir(&memories_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.is_file() && p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } - files + // ~/.codex/memories/*.md — explicit `is_file()` preserves prior + // semantics (rejects a hypothetical directory whose name ends in + // `.md`); cheap parity over a "siblings don't bother" argument. + super::files_with_ext(&self.base_dir().join("memories"), "md") + .filter(|p| p.is_file()) + .collect() } fn project_markers(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/copilot.rs b/crates/hk-core/src/adapter/copilot.rs index 0c1b3b8..0b030fa 100644 --- a/crates/hk-core/src/adapter/copilot.rs +++ b/crates/hk-core/src/adapter/copilot.rs @@ -153,15 +153,7 @@ impl AgentAdapter for CopilotAdapter { self.vscode_user_dir().join("mcp.json"), ]; // ~/.copilot/hooks/*.json - let hooks_dir = self.base_dir().join("hooks"); - if let Ok(entries) = std::fs::read_dir(&hooks_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "json") { - files.push(p); - } - } - } + files.extend(super::files_with_ext(&self.base_dir().join("hooks"), "json")); files } diff --git a/crates/hk-core/src/adapter/gemini.rs b/crates/hk-core/src/adapter/gemini.rs index 7b7cd5f..0f6400d 100644 --- a/crates/hk-core/src/adapter/gemini.rs +++ b/crates/hk-core/src/adapter/gemini.rs @@ -118,25 +118,9 @@ impl AgentAdapter for GeminiAdapter { self.base_dir().join(".env"), ]; // ~/.gemini/commands/*.toml - let commands_dir = self.base_dir().join("commands"); - if let Ok(entries) = std::fs::read_dir(&commands_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "toml") { - files.push(p); - } - } - } + files.extend(super::files_with_ext(&self.base_dir().join("commands"), "toml")); // ~/.gemini/policies/*.toml - let policies_dir = self.base_dir().join("policies"); - if let Ok(entries) = std::fs::read_dir(&policies_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "toml") { - files.push(p); - } - } - } + files.extend(super::files_with_ext(&self.base_dir().join("policies"), "toml")); files } diff --git a/src/components/agents/agent-detail.tsx b/src/components/agents/agent-detail.tsx index 5efb6eb..4b49b50 100644 --- a/src/components/agents/agent-detail.tsx +++ b/src/components/agents/agent-detail.tsx @@ -5,8 +5,8 @@ import { openDirectoryPicker, openFilePicker } from "@/lib/dialog"; import { isDesktop } from "@/lib/transport"; import { agentDisplayName, - type ConfigCategory, CONFIG_CATEGORY_ORDER, + type ConfigCategory, type ConfigScope, type ExtensionCounts, scopeLabel, diff --git a/src/components/agents/config-file-entry.tsx b/src/components/agents/config-file-entry.tsx index 9d928a7..583de47 100644 --- a/src/components/agents/config-file-entry.tsx +++ b/src/components/agents/config-file-entry.tsx @@ -14,8 +14,8 @@ import { import { useEffect, useRef, useState } from "react"; import { useScrollPassthrough } from "@/hooks/use-scroll-passthrough"; import { openDirectoryPicker, openFilePicker } from "@/lib/dialog"; -import type { AgentConfigFile } from "@/lib/types"; import { isDesktop } from "@/lib/transport"; +import type { AgentConfigFile } from "@/lib/types"; import { useAgentConfigStore } from "@/stores/agent-config-store"; export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { @@ -229,6 +229,7 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { onClick={async (e) => { e.stopPropagation(); await updateCustomPath( + // biome-ignore lint/style/noNonNullAssertion: outer JSX gate `file.custom_id != null` (line 178) guarantees this is set; TS narrowing doesn't propagate into the callback. file.custom_id!, editPath.trim(), "", @@ -300,6 +301,7 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { - )} + {(() => { + const activePath = activeInstanceId + ? instanceData.get(activeInstanceId)?.path + : undefined; + return ( + isDesktop() && + activePath && ( + + ) + ); + })()} {/* Agent tabs for switching instance content */} {group.instances.length > 1 && ( diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index 4388cc2..95cbab9 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -68,6 +68,7 @@ export function ExtensionFilters() { // packs that only exist globally (and vice versa). We deliberately don't // narrow by kind/agent/tag/search — those filter the rows further; the // dropdown options should stay stable as the user toggles them. + // biome-ignore lint/correctness/useExhaustiveDependencies: `extensions` is a trigger sentinel — grouped() reads it via Zustand closure; needed in deps so the memo re-runs on store updates. const { scopedPacks, packCounts } = useMemo(() => { const counts = new Map(); for (const g of grouped()) { diff --git a/src/components/extensions/extension-table.tsx b/src/components/extensions/extension-table.tsx index a25e38d..ea31e35 100644 --- a/src/components/extensions/extension-table.tsx +++ b/src/components/extensions/extension-table.tsx @@ -40,6 +40,7 @@ export function ExtensionTable({ // Subscribe to trigger re-render; accessed via getState() in cell renderers useExtensionStore((s) => s.updateStatuses); const toggle = useExtensionStore((s) => s.toggle); + // biome-ignore lint/correctness/useExhaustiveDependencies: `scope` is a trigger sentinel — cell renderers read scope-dependent filter results via getState(); listing it forces a column rebuild on scope change. const columns = useMemo( () => [ col.display({ @@ -196,7 +197,7 @@ export function ExtensionTable({ }), ], // selectedIds, updateStatuses accessed via getState() inside cell renderers - // to avoid recomputing columns on every selection/status change + // to avoid recomputing columns on every selection/status change. [agentOrder, selectAll, clearSelection, toggleSelected, toggle, scope], ); const sorting = useExtensionStore((s) => s.tableSorting) as SortingState; diff --git a/src/components/extensions/new-skills-dialog.tsx b/src/components/extensions/new-skills-dialog.tsx index 9fc9de8..8eb91ba 100644 --- a/src/components/extensions/new-skills-dialog.tsx +++ b/src/components/extensions/new-skills-dialog.tsx @@ -59,10 +59,12 @@ export function NewSkillsDialog({ >(); for (const skill of skills) { const key = skill.repo_url; - if (!grouped.has(key)) { - grouped.set(key, { pack: skill.pack, skills: [] }); + let entry = grouped.get(key); + if (!entry) { + entry = { pack: skill.pack, skills: [] }; + grouped.set(key, entry); } - grouped.get(key)!.skills.push(skill); + entry.skills.push(skill); } useEffect(() => { diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index f99e857..99b5fbe 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -57,7 +57,6 @@ export function AppShell() { setSearchParams(params, { replace: true }); }, [scope, scopeHydrated, searchParams, setSearchParams]); - // Window dragging — anywhere outside
and interactive elements useEffect(() => { const onMouseDown = (e: MouseEvent) => { diff --git a/src/components/layout/scope-switcher-menu.tsx b/src/components/layout/scope-switcher-menu.tsx index 0aabea1..71974b9 100644 --- a/src/components/layout/scope-switcher-menu.tsx +++ b/src/components/layout/scope-switcher-menu.tsx @@ -85,6 +85,7 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { return idx >= 0 ? idx : 0; }); + // biome-ignore lint/correctness/useExhaustiveDependencies: handleSelect and handleAddProject are new closures each render but only capture stable refs (setScope, onClose, navigate); including them would re-bind the keydown listener every render with no behavioural difference. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "ArrowDown") { @@ -103,10 +104,6 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); - // handleSelect / handleAddProject are stable enough for this scope — - // including activeIndex + navigableItems is sufficient to pick up - // changes that affect dispatch. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex, navigableItems]); const activeKey = navigableItems[activeIndex]?.key; diff --git a/src/components/onboarding/onboarding.tsx b/src/components/onboarding/onboarding.tsx index 47ff7fd..5ce9a20 100644 --- a/src/components/onboarding/onboarding.tsx +++ b/src/components/onboarding/onboarding.tsx @@ -294,11 +294,12 @@ function HandAnnotation({ padding: type === "circle" ? [2, 6, 4, 10] : 2, iterations: 1, animationDuration: type === "circle" ? 600 : 400, - roughness: type === "circle" ? 0.6 : 0.8, + // Note: rough-notation hardcodes its own roughness via getOptions(type) + // in render.js; any user-supplied `roughness` would be dead config. ...(type === "highlight" && { color: "color-mix(in oklch, var(--primary) 15%, transparent)", }), - } as any); + }); const timer = setTimeout(() => a.show(), delay); return () => { clearTimeout(timer); diff --git a/src/components/shared/agent-mascot/opencode-mascot.tsx b/src/components/shared/agent-mascot/opencode-mascot.tsx index a6eae44..8d4f4e6 100644 --- a/src/components/shared/agent-mascot/opencode-mascot.tsx +++ b/src/components/shared/agent-mascot/opencode-mascot.tsx @@ -45,16 +45,72 @@ export function OpencodeMascot({ size }: MascotSvgProps) { {/* Click: mosaic pixel tiles */} {/* Border strips approximating the outline stroke */} - - - - + + + + {/* Inner rect split into 2×2 quadrants */} - - - - + + + + ); -} \ No newline at end of file +} diff --git a/src/hooks/use-scope.ts b/src/hooks/use-scope.ts index 4848533..f60add4 100644 --- a/src/hooks/use-scope.ts +++ b/src/hooks/use-scope.ts @@ -1,4 +1,4 @@ -import { useScopeStore, type ScopeValue } from "@/stores/scope-store"; +import { type ScopeValue, useScopeStore } from "@/stores/scope-store"; function computeScopeId(scope: ScopeValue): string { if (scope.type === "all") return "all"; diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index a258e63..91b5b07 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -29,7 +29,7 @@ function validateGitUrl(url: string): void { } function validateNonEmpty(value: string, label: string): void { - if (!value || !value.trim()) { + if (!value?.trim()) { throw new Error(`${label} cannot be empty`); } } diff --git a/src/lib/transport.ts b/src/lib/transport.ts index de63760..e83900c 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -65,7 +65,7 @@ async function httpInvoke( }; const token = getAuthToken(); if (token) { - headers["Authorization"] = `Bearer ${token}`; + headers.Authorization = `Bearer ${token}`; } const response = await fetch(`/api/${command}`, { diff --git a/src/main.tsx b/src/main.tsx index 3854929..978fffa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +// biome-ignore lint/style/noNonNullAssertion: standard React entry — index.html guarantees a #root mount point. ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/pages/agents.tsx b/src/pages/agents.tsx index 0ff78b6..3a5f512 100644 --- a/src/pages/agents.tsx +++ b/src/pages/agents.tsx @@ -60,7 +60,10 @@ export default function AgentsPage() { const agent = searchParams.get("agent"); if (loading || !agent) return; const file = searchParams.get("file"); - const targetScope = resolveDeepLinkScope(searchParams.get("scope"), projects); + const targetScope = resolveDeepLinkScope( + searchParams.get("scope"), + projects, + ); if (!scopesEqual(targetScope, scope)) { setScope(targetScope); prevScopeRef.current = targetScope; diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index f6e3411..bbc41b4 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -30,8 +30,8 @@ import { agentDisplayName, type ConfigScope, type MarketplaceItem, - scopeKey, type SkillAuditInfo, + scopeKey, sortAgents, } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; diff --git a/src/stores/__tests__/extension-helpers.test.ts b/src/stores/__tests__/extension-helpers.test.ts index 58a10ad..3a6138e 100644 --- a/src/stores/__tests__/extension-helpers.test.ts +++ b/src/stores/__tests__/extension-helpers.test.ts @@ -245,6 +245,7 @@ describe("expandGroupKeys", () => { const a = { ...baseExt, id: "ext-1", name: "skill-a" }; const b = { ...baseExt, id: "ext-2", name: "skill-b" }; const groups = buildGroups([a, b]); + // biome-ignore lint/style/noNonNullAssertion: test asserts the group exists; failing the find should fail the test. const keyA = groups.find((g) => g.name === "skill-a")!.groupKey; const ids = expandGroupKeys(groups, new Set([keyA])); diff --git a/src/stores/agent-config-store.ts b/src/stores/agent-config-store.ts index aafefa8..b8989fa 100644 --- a/src/stores/agent-config-store.ts +++ b/src/stores/agent-config-store.ts @@ -114,8 +114,9 @@ export const useAgentConfigStore = create((set, get) => ({ }, async fetchPreview(path: string) { - if (get().previewCache.has(path)) { - return get().previewCache.get(path)!; + const cached = get().previewCache.get(path); + if (cached !== undefined) { + return cached; } if (get().previewLoading.has(path)) { return "";