/** The full skill row as it flows through the FE. Matches `SkillRowWire` * field-for-field with snake_case → camelCase conversion. */ interface SkillRow { id: string; skillId: string; label: string; description: string; skillType: SkillType; body: string | null; iconName: string | null; modelPreference: string | null; allowedTools: string[]; triggerPatterns: string[]; disableAutoInvocation: boolean; platformTargets: string[]; version: string | null; config: Record; categoryId: string | null; parentSkillId: string | null; isActive: boolean; isSystem: boolean; isPublic: boolean; sortOrder: number; userId: string | null; organizationId: string | null; projectId: string | null; /** Multi-project membership via skl_skill_projects join. */ projectIds: string[]; } interface CategoryRow { id: string; categoryKey: string; label: string; description: string | null; iconName: string | null; color: string | null; parentCategoryId: string | null; sortOrder: number; isActive: boolean; /** `null` → system category (visible to every user). Populated when * the row was fetched via Supabase direct; may be undefined when read * via the Python `/api/skills/categories` GET (which strips it * today). The editor uses this to gate write-paths. */ userId?: string | null; /** Full `platform.categories.metadata` blob. Kept so `updateCategoryThunk` * can merge-patch it without wiping unrelated fields (description, is_active, * legacy_table, etc. all live here). Absent on wire-path rows (Python API). */ metadata?: Record | null; } type IngestSkillStatus = "created" | "updated" | "unchanged" | "error" /** Dry run — the write hasn't happened, so the real outcome is unknown. */ | "pending"; interface IngestReport { parsed: number; created: number; updated: number; unchanged: number; errors: string[]; skills: Array<{ skillId: string; sourcePath: string; /** Category slug from frontmatter; "" if none was set. */ category: string; status: IngestSkillStatus; /** Resulting `skill.definition.id`; null on dry run or error. */ id: string | null; }>; roots: string[]; } /** Free-string per the wire contract, but these are the recognized values * for filtering / labeling. */ type SkillType = "render_block" | "convention" | "workflow" | "task" | "reference" | "mode" | "agent_behavior" | string; type AsyncStatus = "idle" | "loading" | "ready" | "error"; /** * `@ai-matrx/agents/skills` — `createSkillCatalog`: the headless skill catalog store. * * The ONE holder of the skill list rows, the skill categories and the "a skill changed on the * server" signal. Built like `createModelCatalog` (framework-free; `getState` / `subscribe`; React * binding in `./react`). It READS only: `fetchSkills`, `fetchSkillById`, `fetchSkillCategories`, * plus `applyStreamEvent` for the stream's `resource_changed` skill events. The skill WRITES (create, * patch, delete, categories, resources, project links) stay with the skill editors in the host, * which tell this store what landed through the `apply*` mirror methods — local state only. * * Reads go direct to Supabase (RLS gates access); a read failure rejects with * `SkillCatalogReadError` AND lands in `error` + the `errorSink` — never silent. */ interface FetchSkillsArgs { categoryId?: string | undefined; isPublicOnly?: boolean | undefined; projectId?: string | undefined; limit?: number | undefined; } /** Payload of a stream `resource_changed` skill event. */ interface SkillStreamEventPayload { kind: string; action: "created" | "modified" | "deleted" | "invalidated"; resourceId: string; metadata: Record; } interface SkillCatalogState { skills: { byId: Record; allIds: string[]; status: AsyncStatus; error: string | null; /** Filter key of the list in `allIds`. */ loadedKey: string | null; }; categories: { byId: Record; allIds: string[]; status: AsyncStatus; error: string | null; }; /** Wall-clock millis of the last skill stream event (0 = none). Subscribers refetch when it moves. */ lastIngestAt: number; /** The report a `skills.ingested` event carried (counts only). */ lastStreamReport: IngestReport | null; } interface SkillCatalog { getState(): SkillCatalogState; subscribe(listener: () => void): () => void; fetchSkills(args?: FetchSkillsArgs): Promise; fetchSkillById(skillRef: string): Promise; fetchSkillCategories(): Promise; /** Record a skill stream event. Does not refetch — subscribers react to `lastIngestAt`. */ applyStreamEvent(event: SkillStreamEventPayload): void; applySkill(row: SkillRow): void; applySkillRemoved(id: string): void; applySkillProjects(skillId: string, projectIds: string[]): void; applyCategory(row: CategoryRow): void; applyCategoryRemoved(id: string): void; applyCategoriesReordered(parentId: string | null, orderedIds: string[]): void; /** Back to the empty initial state (sign-out / scope change). */ clear(): void; } /** Subscribe to a slice of the catalog. `select` must return a referentially stable value for unchanged state. */ declare function useSkillCatalogSelector(catalog: SkillCatalog, select: (state: SkillCatalogState) => T): T; interface UseSkillsArgs extends Pick { /** Only these skill types. Empty/undefined = all. */ types?: readonly SkillType[]; /** Suppress the auto-fetch on mount (a parent owns loading). */ skipAutoFetch?: boolean; } interface UseSkillsResult { skills: SkillRow[]; count: number; loading: boolean; error: string | null; reload: () => Promise; } /** The canonical "give me the skills list" over a catalog: reads once per filter key, reloads when a skill stream event lands. */ declare function useSkills(catalog: SkillCatalog, args?: UseSkillsArgs): UseSkillsResult; export { useSkillCatalogSelector, useSkills }; export type { UseSkillsArgs, UseSkillsResult };