From 581609835e49b217f55ea004c654c7e5921bc750 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 8 May 2026 19:22:45 +0300 Subject: [PATCH 1/2] refactor(adapter): dedupe remaining read_dir loops with files_with_ext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #42's helper extraction. Replaces 8 pre-existing `read_dir + extension filter` loops with `super::files_with_ext` helper calls; behaviour-preserving mechanical refactor, 4 adapters touched: - claude.rs: rules/, commands/, output-styles/ (3 loops in global_rules_files / global_settings_files); inner memory/ loop in global_memory_files (outer projects/* iteration kept — multi-level pattern doesn't match the helper) - gemini.rs: commands/, policies/ in global_settings_files - copilot.rs: hooks/ in global_settings_files - codex.rs: memories/ in global_memory_files — adds explicit `.filter(|p| p.is_file())` to preserve prior `is_file()` check (cheap parity over a "siblings don't bother" argument) Net: -64 / +12 lines. 385 hk-core tests pass, clippy -D warnings clean. Plugin/marketplace recursive multi-level scans intentionally left untouched — they have a different shape than the helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/hk-core/src/adapter/claude.rs | 43 +++++---------------------- crates/hk-core/src/adapter/codex.rs | 17 ++++------- crates/hk-core/src/adapter/copilot.rs | 10 +------ crates/hk-core/src/adapter/gemini.rs | 20 ++----------- 4 files changed, 16 insertions(+), 74 deletions(-) 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 } From f54bfbdafb4834dc3de52519341c4c226d6525ac Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 8 May 2026 19:39:52 +0300 Subject: [PATCH 2/2] chore(lint): drive biome to clean (0 errors / 0 warnings / 0 info) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces biome diagnostics from 13 errors + 12 warnings + 1 info to fully clean. Combines auto-fix and manual passes: Auto-fix (Step 2 — `npm run lint:fix`, 8 files): import sorting, formatter pass, blank-line cleanup. Pure cosmetic, zero behavior change. Manual (Step 3, by category): Category A — trivial mechanical (2 sites): - transport.ts useLiteralKeys: `headers["Authorization"]` → `.Authorization` - invoke.ts useOptionalChain: `!value || !value.trim()` → `!value?.trim()` Category D — noExplicitAny (1 site, onboarding.tsx): - Removed dead `roughness` field from rough-notation `annotate()` config (library hardcodes its own roughness via getOptions(type) in render.js and never reads user-supplied values), removed accompanying `as any`. Category C — useExhaustiveDependencies (4 sites, 5 findings): - Added single-line `biome-ignore` with rationale at each site where the dep array intentionally omits a value or includes a trigger sentinel: - section-anchor-rail revisionKey (documented re-discovery sentinel) - extension-filters extensions (Zustand getter trigger) - extension-table scope (cell-renderer getState() trigger) - scope-switcher-menu handleSelect/handleAddProject (new closures capturing only stable refs — adding them would only churn the keydown listener) - Replaced obsolete `eslint-disable-next-line` comment in scope-switcher-menu (project uses biome, not eslint). Category B — noNonNullAssertion (9 sites, 5 biome-ignore + 4 refactor): - biome-ignore where the `!` reflects a TS narrowing limitation through JSX/by-construction guarantees (main.tsx React entry, config-file-entry JSX gates, detail-paths Map.get of own keys, test idiom). - Refactor where a small rewrite removes the `!` cleanly: - delete-dialog: redundant `&& filteredSkillLocations` lets TS narrow - extension-detail: extracted `activePath` local var (kills 2 `!`) - new-skills-dialog: explicit Map get-or-create binding - agent-config-store: single `get()` + undefined check instead of has()/get()! pair (cache stores strings only, so `cached !== undefined` is exactly equivalent to `has`) Final state: `npm run lint` clean; tsc clean; 148/148 frontend tests pass; no behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/agents/agent-detail.tsx | 2 +- src/components/agents/config-file-entry.tsx | 4 +- src/components/agents/section-anchor-rail.tsx | 6 +- src/components/extensions/delete-dialog.tsx | 9 ++- src/components/extensions/detail-paths.tsx | 1 + .../extensions/extension-detail.tsx | 32 ++++---- .../extensions/extension-filters.tsx | 1 + src/components/extensions/extension-table.tsx | 3 +- .../extensions/new-skills-dialog.tsx | 8 +- src/components/layout/app-shell.tsx | 1 - src/components/layout/scope-switcher-menu.tsx | 5 +- src/components/onboarding/onboarding.tsx | 5 +- .../shared/agent-mascot/opencode-mascot.tsx | 74 ++++++++++++++++--- src/hooks/use-scope.ts | 2 +- src/lib/invoke.ts | 2 +- src/lib/transport.ts | 2 +- src/main.tsx | 1 + src/pages/agents.tsx | 5 +- src/pages/marketplace.tsx | 2 +- .../__tests__/extension-helpers.test.ts | 1 + src/stores/agent-config-store.ts | 5 +- 21 files changed, 120 insertions(+), 51 deletions(-) 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 "";