fix: biome & tsc setup across repo (#493)

* fix: biome lint issues

* fix: code quality workflow

* fix: all lint issues

* chore: test lefthook pre-commit hook

* chore: test lefthook with agent file

* chore: revert test comment from lefthook verification

* feat: setup tsgo for typechecking agent

* fix: typecheck cli command

* fix: early return to prevent errors
This commit is contained in:
Dani Akash
2026-03-19 18:18:24 +05:30
committed by GitHub
parent 50b2f45590
commit d965698905
50 changed files with 416 additions and 169 deletions

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'packages/browseros-agent/**'
- "packages/browseros-agent/**"
jobs:
biome:
@@ -50,6 +50,9 @@ jobs:
- name: Install dependencies
run: bun ci
- name: Prepare wxt
run: VITE_PUBLIC_BROWSEROS_API=http://localhost:3000 bun run --cwd apps/agent wxt prepare
- name: Run codegen
run: bun run --cwd apps/agent codegen

57
lefthook.yml Normal file
View File

@@ -0,0 +1,57 @@
commit-msg:
commands:
conventional:
run: |
msg=$(head -1 {1})
if [[ ! "$msg" =~ ^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?\!?:\ .+ ]]; then
echo "Commit message must follow Conventional Commits format:"
echo " <type>(<optional scope>): <description>"
echo " Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
echo ""
echo "Examples:"
echo " feat(auth): add OAuth2 support"
echo " fix: resolve null pointer exception"
exit 1
fi
pre-commit:
commands:
biome-check:
root: "packages/browseros-agent/"
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true
file-length:
root: "packages/browseros-agent/"
glob: "*.{ts,tsx}"
exclude: "*.{test,spec,d}.ts|*.{test,spec}.tsx|**/__tests__/**|**/tests/**|**/*.generated.*"
run: |
for file in {staged_files}; do
if [[ -f "$file" ]]; then
lines=$(wc -l < "$file" | tr -d ' ')
if [[ $lines -gt 400 ]]; then
echo "⚠️ Warning: $file has $lines lines (threshold: 400)"
echo " Consider splitting this file if it has multiple responsibilities."
fi
fi
done
pre-push:
commands:
branch-name:
run: |
branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
exit 0
fi
if [[ ! "$branch" =~ ^(feat|fix|bugfix|hotfix|release|docs|refactor|test|chore|experiment)/[a-z0-9-]+$ ]]; then
echo "⚠️ Warning: Branch name '$branch' doesn't match recommended format."
echo " Use: <type>/<short-description>"
echo " Types: feat, fix, bugfix, hotfix, release, docs, refactor, test, chore, experiment"
echo " Example: feat/add-auth, fix/login-crash"
echo ""
echo " To rename your branch:"
echo " git branch -m <new-name>"
echo " git push -u origin <new-name>"
fi

View File

@@ -176,14 +176,14 @@ function AlertDialogCancel({
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -72,4 +72,4 @@ function AlertDescription({
)
}
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertDescription, AlertTitle }

View File

@@ -104,10 +104,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
}

View File

@@ -251,10 +251,10 @@ function CarouselNext({
}
export {
type CarouselApi,
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
CarouselPrevious,
}

View File

@@ -39,4 +39,4 @@ function CollapsibleContent({
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleContent, CollapsibleTrigger }

View File

@@ -198,11 +198,11 @@ function CommandShortcut({
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandShortcut,
CommandList,
CommandSeparator,
CommandShortcut,
}

View File

@@ -283,18 +283,18 @@ function DropdownMenuSubContent({
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}

View File

@@ -179,12 +179,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
}

View File

@@ -50,4 +50,4 @@ function HoverCardContent({
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
export { HoverCard, HoverCardContent, HoverCardTrigger }

View File

@@ -184,7 +184,7 @@ export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
}

View File

@@ -55,4 +55,4 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }

View File

@@ -49,4 +49,4 @@ function ResizableHandle({
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@@ -129,11 +129,11 @@ function SheetDescription({
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
}

View File

@@ -86,4 +86,4 @@ function TabsContent({
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }

View File

@@ -68,4 +68,4 @@ function TooltipContent({
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -103,10 +103,10 @@ export const ProviderCard: FC<ProviderCardProps> = ({
for better performance.
</>
)
) : provider.baseUrl ? (
`${provider.modelId}${provider.baseUrl}`
) : (
provider.baseUrl
? `${provider.modelId}${provider.baseUrl}`
: provider.modelId
provider.modelId
)}
</p>
</div>

View File

@@ -3,6 +3,7 @@ import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod/v3'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
@@ -34,16 +35,15 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import {
defaultProviderIdStorage,
providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { refinePrompt } from '@/lib/schedules/refine-prompt'
import { toast } from 'sonner'
import type { ScheduledJob } from './types'
const formSchema = z
@@ -291,7 +291,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
type="button"
variant="ghost"
size="sm"
className="h-auto gap-1 px-2 py-1 text-xs text-muted-foreground"
className="h-auto gap-1 px-2 py-1 text-muted-foreground text-xs"
disabled={!queryValue?.trim() || isRefining}
onClick={handleRefinePrompt}
>
@@ -322,7 +322,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
{!isRefining && originalPromptRef.current !== null ? (
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
onClick={handleUndoRefine}
>
<Undo2 className="h-3 w-3" />

View File

@@ -280,7 +280,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
if (voice.isTranscribing) {
return (
<button type="button" disabled className="rounded-full p-2 text-muted-foreground">
<button
type="button"
disabled
className="rounded-full p-2 text-muted-foreground"
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="sr-only">Transcribing</span>
</button>
@@ -317,7 +321,9 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
return (
<button
type="submit"
disabled={!input.trim() || voice?.isRecording || voice?.isTranscribing}
disabled={
!input.trim() || voice?.isRecording || voice?.isTranscribing
}
className="cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
@@ -341,12 +347,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
anchorRef={textareaRef}
/>
{voice?.isRecording ? (
<div
className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]"
>
<div className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]">
{voice.audioLevels.map((level, i) => (
<div
key={i}
key={i.toString()}
className="w-1 rounded-full bg-red-500 transition-all duration-75"
style={{
height: `${Math.max(4, Math.min(20, level * 0.6))}px`,

View File

@@ -74,6 +74,7 @@ export function useOAuthStatus(provider: string): UseOAuthStatusReturn {
}, [])
// Cleanup on unmount
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
useEffect(() => {
return () => stopPolling()
}, [])

View File

@@ -23,4 +23,4 @@ type ScheduleMessagesProtocol = {
const { sendMessage, onMessage } =
defineExtensionMessaging<ScheduleMessagesProtocol>()
export { sendMessage as sendScheduleMessage, onMessage as onScheduleMessage }
export { onMessage as onScheduleMessage, sendMessage as sendScheduleMessage }

View File

@@ -12,4 +12,4 @@ type ServerMessagesProtocol = {
const { sendMessage, onMessage } =
defineExtensionMessaging<ServerMessagesProtocol>()
export { sendMessage as sendServerMessage, onMessage as onServerMessage }
export { onMessage as onServerMessage, sendMessage as sendServerMessage }

View File

@@ -12,6 +12,6 @@ const { sendMessage, onMessage } =
defineExtensionMessaging<OpenSidePanelWithSearchParams>()
export {
sendMessage as openSidePanelWithSearch,
onMessage as onOpenSidePanelWithSearch,
sendMessage as openSidePanelWithSearch,
}

View File

@@ -45,7 +45,9 @@ async function transcribeAudio(audioBlob: Blob): Promise<string> {
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(errorBody.error || `Transcription failed: ${response.status}`)
throw new Error(
errorBody.error || `Transcription failed: ${response.status}`,
)
}
const result: TranscribeResponse = await response.json()
@@ -81,6 +83,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
setAudioLevels(EMPTY_LEVELS)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
useEffect(() => {
return () => {
streamRef.current?.getTracks().forEach((track) => {
@@ -171,7 +174,9 @@ export function useVoiceInput(): UseVoiceInputReturn {
setIsRecording(true)
return true
} catch (err) {
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current?.getTracks().forEach((track) => {
track.stop()
})
streamRef.current = null
stopAudioLevelMonitoring()

View File

@@ -9,9 +9,9 @@
"build": "bun run codegen && wxt build",
"build:dev": "bun --env-file=.env.development wxt build --mode development",
"zip": "wxt zip",
"compile": "tsc --noEmit",
"compile": "tsgo --noEmit",
"lint": "bunx biome check",
"typecheck": "tsc --noEmit",
"typecheck": "tsgo --noEmit",
"lint:fix": "bunx biome check --write --unsafe",
"clean:cache": "rm -rf node_modules/.cache && rm -rf .output/ && rm -rf .wxt/",
"codegen": "bun --env-file=.env.development graphql-codegen --config codegen.ts",

View File

@@ -4,7 +4,6 @@
"types": ["chrome", "bun"],
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},

View File

@@ -90,7 +90,8 @@ export class SingleAgentEvaluator implements AgentEvaluator {
timeoutMs,
capture,
async (signal) => {
const result = await agent!.toolLoopAgent.generate({
if (!agent) throw new Error('Agent was not initialized')
const result = await agent.toolLoopAgent.generate({
prompt: task.query,
abortSignal: signal,

View File

@@ -63,9 +63,9 @@ export class ScreenshotCapture {
}
private async captureDirect(pageId: number): Promise<string | null> {
if (!this.browser) return null
try {
// browser is guaranteed non-null here — captureDirect is only called when this.browser is truthy
const result = await this.browser!.screenshot(pageId, {
const result = await this.browser.screenshot(pageId, {
format: 'png',
fullPage: false,
})
@@ -74,9 +74,9 @@ export class ScreenshotCapture {
} catch (error) {
// If page ID is invalid, try listing pages and use the first one
try {
const pages = await this.browser!.listPages()
const pages = await this.browser.listPages()
if (pages.length > 0) {
const result = await this.browser!.screenshot(pages[0].pageId, {
const result = await this.browser.screenshot(pages[0].pageId, {
format: 'png',
fullPage: false,
})

View File

@@ -4,8 +4,8 @@ import type {
LanguageModelV3Middleware,
} from '@ai-sdk/provider'
import { AGENT_LIMITS } from '@browseros/shared/constants/limits'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import {
type LanguageModel,
type ModelMessage,
@@ -204,10 +204,8 @@ export class AiSdkAgent {
providerOptions: {
openai: {
store: false,
reasoningEffort:
config.resolvedConfig.reasoningEffort || 'high',
reasoningSummary:
config.resolvedConfig.reasoningSummary || 'auto',
reasoningEffort: config.resolvedConfig.reasoningEffort || 'high',
reasoningSummary: config.resolvedConfig.reasoningSummary || 'auto',
include: ['reasoning.encrypted_content'],
},
},

View File

@@ -388,7 +388,7 @@ function getMemoryAndIdentity(
section += `
### Your Personality (SOUL.md)
${options?.soulContent ? options.soulContent + '\n' : ''}SOUL.md defines **how you behave** — your personality, tone, communication style, rules, and boundaries. Update it with \`soul_update\` when you learn how the user wants you to act. Use \`soul_read\` to read the current SOUL.md before updating.
${options?.soulContent ? `${options.soulContent}\n` : ''}SOUL.md defines **how you behave** — your personality, tone, communication style, rules, and boundaries. Update it with \`soul_update\` when you learn how the user wants you to act. Use \`soul_read\` to read the current SOUL.md before updating.
**SOUL.md is NOT for storing facts about the user.** User facts belong in core memory via \`memory_save_core\`.`
// Soul bootstrap

View File

@@ -21,12 +21,12 @@ import type { ToolRegistry } from '../tools/tool-registry'
// Re-export browser context types for consumers
export {
BrowserContextSchema,
CustomMcpServerSchema,
TabSchema,
type BrowserContext,
BrowserContextSchema,
type CustomMcpServer,
CustomMcpServerSchema,
type Tab,
TabSchema,
}
export const AgentLLMConfigSchema = LLMConfigSchema.extend({

View File

@@ -823,14 +823,7 @@ export class Browser {
if (coords) {
const value = await elements.getInputValue(session, element)
if (value) {
await mouse.dispatchClick(
session,
coords.x,
coords.y,
'left',
3,
0,
)
await mouse.dispatchClick(session, coords.x, coords.y, 'left', 3, 0)
}
}
}

View File

@@ -40,12 +40,16 @@ async function resolveChatGPTProConfig(
): Promise<ResolvedLLMConfig> {
const tokenManager = getOAuthTokenManager()
if (!tokenManager || !browserosId) {
throw new Error('Not authenticated with ChatGPT Plus/Pro. Please login first.')
throw new Error(
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
)
}
const tokens = await tokenManager.refreshIfExpired('chatgpt-pro')
if (!tokens) {
throw new Error('Not authenticated with ChatGPT Plus/Pro. Please login first.')
throw new Error(
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
)
}
return {

View File

@@ -16,8 +16,8 @@ import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { logger } from '../../logger'
import { createCodexFetch } from '../oauth/codex-fetch'
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
import { createCodexFetch } from '../oauth/codex-fetch'
import type { ResolvedLLMConfig } from './types'
type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel

View File

@@ -12,9 +12,9 @@ import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
import { logger } from '../../logger'
import type { OAuthTokenManager } from './token-manager'
export function startOAuthCallbackServer(
tokenManager: OAuthTokenManager,
): { stop: () => void } {
export function startOAuthCallbackServer(tokenManager: OAuthTokenManager): {
stop: () => void
} {
const server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
hostname: '127.0.0.1',
@@ -29,8 +29,7 @@ export function startOAuthCallbackServer(
const error = url.searchParams.get('error')
if (error) {
const description =
url.searchParams.get('error_description') || error
const description = url.searchParams.get('error_description') || error
logger.warn('OAuth callback received error', { error, description })
return htmlResponse(errorPage(description))
}

View File

@@ -60,9 +60,12 @@ export function createCodexFetch(accountId?: string) {
}
body = JSON.stringify(json)
} catch (err) {
logger.warn('Failed to inject Codex-required fields into request body', {
error: err instanceof Error ? err.message : String(err),
})
logger.warn(
'Failed to inject Codex-required fields into request body',
{
error: err instanceof Error ? err.message : String(err),
},
)
}
}

View File

@@ -27,7 +27,10 @@ interface OAuthTokenResponse {
export class OAuthTokenManager {
private readonly pendingFlows = new Map<string, PendingOAuthFlow>()
private readonly refreshLocks = new Map<string, Promise<StoredOAuthTokens | null>>()
private readonly refreshLocks = new Map<
string,
Promise<StoredOAuthTokens | null>
>()
constructor(
private readonly store: OAuthTokenStore,
@@ -107,9 +110,12 @@ export class OAuthTokenManager {
const data = (await tokenResponse.json()) as OAuthTokenResponse
if (!data.refresh_token) {
logger.warn('OAuth token response missing refresh_token — token refresh will not be available', {
provider: flow.provider,
})
logger.warn(
'OAuth token response missing refresh_token — token refresh will not be available',
{
provider: flow.provider,
},
)
}
const { accountId, email } = parseAccessTokenClaims(data.access_token)
@@ -161,7 +167,9 @@ export class OAuthTokenManager {
): Promise<StoredOAuthTokens> {
if (!tokens.refreshToken) {
this.store.deleteTokens(this.browserosId, provider)
throw new Error(`${provider} session expired (no refresh token). Please re-login.`)
throw new Error(
`${provider} session expired (no refresh token). Please re-login.`,
)
}
const providerConfig = getOAuthProvider(provider)
@@ -269,9 +277,7 @@ function parseAccessTokenClaims(accessToken: string): {
authClaims?.chatgpt_account_id ??
payload.chatgpt_account_id ??
payload.account_id,
email:
profileClaims?.email ??
payload.email,
email: profileClaims?.email ?? payload.email,
}
} catch {
return {}

View File

@@ -29,7 +29,11 @@ import { RateLimiter } from './lib/rate-limiter/rate-limiter'
import { Sentry } from './lib/sentry'
import { seedSoulTemplate } from './lib/soul'
import { migrateBuiltinSkills } from './skills/migrate'
import { startSkillSync, stopSkillSync, syncBuiltinSkills } from './skills/remote-sync'
import {
startSkillSync,
stopSkillSync,
syncBuiltinSkills,
} from './skills/remote-sync'
import { registry } from './tools/registry'
import { VERSION } from './version'

View File

@@ -1,7 +1,7 @@
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import matter from 'gray-matter'
import { PATHS } from '@browseros/shared/constants/paths'
import matter from 'gray-matter'
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
import { logger } from '../lib/logger'
import type { SkillFrontmatter, SkillMeta } from './types'

View File

@@ -1,4 +1,4 @@
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
@@ -55,7 +55,9 @@ export async function fetchRemoteCatalog(): Promise<RemoteSkillCatalog | null> {
signal: AbortSignal.timeout(TIMEOUTS.SKILLS_FETCH),
})
if (!response.ok) {
logger.warn('Failed to fetch remote skill catalog', { status: response.status })
logger.warn('Failed to fetch remote skill catalog', {
status: response.status,
})
return null
}
const data: unknown = await response.json()
@@ -77,11 +79,17 @@ export async function syncBuiltinSkills(): Promise<void> {
const contentMap = new Map<string, { version: string; content: string }>()
for (const skill of DEFAULT_SKILLS) {
contentMap.set(skill.id, { version: extractVersion(skill.content), content: skill.content })
contentMap.set(skill.id, {
version: extractVersion(skill.content),
content: skill.content,
})
}
if (catalog) {
for (const skill of catalog.skills) {
contentMap.set(skill.id, { version: skill.version, content: skill.content })
contentMap.set(skill.id, {
version: skill.version,
content: skill.content,
})
}
}

View File

@@ -50,4 +50,3 @@ export type RemoteSkillCatalog = {
version: number
skills: RemoteSkillEntry[]
}

View File

@@ -50,7 +50,8 @@ describe('Flow tests against live CDN', () => {
const afterSync = await readFile(skillPath, 'utf-8')
assert.ok(
afterSync.includes('enabled: "false"') || afterSync.includes("enabled: 'false'"),
afterSync.includes('enabled: "false"') ||
afterSync.includes("enabled: 'false'"),
'disabled state should be preserved',
)
})
@@ -58,7 +59,10 @@ describe('Flow tests against live CDN', () => {
it('reinstalls deleted builtin skill', async () => {
await rm(join(builtinDir, 'save-page'), { recursive: true })
await syncBuiltinSkills()
const content = await readFile(join(builtinDir, 'save-page', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(builtinDir, 'save-page', 'SKILL.md'),
'utf-8',
)
assert.ok(content.includes('name: save-page'))
})

View File

@@ -62,7 +62,10 @@ metadata:
describe('loader two-directory scanning', () => {
it('marks builtin/ skills as builtIn: true', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
BUILTIN_SKILL,
)
const skills = await loadAllSkills()
const skill = skills.find((s) => s.id === 'summarize-page')
@@ -82,7 +85,10 @@ describe('loader two-directory scanning', () => {
it('merges skills from both directories', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
BUILTIN_SKILL,
)
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), USER_SKILL)
@@ -92,7 +98,10 @@ describe('loader two-directory scanning', () => {
it('skips builtin/ subdirectory when scanning root', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
BUILTIN_SKILL,
)
const skills = await loadAllSkills()
const dupes = skills.filter((s) => s.id === 'summarize-page')
@@ -102,9 +111,15 @@ describe('loader two-directory scanning', () => {
it('loadSkills filters out disabled skills', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
BUILTIN_SKILL,
)
await mkdir(join(builtinDir, 'deep-research'), { recursive: true })
await writeFile(join(builtinDir, 'deep-research', 'SKILL.md'), BUILTIN_DISABLED)
await writeFile(
join(builtinDir, 'deep-research', 'SKILL.md'),
BUILTIN_DISABLED,
)
const skills = await loadSkills()
assert.strictEqual(skills.length, 1)

View File

@@ -1,6 +1,14 @@
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
import assert from 'node:assert'
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -43,39 +51,56 @@ describe('migrateBuiltinSkills', () => {
await migrateBuiltinSkills()
const content = await readFile(join(builtinDir, 'summarize-page', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
'utf-8',
)
assert.strictEqual(content, SKILL_CONTENT)
const oldExists = await stat(join(testDir, 'summarize-page')).then(() => true).catch(() => false)
const oldExists = await stat(join(testDir, 'summarize-page'))
.then(() => true)
.catch(() => false)
assert.strictEqual(oldExists, false)
})
it('does not move user-created skills', async () => {
const userContent = '---\nname: my-workflow\ndescription: mine\n---\n# Mine\n'
const userContent =
'---\nname: my-workflow\ndescription: mine\n---\n# Mine\n'
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), userContent)
await migrateBuiltinSkills()
const content = await readFile(join(testDir, 'my-workflow', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(testDir, 'my-workflow', 'SKILL.md'),
'utf-8',
)
assert.strictEqual(content, userContent)
})
it('skips if builtin/ already has skills', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), SKILL_CONTENT)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
SKILL_CONTENT,
)
await mkdir(join(testDir, 'deep-research'), { recursive: true })
await writeFile(join(testDir, 'deep-research', 'SKILL.md'), SKILL_CONTENT)
await migrateBuiltinSkills()
const stillInRoot = await stat(join(testDir, 'deep-research')).then(() => true).catch(() => false)
const stillInRoot = await stat(join(testDir, 'deep-research'))
.then(() => true)
.catch(() => false)
assert.strictEqual(stillInRoot, true)
})
it('is a no-op for fresh installs', async () => {
await migrateBuiltinSkills()
const entries = await readdir(builtinDir)
assert.strictEqual(entries.filter((e: string) => !e.startsWith('.')).length, 0)
assert.strictEqual(
entries.filter((e: string) => !e.startsWith('.')).length,
0,
)
})
})

View File

@@ -1,6 +1,14 @@
import { afterEach, beforeEach, describe, it, mock, spyOn } from 'bun:test'
import assert from 'node:assert'
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { RemoteSkillCatalog } from '../../src/skills/types'
@@ -13,8 +21,9 @@ mock.module('../../src/lib/browseros-dir', () => ({
getBuiltinSkillsDir: () => builtinDir,
}))
const { fetchRemoteCatalog, syncBuiltinSkills } =
await import('../../src/skills/remote-sync')
const { fetchRemoteCatalog, syncBuiltinSkills } = await import(
'../../src/skills/remote-sync'
)
function makeCatalog(
skills: { id: string; version: string; content: string }[],
@@ -63,7 +72,9 @@ afterEach(async () => {
describe('fetchRemoteCatalog', () => {
it('returns null on network failure', async () => {
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(
new Error('offline'),
)
assert.strictEqual(await fetchRemoteCatalog(), null)
spy.mockRestore()
})
@@ -77,7 +88,9 @@ describe('fetchRemoteCatalog', () => {
})
it('returns catalog on success', async () => {
const catalog = makeCatalog([{ id: 'test', version: '1.0', content: 'hello' }])
const catalog = makeCatalog([
{ id: 'test', version: '1.0', content: 'hello' },
])
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(catalog), { status: 200 }),
)
@@ -89,12 +102,18 @@ describe('fetchRemoteCatalog', () => {
describe('syncBuiltinSkills', () => {
it('installs from remote into builtin/', async () => {
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(makeCatalog([
{ id: 'new-skill', version: '1.0', content: SKILL_V1 },
])), { status: 200 }),
new Response(
JSON.stringify(
makeCatalog([{ id: 'new-skill', version: '1.0', content: SKILL_V1 }]),
),
{ status: 200 },
),
)
await syncBuiltinSkills()
const content = await readFile(join(builtinDir, 'new-skill', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(builtinDir, 'new-skill', 'SKILL.md'),
'utf-8',
)
assert.strictEqual(content, SKILL_V1)
spy.mockRestore()
})
@@ -104,12 +123,20 @@ describe('syncBuiltinSkills', () => {
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(makeCatalog([
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
])), { status: 200 }),
new Response(
JSON.stringify(
makeCatalog([
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
]),
),
{ status: 200 },
),
)
await syncBuiltinSkills()
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(builtinDir, 'test-skill', 'SKILL.md'),
'utf-8',
)
assert.strictEqual(content, SKILL_V2)
spy.mockRestore()
})
@@ -119,12 +146,20 @@ describe('syncBuiltinSkills', () => {
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(makeCatalog([
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
])), { status: 200 }),
new Response(
JSON.stringify(
makeCatalog([
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
]),
),
{ status: 200 },
),
)
await syncBuiltinSkills()
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(builtinDir, 'test-skill', 'SKILL.md'),
'utf-8',
)
assert.strictEqual(content, SKILL_V1)
spy.mockRestore()
})
@@ -135,22 +170,33 @@ describe('syncBuiltinSkills', () => {
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), disabledV1)
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(makeCatalog([
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
])), { status: 200 }),
new Response(
JSON.stringify(
makeCatalog([
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
]),
),
{ status: 200 },
),
)
await syncBuiltinSkills()
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(builtinDir, 'test-skill', 'SKILL.md'),
'utf-8',
)
assert.ok(content.includes('v2'), 'should have v2 content')
assert.ok(
content.includes('enabled: "false"') || content.includes("enabled: 'false'"),
content.includes('enabled: "false"') ||
content.includes("enabled: 'false'"),
'should preserve disabled state',
)
spy.mockRestore()
})
it('falls back to bundled defaults when offline', async () => {
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(
new Error('offline'),
)
await syncBuiltinSkills()
const entries = await readdir(builtinDir)
const skills = entries.filter((e: string) => !e.startsWith('.'))
@@ -163,12 +209,19 @@ describe('syncBuiltinSkills', () => {
await writeFile(join(builtinDir, 'old-skill', 'SKILL.md'), SKILL_V1)
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(makeCatalog([
{ id: 'other-skill', version: '1.0', content: SKILL_V2 },
])), { status: 200 }),
new Response(
JSON.stringify(
makeCatalog([
{ id: 'other-skill', version: '1.0', content: SKILL_V2 },
]),
),
{ status: 200 },
),
)
await syncBuiltinSkills()
const exists = await stat(join(builtinDir, 'old-skill')).then(() => true).catch(() => false)
const exists = await stat(join(builtinDir, 'old-skill'))
.then(() => true)
.catch(() => false)
assert.strictEqual(exists, false)
spy.mockRestore()
})
@@ -179,24 +232,39 @@ describe('syncBuiltinSkills', () => {
await writeFile(join(testDir, 'my-custom', 'SKILL.md'), custom)
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(makeCatalog([
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
])), { status: 200 }),
new Response(
JSON.stringify(
makeCatalog([
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
]),
),
{ status: 200 },
),
)
await syncBuiltinSkills()
const content = await readFile(join(testDir, 'my-custom', 'SKILL.md'), 'utf-8')
const content = await readFile(
join(testDir, 'my-custom', 'SKILL.md'),
'utf-8',
)
assert.strictEqual(content, custom)
spy.mockRestore()
})
it('rejects path traversal in skill ids', async () => {
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(makeCatalog([
{ id: '../../etc/evil', version: '1.0', content: SKILL_V1 },
])), { status: 200 }),
new Response(
JSON.stringify(
makeCatalog([
{ id: '../../etc/evil', version: '1.0', content: SKILL_V1 },
]),
),
{ status: 200 },
),
)
await syncBuiltinSkills()
const exists = await stat(join(builtinDir, '..', '..', 'etc', 'evil')).then(() => true).catch(() => false)
const exists = await stat(join(builtinDir, '..', '..', 'etc', 'evil'))
.then(() => true)
.catch(() => false)
assert.strictEqual(exists, false)
spy.mockRestore()
})

View File

@@ -22,8 +22,9 @@ mock.module('../../src/lib/browseros-dir', () => ({
getBuiltinSkillsDir: () => builtinDir,
}))
const { createSkill, deleteSkill, getSkill, listSkills, updateSkill } =
await import('../../src/skills/service')
const { createSkill, deleteSkill, getSkill, updateSkill } = await import(
'../../src/skills/service'
)
const BUILTIN_SKILL = `---
name: summarize-page
@@ -40,14 +41,21 @@ metadata:
describe('getSkill', () => {
it('finds builtin skill with builtIn: true', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
BUILTIN_SKILL,
)
const skill = await getSkill('summarize-page')
assert.ok(skill)
assert.strictEqual(skill.builtIn, true)
})
it('finds user skill with builtIn: false', async () => {
await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
await createSkill({
name: 'My Skill',
description: 'Custom',
content: '# Custom',
})
const skill = await getSkill('my-skill')
assert.ok(skill)
assert.strictEqual(skill.builtIn, false)
@@ -56,7 +64,11 @@ describe('getSkill', () => {
describe('createSkill', () => {
it('creates in user directory with builtIn: false', async () => {
const skill = await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
const skill = await createSkill({
name: 'My Skill',
description: 'Custom',
content: '# Custom',
})
assert.strictEqual(skill.builtIn, false)
assert.ok(!skill.location.includes('builtin'))
})
@@ -65,7 +77,12 @@ describe('createSkill', () => {
await mkdir(join(builtinDir, 'my-skill'), { recursive: true })
await writeFile(join(builtinDir, 'my-skill', 'SKILL.md'), BUILTIN_SKILL)
await assert.rejects(
() => createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' }),
() =>
createSkill({
name: 'My Skill',
description: 'Custom',
content: '# Custom',
}),
/already exists/,
)
})
@@ -74,7 +91,10 @@ describe('createSkill', () => {
describe('updateSkill', () => {
it('updates builtin skill in place', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
BUILTIN_SKILL,
)
const updated = await updateSkill('summarize-page', { enabled: false })
assert.strictEqual(updated.enabled, false)
assert.strictEqual(updated.builtIn, true)
@@ -83,14 +103,24 @@ describe('updateSkill', () => {
describe('deleteSkill', () => {
it('deletes user skill', async () => {
await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
await createSkill({
name: 'My Skill',
description: 'Custom',
content: '# Custom',
})
await deleteSkill('my-skill')
assert.strictEqual(await getSkill('my-skill'), null)
})
it('rejects deleting builtin skill', async () => {
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
await assert.rejects(() => deleteSkill('summarize-page'), /Cannot delete built-in skill/)
await writeFile(
join(builtinDir, 'summarize-page', 'SKILL.md'),
BUILTIN_SKILL,
)
await assert.rejects(
() => deleteSkill('summarize-page'),
/Cannot delete built-in skill/,
)
})
})

View File

@@ -10,6 +10,7 @@
"@sentry/cli": "^2.42.2",
"@types/bun": "^1.3.5",
"@types/node": "^24.3.3",
"@typescript/native-preview": "^7.0.0-dev.20260319.1",
"commander": "^14.0.1",
"dotenv": "^17.2.3",
"globals": "^16.4.0",
@@ -1914,6 +1915,22 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260319.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260319.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260319.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260319.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260319.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260319.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260319.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260319.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-K9evb5u4QmH3Xv2XUg9OWUETYMrIX1C7Hls1ce8DW+Nlbb26NnQ5SPQCt8fGq4FGqZ9BodMwfane1pTd+BWYwQ=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260319.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CMpCsOVUJ/BLC9x9VaT/DQviTmfHqw/NVwnyAR6ZHVVR1YJynJ6EJBPPHI4oStgTwVSdhna5uTPRiKhUpzvgCA=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260319.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gUcJwLYodGDC7Xf8aRDSMyL1impp4I+sE4ySTnnibTCzI5Mi2g8Sckkr81TEgMx5L4B10gkMmREuqr//1E+Y3A=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260319.1", "", { "os": "linux", "cpu": "arm" }, "sha512-boLhrOyT6nSWaVARBD7AF99rLPMy7jjQB50KywZMFzJ9Gg9N+MBBHJCzgwqlDK2sfahfPWBSV5JjYfET8AOZMg=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260319.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-7JmTBE0URYCmLiHXWZ/wlFoNWMu6YJkHGXXqkR3EGTck8+qi0X+dutRsasKYwGHS4I8Ubn/yWTcJHX5qHf68sQ=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260319.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9LdXkBqR3r8MHmHHwoA7mwv/fkATe2TO+6fLuan/sfmGjob/9y7swyYVvauYzX+aiTbzXjjCkOtEw0klQLxT/A=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260319.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-jKJ+lha8EZtxP5chtsuSNAZOvPInJbFExu0ctoiGVgw01sdMUZeyFYz9TSLoa5oNUs0dboydtiHTK/uyrKThNw=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260319.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GonXb3TBp23brWlPMA+uL42qUvBTNkCHFoE653tQK5SQkCvCSO3GYyY9K5GhEdG6kV2vDpG+Y98p1BYTM5z0Qg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="],

View File

@@ -49,6 +49,7 @@
"@sentry/cli": "^2.42.2",
"@types/bun": "^1.3.5",
"@types/node": "^24.3.3",
"@typescript/native-preview": "^7.0.0-dev.20260319.1",
"commander": "^14.0.1",
"dotenv": "^17.2.3",
"globals": "^16.4.0",

View File

@@ -1,7 +1,10 @@
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type { RemoteSkillCatalog, RemoteSkillEntry } from '../apps/server/src/skills/types'
import type {
RemoteSkillCatalog,
RemoteSkillEntry,
} from '../apps/server/src/skills/types'
const DEFAULTS_DIR = join(import.meta.dir, '../apps/server/src/skills/defaults')
const R2_KEY = 'skills/v1/catalog.json'