diff --git a/.changeset/list-project-default.md b/.changeset/list-project-default.md new file mode 100644 index 0000000..52fcf01 --- /dev/null +++ b/.changeset/list-project-default.md @@ -0,0 +1,5 @@ +--- +'skilless': minor +--- + +`skilless list` now lists the current project's skills by default, including ones in the project's own skill folders (⌂), marks global skills with ⊕, and drops the date. Pass `--lib` to list your whole library. It now reads only what is on this machine, so it's instant; pass `--sync` to check skilless.dev first. diff --git a/.changeset/local-first-commands.md b/.changeset/local-first-commands.md new file mode 100644 index 0000000..16273d2 --- /dev/null +++ b/.changeset/local-first-commands.md @@ -0,0 +1,5 @@ +--- +'skilless': minor +--- + +Commands now work from what is on this machine and send their changes to skilless.dev in a background process, so they finish right away. Pass `--sync` to read the latest from skilless.dev first. The background process also checks for changes made elsewhere and the next command tells you to run `skilless sync`. skilless now tells you when a new version is out. diff --git a/apps/web/src/content/docs/cli.md b/apps/web/src/content/docs/cli.md index 9722596..acc621f 100644 --- a/apps/web/src/content/docs/cli.md +++ b/apps/web/src/content/docs/cli.md @@ -5,6 +5,10 @@ description: Every skilless command and option. Run `skilless --help` for the same information in your terminal. Every command also takes `--cwd ` to run as if from another directory. +Commands only read what is on this machine, so they finish right away and work offline. Anything they change is sent to skilless cloud in the background. Commands that read your library also take `-s, --sync` to check skilless cloud for changes made elsewhere first. See [sync](/docs/cloud/sync). + +skilless tells you when a new version is out. Set `NO_UPDATE_NOTIFIER=1` to turn that off. + ## init Create your library at `~/.skilless`. @@ -92,15 +96,17 @@ skilless delete ## list -List the skills in your library. +List every skill this project's agents get, and how each one reaches them: ⊕ for a global skill, ⌂ for one that sits in the project's own skill folders. Outside a project, it lists your library. ```bash skilless list ``` -| Option | Description | -| --------------------- | ---------------------------------------------------------- | -| `-p, --project [key]` | List this project's skills, or another project's, instead. | +| Option | Description | +| --------------------- | -------------------------------------- | +| `-l, --lib` | List every skill in your library. | +| `-d, --details` | Show what each skill does. | +| `-p, --project [key]` | List another project's skills instead. | ## install diff --git a/apps/web/src/content/docs/cloud/sync.md b/apps/web/src/content/docs/cloud/sync.md index 38ff1cf..745e732 100644 --- a/apps/web/src/content/docs/cloud/sync.md +++ b/apps/web/src/content/docs/cloud/sync.md @@ -27,7 +27,19 @@ skilless sync Sync runs in both directions. New skills and edits go up from this machine and down from your others, and a skill deleted in one place is deleted everywhere. It then relinks this project's skills and your global ones to match. -Changes you make while offline are queued and sent the next time a command reaches the server. +## Between syncs + +Every other command works from what is on this machine, so it finishes right away. What it changes (a new skill, an edit, a skill added to a project, one made global or deleted) is sent to skilless cloud by a process that runs in the background after the command finishes. If that can't reach the server, the change waits and goes with your next command, and skilless tells you. + +The background process also checks every so often for changes made somewhere else, like on another machine or on skilless.dev. When it finds some, your next command says so, and you run `skilless sync` to bring them here. Nothing comes down until you ask for it. + +To read the latest from skilless cloud for one command, pass `--sync`: + +```bash +skilless list --sync +``` + +Set `SKILLESS_BACKGROUND=0` to turn the background process off. Your changes then wait for `skilless sync`. ## Conflicts diff --git a/packages/skilless/package.json b/packages/skilless/package.json index 46b55fe..cfeac2f 100644 --- a/packages/skilless/package.json +++ b/packages/skilless/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@types/node": "catalog:", + "@types/update-notifier": "^6.0.8", "commander": "catalog:", "enquirer": "^2.4.1", "ora": "^9.4.1", @@ -48,5 +49,8 @@ "typescript": "catalog:", "vitest": "catalog:", "zod": "catalog:" + }, + "dependencies": { + "update-notifier": "^7.3.1" } } diff --git a/packages/skilless/src/bin.ts b/packages/skilless/src/bin.ts index 9d969c7..83632a1 100644 --- a/packages/skilless/src/bin.ts +++ b/packages/skilless/src/bin.ts @@ -1,5 +1,13 @@ #!/usr/bin/env node +import updateNotifier from 'update-notifier'; import { cli } from '@/cli'; +import { BACKGROUND_COMMAND } from '@/utils/background'; +import { NAME, VERSION } from '@/utils/version'; + +// checks npm in its own detached process, and says so on a later run +if (process.argv[2] !== BACKGROUND_COMMAND) { + updateNotifier({ pkg: { name: NAME, version: VERSION } }).notify(); +} cli.parse(); diff --git a/packages/skilless/src/cli.ts b/packages/skilless/src/cli.ts index c556e0b..6d184ce 100644 --- a/packages/skilless/src/cli.ts +++ b/packages/skilless/src/cli.ts @@ -1,7 +1,14 @@ import { program } from 'commander'; import * as commands from '@/commands'; +import { BACKGROUND_COMMAND, showNotices, startBackground } from '@/utils/background'; import { DESCRIPTION, NAME, VERSION } from '@/utils/version'; +/** + * Talk to the server themselves, so a background run after them would only + * repeat the work — or, for the background command, start itself again. + */ +const QUIET = new Set([BACKGROUND_COMMAND, 'sync', 'install', 'auth']); + const cli = program .name(NAME) .description(DESCRIPTION) @@ -24,6 +31,13 @@ const cli = program .addCommand(commands.sync) // Put the built-in help command back with the regular commands. .commandsGroup('Commands:') - .helpCommand(true); + .helpCommand(true) + .addCommand(commands.background, { hidden: true }) + .hook('postAction', (_, action) => { + if (QUIET.has(action.name())) return; + + showNotices(); + startBackground(action.opts().cwd ?? process.cwd()); + }); export { cli }; diff --git a/packages/skilless/src/commands/add.ts b/packages/skilless/src/commands/add.ts index 48044bd..f310816 100644 --- a/packages/skilless/src/commands/add.ts +++ b/packages/skilless/src/commands/add.ts @@ -4,14 +4,14 @@ import { z } from 'zod'; import { SkillessError } from '@/utils/errors'; import * as git from '@/utils/git'; import { - flush, - type LibraryEntry, + fetchMissing, readBindings, readLibrary, refreshGlobals, refreshProject, saveToLibrary } from '@/utils/library'; +import { pushInBackground } from '@/utils/background'; import { queueBind, queueGlobal } from '@/utils/pending'; import * as project from '@/utils/project'; import { confirm, isInteractive, log, multiselect, spin } from '@/utils/prompts'; @@ -24,8 +24,11 @@ import { VERSION } from '@/utils/version'; import { commonOptions, defaultCommandOptionsSchema, + load, parseOptions, + remoteIf, requireProjectKey, + skillChoices, tryCommand, USER_SKILLS, settleRefresh @@ -37,6 +40,7 @@ const schema = defaultCommandOptionsSchema.extend({ global: z.boolean().optional(), notGlobal: z.boolean().optional(), overwrite: z.boolean(), + sync: z.boolean().optional(), yes: z.boolean() }); @@ -60,13 +64,14 @@ export const add = new Command('add') .option('--overwrite', 'From a repo: replace library skills of the same name.', false) .addOption(commonOptions.yes) .addOption(commonOptions.project) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (names: string[], raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); + let remote = remoteIf(options.sync); const [first, ...rest] = names; @@ -82,24 +87,32 @@ export const add = new Command('add') }); } - const { entries: library, live } = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); - - // without the server, a skill that is not on disk has nothing to link - const linkable = (skill: LibraryEntry) => live || skill.local !== null; - const assertLinkable = (selected: string[]) => { - const missing = library - .filter((skill) => selected.includes(skill.name) && !linkable(skill)) + // the project's skills only matter when adding to one, but are read + // alongside the library rather than after it + const projectKey = + options.global || options.notGlobal + ? null + : (options.project ?? git.projectKey(options.cwd)); + + const [{ entries: library }, boundNames] = await load(remote, () => + Promise.all([readLibrary(remote), projectKey ? readBindings(remote, projectKey) : []]) + ); + + // a skill your library has that is not on disk yet is fetched to be linked + const ensureLocal = async (selected: string[]) => { + const absent = library + .filter((skill) => selected.includes(skill.name) && skill.local === null) .map((skill) => skill.name); + if (absent.length === 0) return; - if (missing.length === 0) return; + const server = (remote ??= new Remote()); + const failed = await spin('Fetching from skilless.dev', () => fetchMissing(server, absent)); - throw new SkillessError( - `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not on this machine, and the server can't be reached to fetch ${missing.length === 1 ? 'it' : 'them'}.`, - { suggestion: 'Run `skilless sync` once you are back online, then try again.' } - ); + if (failed.length > 0) { + throw new SkillessError(`Couldn't fetch ${failed.join(', ')} from skilless.dev.`, { + suggestion: 'Check your connection, then try again.' + }); + } }; const known = new Set(library.map((skill) => skill.name)); @@ -118,9 +131,7 @@ export const add = new Command('add') let selected = names; if (selected.length === 0) { - const candidates = library.filter( - (skill) => skill.global !== value && (!value || linkable(skill)) - ); + const candidates = library.filter((skill) => skill.global !== value); if (candidates.length === 0) { log.info( @@ -133,7 +144,7 @@ export const add = new Command('add') selected = await multiselect( value ? 'Make global' : 'Stop being global', - candidates.map((skill) => ({ name: skill.name })) + skillChoices(candidates.map((entry) => ({ entry }))) ); if (selected.length === 0) { @@ -142,48 +153,41 @@ export const add = new Command('add') } } - if (value) assertLinkable(selected); + if (value) await ensureLocal(selected); - await setGlobal(remote, selected, value, options); - remote.report(); + await setGlobal(selected, value, options); + remote?.report(); return; } /* ----------------------------------------------------------- project */ - const key = requireProjectKey(options.cwd, options.project); - const bound = new Set(await spin(`Loading ${key}`, () => readBindings(remote, key))); + const key = requireProjectKey(options.cwd, projectKey ?? undefined); + const bound = new Set(boundNames); let selected = names; if (selected.length === 0) { // globals are already in every project, so there is nothing to add - const available = library.filter( - (skill) => !bound.has(skill.name) && !skill.global && linkable(skill) - ); + const available = library.filter((skill) => !bound.has(skill.name) && !skill.global); if (available.length === 0) { - log.info( - live - ? 'Every skill in your library is already in this project.' - : 'Every skill on this machine is already in this project.' - ); - remote.report(); + log.info('Every skill in your library is already in this project.'); + remote?.report(); return; } - // already-added skills stay in the list, so it is clear they were not lost + // already-added skills stay in the list, so it is clear they were not lost. + // Globals are in every project already, so they would only be noise selected = await multiselect( `Add to ${key}`, - library - .filter((skill) => skill.global || bound.has(skill.name) || linkable(skill)) - .map((skill) => - skill.global - ? { name: skill.name, disabled: 'installed globally' } - : bound.has(skill.name) - ? { name: skill.name, disabled: 'already added' } - : { name: skill.name } - ) + skillChoices( + library + .filter((entry) => !entry.global) + .map((entry) => + bound.has(entry.name) ? { entry, disabled: 'already added' } : { entry } + ) + ) ); if (selected.length === 0) { @@ -193,10 +197,10 @@ export const add = new Command('add') } const globals = library.filter((skill) => skill.global).map((skill) => skill.name); - assertLinkable(selected.filter((name) => !globals.includes(name))); + await ensureLocal(selected.filter((name) => !globals.includes(name))); - await bind(remote, key, selected, globals, options); - remote.report(); + await bind(key, selected, globals, options); + remote?.report(); }); }); @@ -205,14 +209,9 @@ export const add = new Command('add') * lives once at the user level (`~/.agents/skills`, `~/.claude/skills`) rather * than in every project that uses it. */ -async function setGlobal( - remote: Remote, - names: string[], - value: boolean, - options: Options -): Promise { +async function setGlobal(names: string[], value: boolean, options: Options): Promise { queueGlobal(names, value); - await spin('Saving to skilless.dev', () => flush(remote)); + pushInBackground(); for (const name of names) { log.step( @@ -223,15 +222,15 @@ async function setGlobal( const key = options.project ?? git.projectKey(options.cwd); const root = project.projectRoot(options.cwd); - const [result, user] = await spin('Updating your skills', async () => { + const [result, user] = await (async () => { if (!key) { - const user = await refreshGlobals(remote); + const user = await refreshGlobals(null); return [user, user] as const; } - const result = await refreshProject(remote, root, key, { copy: options.copy }); + const result = await refreshProject(null, root, key, { copy: options.copy }); return [result, result.user] as const; - }); + })(); for (const name of user.written.filter((name) => names.includes(name))) log.dim(`Linked ${name} into ${USER_SKILLS}.`); @@ -248,7 +247,6 @@ async function setGlobal( /** Binds skills to a project and links them in. */ async function bind( - remote: Remote, key: string, names: string[], globals: string[], @@ -262,12 +260,10 @@ async function bind( const toBind = names.filter((name) => !globals.includes(name)); queueBind(key, toBind); + pushInBackground(); const root = project.projectRoot(options.cwd); - const installed = await spin('Updating this project', async () => { - await flush(remote); - return refreshProject(remote, root, key, { copy: options.copy }); - }); + const installed = await refreshProject(null, root, key, { copy: options.copy }); for (const name of toBind) log.step(`Added ${name} to this project.`); @@ -280,7 +276,7 @@ async function bind( * one remembers the repo it came from, so `skilless update` can refresh it. */ async function addFromSource( - remote: Remote, + remote: Remote | null, source: Source, wanted: string[], options: Options @@ -292,10 +288,7 @@ async function addFromSource( // resolve this before cloning, so a bad --project fails fast const key = options.global ? null : (options.project ?? git.projectKey(options.cwd)); - const library = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); + const library = await load(remote, () => readLibrary(remote)); const existing = new Map(library.entries.map((skill) => [skill.name, skill])); const requested = [...wanted, ...(source.skill ? [source.skill] : [])]; @@ -365,7 +358,7 @@ async function addFromSource( if (chosen.length === 0) { log.info('Nothing selected.'); - remote.report(); + remote?.report(); return; } @@ -410,25 +403,23 @@ async function addFromSource( names.push(skill.name); } - const saved = await spin(`Saving ${toSave.length} skill(s) to your library`, () => - saveToLibrary(remote, toSave, existing, sources) - ); + const saved = saveToLibrary(toSave, existing, sources); for (const name of saved) { log.step(`Copied ${name} into your library from ${source.label}.`); } if (names.length === 0) { - remote.report(); + remote?.report(); return; } if (options.global) { - await setGlobal(remote, names, true, options); + await setGlobal(names, true, options); } else if (key) { - const library = await spin('Loading your library', () => readLibrary(remote)); + const library = await readLibrary(null); const globals = library.entries.filter((skill) => skill.global).map((skill) => skill.name); - await bind(remote, key, names, globals, options); + await bind(key, names, globals, options); } else { log.blank(); log.dim( @@ -436,7 +427,7 @@ async function addFromSource( ); } - remote.report(); + remote?.report(); } /** A description short enough to sit beside a name in a list. */ diff --git a/packages/skilless/src/commands/background.ts b/packages/skilless/src/commands/background.ts new file mode 100644 index 0000000..cfd3e09 --- /dev/null +++ b/packages/skilless/src/commands/background.ts @@ -0,0 +1,22 @@ +import { Command } from 'commander'; +import { ApiClient } from '@/utils/api'; +import { getApiUrl, getToken } from '@/utils/auth'; +import { BACKGROUND_COMMAND, runBackground } from '@/utils/background'; + +/** Started detached by other commands. Not for people, so it is hidden from help. */ +export const background = new Command(BACKGROUND_COMMAND) + .description('Push changes to skilless.dev and check it for new ones.') + .option('--push', 'Send what changed on this machine.') + .option('--check', 'Look for changes made elsewhere.') + .option('--key ', 'The project to check.') + .action(async (opts: { push?: boolean; check?: boolean; key?: string }) => { + const token = getToken(); + if (!token) return; + + // nobody is watching, so a failure has nowhere to go but background.json + await runBackground(new ApiClient(token, getApiUrl()), { + push: opts.push === true, + check: opts.check === true, + key: opts.key ?? null + }).catch(() => {}); + }); diff --git a/packages/skilless/src/commands/create.ts b/packages/skilless/src/commands/create.ts index d1341ad..40b0191 100644 --- a/packages/skilless/src/commands/create.ts +++ b/packages/skilless/src/commands/create.ts @@ -5,26 +5,22 @@ import { readConfig } from '@/utils/config'; import { EDITORS, isTerminalEditor, openInEditor } from '@/utils/editor'; import { SkillessError } from '@/utils/errors'; import * as git from '@/utils/git'; -import { flush, readLibrary, refreshGlobals, refreshProject } from '@/utils/library'; +import { getToken } from '@/utils/auth'; +import { pushInBackground } from '@/utils/background'; +import { readLibrary, refreshGlobals, refreshProject } from '@/utils/library'; import { queueBind, queueGlobal } from '@/utils/pending'; import { skillDir } from '@/utils/paths'; import * as project from '@/utils/project'; -import { input, isInteractive, log, spin } from '@/utils/prompts'; -import { Remote } from '@/utils/remote'; -import { - assertValidName, - findLocalSkill, - isValidName, - readSkill, - scaffold, - writeSkill -} from '@/utils/skill'; +import { input, isInteractive, log } from '@/utils/prompts'; +import { assertValidName, findLocalSkill, isValidName, scaffold, writeSkill } from '@/utils/skill'; import { readState, writeState } from '@/utils/state'; import { VERSION } from '@/utils/version'; import { commonOptions, defaultCommandOptionsSchema, + load, parseOptions, + remoteIf, tryCommand, settleRefresh } from './utils'; @@ -33,6 +29,7 @@ const schema = defaultCommandOptionsSchema.extend({ description: z.string().optional(), project: z.string().optional(), global: z.boolean().optional(), + sync: z.boolean().optional(), open: z.boolean() }); @@ -48,17 +45,15 @@ export const create = new Command('create') ) .option('--no-open', 'Do not open SKILL.md in your editor (see `skilless config editor`).') .addOption(commonOptions.project) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (nameArg: string | undefined, raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); - const library = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); + const remote = remoteIf(options.sync); + const library = await load(remote, () => readLibrary(remote)); const taken = new Set(library.entries.map((skill) => skill.name)); let name = nameArg; @@ -98,22 +93,12 @@ export const create = new Command('create') writeSkill(name, scaffold(name, description)); - // left out of state.json if this does not go through, which is exactly - // what makes the next sync push it - const skill = readSkill(skillDir(name), name); - const pushed = await spin(`Creating ${name}`, () => - remote.try((api) => api.putSkill(name, skill.files, skill.editedAt)) - ); - - if (pushed) { - const state = readState(); - state.skills[name] = { - contentHash: skill.contentHash, - editedAt: skill.editedAt, - syncedAt: Date.now() - }; - writeState(state); - } + // unknown to state.json is what makes a push send it as new, rather + // than read it as a skill deleted elsewhere + const state = readState(); + delete state.skills[name]; + writeState(state); + pushInBackground(); log.step(`Created ${name} in your library.`); @@ -128,19 +113,9 @@ export const create = new Command('create') const root = project.projectRoot(options.cwd); // the whole resolved set, so any global skills land too - const result = await spin('Updating this project', async () => { - await flush(remote); - return refreshProject(remote, root, key); - }); - await settleRefresh(result); + await settleRefresh(await refreshProject(null, root, key)); } else if (options.global) { - const result = await spin('Linking your global skills', async () => { - await flush(remote); - return refreshGlobals(remote); - }); - await settleRefresh(result); - } else { - await spin('Saving to skilless.dev', () => flush(remote)); + await settleRefresh(await refreshGlobals(null)); } if (options.global) log.step(`${name} is now global, so every project has it.`); @@ -166,8 +141,8 @@ export const create = new Command('create') log.dim(`Edit ${name} at ${file}`); } - if (remote.api) log.dim('Run `skilless sync` to save your changes.'); + if (getToken()) log.dim('Run `skilless sync` to save your changes.'); - remote.report(); + remote?.report(); }); }); diff --git a/packages/skilless/src/commands/delete.ts b/packages/skilless/src/commands/delete.ts index ea7c5ac..e70189f 100644 --- a/packages/skilless/src/commands/delete.ts +++ b/packages/skilless/src/commands/delete.ts @@ -2,19 +2,27 @@ import { Command } from 'commander'; import { z } from 'zod'; import { SkillessError } from '@/utils/errors'; import * as fsu from '@/utils/fs'; -import { flush, readLibrary } from '@/utils/library'; +import { pushInBackground } from '@/utils/background'; +import { readLibrary } from '@/utils/library'; import { skillDir } from '@/utils/paths'; import { queueDelete } from '@/utils/pending'; import { dropSources } from '@/utils/sources'; import * as project from '@/utils/project'; -import { confirm, log, spin } from '@/utils/prompts'; -import { Remote } from '@/utils/remote'; +import { confirm, log } from '@/utils/prompts'; import { readState, writeState } from '@/utils/state'; import { VERSION } from '@/utils/version'; -import { commonOptions, defaultCommandOptionsSchema, parseOptions, tryCommand } from './utils'; +import { + commonOptions, + defaultCommandOptionsSchema, + load, + parseOptions, + remoteIf, + tryCommand +} from './utils'; const schema = defaultCommandOptionsSchema.extend({ - yes: z.boolean() + yes: z.boolean(), + sync: z.boolean().optional() }); /** @@ -25,17 +33,15 @@ export const deleteCommand = new Command('delete') .description('Delete skills from your library, everywhere.') .argument('', 'Skills to delete.') .addOption(commonOptions.yes) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (names: string[], raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); - const library = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); + const remote = remoteIf(options.sync); + const library = await load(remote, () => readLibrary(remote)); const known = new Set(library.entries.map((skill) => skill.name)); const unknown = names.filter((name) => !known.has(name)); @@ -78,12 +84,12 @@ export const deleteCommand = new Command('delete') project.unmaterialize(root, names); project.unmaterialize(project.userScope(), names); - await spin('Deleting from skilless.dev', () => flush(remote)); + pushInBackground(); log.blank(); log.dim('Other checkouts will clean up on their next `skilless install`.'); log.dim('Restore at skilless.dev/skills within 30 days.'); - remote.report(); + remote?.report(); }); }); diff --git a/packages/skilless/src/commands/import.ts b/packages/skilless/src/commands/import.ts index b02a3bb..f7923cc 100644 --- a/packages/skilless/src/commands/import.ts +++ b/packages/skilless/src/commands/import.ts @@ -4,17 +4,24 @@ import path from 'pathe'; import { z } from 'zod'; import { SkillessError } from '@/utils/errors'; import * as fsu from '@/utils/fs'; -import { flush, readLibrary, saveToLibrary } from '@/utils/library'; -import { confirm, log, spin } from '@/utils/prompts'; -import { Remote } from '@/utils/remote'; +import { readLibrary, saveToLibrary } from '@/utils/library'; +import { confirm, log } from '@/utils/prompts'; import { isValidName, readSkill, SKILL_FILE } from '@/utils/skill'; import type { LocalSkill } from '@/utils/types'; import { VERSION } from '@/utils/version'; -import { commonOptions, defaultCommandOptionsSchema, parseOptions, tryCommand } from './utils'; +import { + commonOptions, + defaultCommandOptionsSchema, + load, + parseOptions, + remoteIf, + tryCommand +} from './utils'; const schema = defaultCommandOptionsSchema.extend({ yes: z.boolean(), - overwrite: z.boolean() + overwrite: z.boolean(), + sync: z.boolean().optional() }); /** Any immediate subdirectory holding a SKILL.md is a skill. */ @@ -32,13 +39,14 @@ export const importCommand = new Command('import') .argument('', 'A directory whose subdirectories each contain a SKILL.md.') .option('--overwrite', 'Replace skills that already exist.', false) .addOption(commonOptions.yes) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (dir: string, raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); + const remote = remoteIf(options.sync); const source = path.resolve(options.cwd, dir); if (!fsu.exists(source)) { @@ -53,10 +61,7 @@ export const importCommand = new Command('import') }); } - const library = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); + const library = await load(remote, () => readLibrary(remote)); const existing = new Map(library.entries.map((skill) => [skill.name, skill])); const importable: LocalSkill[] = []; @@ -87,7 +92,7 @@ export const importCommand = new Command('import') if (importable.length === 0) { log.info('Nothing to import.'); - remote.report(); + remote?.report(); return; } @@ -97,9 +102,7 @@ export const importCommand = new Command('import') const ok = options.yes || (await confirm(`Import ${importable.length} skill(s)?`, true)); if (!ok) return; - const imported = await spin(`Importing ${importable.length} skill(s)`, () => - saveToLibrary(remote, importable, existing) - ); + const imported = saveToLibrary(importable, existing); for (const name of imported) { log.step(`Imported ${name} into your library.`); @@ -110,6 +113,6 @@ export const importCommand = new Command('import') `Run \`skilless add ${importable[0]?.name ?? ''}\` to add one to this project.` ); - remote.report(); + remote?.report(); }); }); diff --git a/packages/skilless/src/commands/index.ts b/packages/skilless/src/commands/index.ts index 49cb880..115ebf5 100644 --- a/packages/skilless/src/commands/index.ts +++ b/packages/skilless/src/commands/index.ts @@ -1,5 +1,6 @@ import { add } from '@/commands/add'; import { auth } from '@/commands/auth'; +import { background } from '@/commands/background'; import { config } from '@/commands/config'; import { create } from '@/commands/create'; import { deleteCommand } from '@/commands/delete'; @@ -16,6 +17,7 @@ import { vendor } from '@/commands/vendor'; export { add, auth, + background, config, create, deleteCommand, diff --git a/packages/skilless/src/commands/list.ts b/packages/skilless/src/commands/list.ts index 79effbb..617d745 100644 --- a/packages/skilless/src/commands/list.ts +++ b/packages/skilless/src/commands/list.ts @@ -1,90 +1,156 @@ import { Command } from 'commander'; import pc from 'picocolors'; import { z } from 'zod'; -import { normalizeRemote } from '@/utils/git'; -import { - flush, - type LibraryEntry, - readBindings, - readLibrary, - resolveProject -} from '@/utils/library'; -import { log, spin } from '@/utils/prompts'; -import { Remote } from '@/utils/remote'; +import { projectKey } from '@/utils/git'; +import { type LibraryEntry, readBindings, readLibrary, resolveProject } from '@/utils/library'; +import { getToken } from '@/utils/auth'; +import { folderSkills, ownedSkills, projectRoot } from '@/utils/project'; +import fs from 'node:fs'; +import path from 'pathe'; +import { AGENTS_SKILLS, CLAUDE_SKILLS } from '@/utils/paths'; +import { log, wrap } from '@/utils/prompts'; +import { SKILL_FILE } from '@/utils/skill'; +import { frontmatter } from '@/utils/source'; import { VERSION } from '@/utils/version'; import { commonOptions, defaultCommandOptionsSchema, + load, parseOptions, - requireProjectKey, + remoteIf, + skillDetails, tryCommand } from './utils'; const schema = defaultCommandOptionsSchema.extend({ + lib: z.boolean().optional(), + details: z.boolean().optional(), + sync: z.boolean().optional(), project: z.union([z.boolean(), z.string()]).optional() }); -/** `synced` is off when signed out, where "not synced" would tag every skill. */ -function markers(skill: LibraryEntry, synced: boolean): string { - const out: string[] = []; +/** Plain text glyphs rather than emoji, so they take the terminal's own font and color. */ +const GLOBAL = '⊕'; +const FOLDER = '⌂'; + +/** A skill as it reads in the list: how it reaches the agents, then gray details. */ +type Row = { name: string; icon: string | null; details: string[]; description?: string }; + +/** What a skill says it does, from its SKILL.md frontmatter. */ +function describe(contents: string | undefined): string | undefined { + return contents === undefined ? undefined : frontmatter(contents).description; +} + +/** A skill in the project's own folders, read from whichever one holds it. */ +function folderRow(root: string, name: string): Row { + const file = [AGENTS_SKILLS, CLAUDE_SKILLS] + .map((dir) => path.join(root, dir, name, SKILL_FILE)) + .find((candidate) => fs.existsSync(candidate)); + + return { + name, + icon: FOLDER, + details: [], + description: describe(file ? fs.readFileSync(file, 'utf8') : undefined) + }; +} + +function libraryRow(skill: LibraryEntry, synced: boolean): Row { + return { + name: skill.name, + icon: skill.global ? GLOBAL : null, + details: skillDetails(skill, synced), + // a skill only on the server has no files here to read it from + description: describe(skill.local?.files.find((file) => file.path === SKILL_FILE)?.contents) + }; +} - if (skill.global) out.push('global'); - if (skill.source) out.push(`from ${normalizeRemote(skill.source.url) ?? skill.source.url}`); - if (!skill.local) out.push('not on this machine'); - else if (synced && skill.unsynced) out.push('not synced'); +function print(rows: Row[], details: boolean): void { + const width = Math.max(...rows.map((row) => row.name.length)) + 2; + const iconless = rows.every((row) => row.icon === null); - return out.map((marker) => ` · ${marker}`).join(''); + for (const [i, row] of rows.entries()) { + const icon = iconless ? '' : `${row.icon ?? ' '} `; + log.raw(` ${row.name.padEnd(width)}${icon}${pc.gray(row.details.join(' · '))}`.trimEnd()); + + if (!details) continue; + + if (row.description) + process.stdout.write(pc.gray(wrap(row.description.split(/\s+/), ' ', ' '))); + // a gap between skills, or the descriptions run together + if (i < rows.length - 1) log.blank(); + } } export const list = new Command('list') - .description('List your skills, or the ones added to this project.') - .option('-p, --project [key]', 'List this project’s skills instead of your library.') + .description('List the skills this project’s agents get, or your whole library.') + .option('-l, --lib', 'List every skill in your library.') + .option('-d, --details', 'Show what each skill does, from its SKILL.md.') + .option('-p, --project [key]', 'List another project’s skills.') + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); - const library = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); - - if (options.project !== undefined) { - const key = requireProjectKey( - options.cwd, - typeof options.project === 'string' ? options.project : undefined - ); - const bound = await spin(`Loading ${key}`, () => readBindings(remote, key)); - const skills = resolveProject(library, bound); + const here = projectKey(options.cwd); + const key = options.lib ? null : typeof options.project === 'string' ? options.project : here; - log.info(`${pc.bold(key)}`); + // everything is on this machine already: the store, the cache of what the + // server last said, and the links themselves. The server is only asked with --sync + const remote = remoteIf(options.sync); + const [library, cached] = await load(remote, () => + Promise.all([readLibrary(remote), key ? readBindings(remote, key) : []]) + ); + const synced = getToken() !== null; + + if (key) { + let bound = cached; + let inFolder: string[] = []; + + if (key === here) { + const root = projectRoot(options.cwd); + // what is linked here is what agents get, whatever the cache says + bound = [...new Set([...cached, ...ownedSkills(root)])]; + inFolder = folderSkills(root); + } + + let rows = resolveProject(library, bound).map((skill) => libraryRow(skill, synced)); + + // what sits in the folder is what agents read, even over a library skill + if (inFolder.length > 0) { + rows = [ + ...rows.filter((row) => !inFolder.includes(row.name)), + ...inFolder.map((name) => folderRow(projectRoot(options.cwd), name)) + ].sort((a, b) => a.name.localeCompare(b.name)); + } + + log.info(`Listing skills for ${pc.bold(key)}`); log.blank(); - if (skills.length === 0) { + if (rows.length === 0) { log.dim('No skills in this project. Run `skilless add ` to add one.'); + } else { + print(rows, options.details === true); } - for (const skill of skills) - log.dim(` ${skill.name}${pc.gray(markers(skill, remote.api !== null))}`); - - remote.report(); + remote?.report(); return; } + log.info('Listing every skill in your library'); + log.blank(); + if (library.entries.length === 0) { log.dim('Your library is empty. Run `skilless create ` to make one.'); - } - - for (const skill of library.entries) { - const updatedAt = skill.local?.editedAt ?? skill.remote?.updatedAt ?? 0; - const when = new Date(updatedAt).toISOString().slice(0, 10); - log.dim( - ` ${skill.name.padEnd(28)} ${pc.gray(when + markers(skill, remote.api !== null))}` + } else { + print( + library.entries.map((skill) => libraryRow(skill, synced)), + options.details === true ); } - remote.report(); + remote?.report(); }); }); diff --git a/packages/skilless/src/commands/migrate.ts b/packages/skilless/src/commands/migrate.ts index 416a22d..d82831e 100644 --- a/packages/skilless/src/commands/migrate.ts +++ b/packages/skilless/src/commands/migrate.ts @@ -5,11 +5,12 @@ import { z } from 'zod'; import { SkillessError } from '@/utils/errors'; import * as fsu from '@/utils/fs'; import * as git from '@/utils/git'; -import { flush, readLibrary, refreshGlobals, refreshProject } from '@/utils/library'; +import { pushInBackground } from '@/utils/background'; +import { readLibrary, refreshGlobals, refreshProject } from '@/utils/library'; import { AGENTS_SKILLS, CLAUDE_SKILLS, userAgentsSkills, userClaudeSkills } from '@/utils/paths'; import { queueBind, queueGlobal } from '@/utils/pending'; import * as project from '@/utils/project'; -import { confirm, isInteractive, log, select, spin } from '@/utils/prompts'; +import { confirm, isInteractive, log, select } from '@/utils/prompts'; import { Remote } from '@/utils/remote'; import { isValidName, readSkill, SKILL_FILE, writeSkill } from '@/utils/skill'; import { readState, writeState } from '@/utils/state'; @@ -19,7 +20,10 @@ import { VERSION } from '@/utils/version'; import { commonOptions, defaultCommandOptionsSchema, + fetchFiles, + load, parseOptions, + remoteIf, tryCommand, settleRefresh, tilde @@ -28,7 +32,8 @@ import { const schema = defaultCommandOptionsSchema.extend({ yes: z.boolean(), user: z.boolean().optional(), - includeCommitted: z.boolean().optional() + includeCommitted: z.boolean().optional(), + sync: z.boolean().optional() }); type Scope = 'project' | 'user'; @@ -122,13 +127,14 @@ export const migrate = new Command('migrate') 'Move skills committed to this repo too, without asking. Their removal from git is staged, not committed.' ) .addOption(commonOptions.yes) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (wanted: string[], raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); + let remote = remoteIf(options.sync); const root = project.projectRoot(options.cwd); const key = git.projectKey(options.cwd); @@ -191,10 +197,7 @@ export const migrate = new Command('migrate') /* ------------------------------------------------------------ plan */ - const current = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); + const current = await load(remote, () => readLibrary(remote)); const library = new Map(current.entries.map((e) => [e.name, e])); // a skill found at both levels is planned twice; ask about it once @@ -247,11 +250,7 @@ export const migrate = new Command('migrate') // whichever side loses, the library's files are needed: to keep or to stash const files = existing.local?.files ?? - ( - await spin(`Fetching ${name} from your library`, () => - remote.try((api) => api.getSkill(name)) - ) - )?.files; + (await fetchFiles((remote ??= new Remote()), name, `Fetching ${name} from your library`)); if (!files) { log.warn(`Skipped ${name}: could not fetch the one in your library.`); return null; @@ -406,20 +405,8 @@ export const migrate = new Command('migrate') writeSkill(skill.name, skill.files, skill.editedAt); if (adopted) continue; - const pushed = await spin(`Saving ${skill.name} to your library`, () => - remote.try((api) => api.putSkill(skill.name, skill.files, skill.editedAt)) - ); - - if (pushed) { - state.skills[skill.name] = { - contentHash: skill.contentHash, - editedAt: skill.editedAt, - syncedAt: Date.now() - }; - } else { - // unknown to state.json, so the next sync pushes it - delete state.skills[skill.name]; - } + // unknown to state.json, so the background push sends it + delete state.skills[skill.name]; } writeState(state); @@ -447,7 +434,7 @@ export const migrate = new Command('migrate') if (globals.length > 0) queueGlobal(globals, true); if (key && bound.length > 0) queueBind(key, bound); - await spin('Saving to skilless.dev', () => flush(remote)); + pushInBackground(); // a skill already in the library with the same contents was only linked, // and saying it was moved in would hide which ones were already there @@ -487,17 +474,11 @@ export const migrate = new Command('migrate') // the originals are gone, so link everything back — a global lands // at the user level, once, right where it was before if (key) { - await settleRefresh( - await spin('Updating this project', () => refreshProject(remote, root, key)), - { yes: options.yes } - ); + await settleRefresh(await refreshProject(null, root, key), { yes: options.yes }); } else if (globals.length > 0) { - await settleRefresh( - await spin('Linking your global skills', () => refreshGlobals(remote)), - { yes: options.yes } - ); + await settleRefresh(await refreshGlobals(null), { yes: options.yes }); } - remote.report(); + remote?.report(); }); }); diff --git a/packages/skilless/src/commands/remove.ts b/packages/skilless/src/commands/remove.ts index 2cd0135..a078500 100644 --- a/packages/skilless/src/commands/remove.ts +++ b/packages/skilless/src/commands/remove.ts @@ -1,15 +1,17 @@ import { Command } from 'commander'; import { z } from 'zod'; -import { flush, readBindings, readLibrary, refreshProject } from '@/utils/library'; +import { pushInBackground } from '@/utils/background'; +import { readBindings, readLibrary, refreshProject } from '@/utils/library'; import { queueUnbind } from '@/utils/pending'; import * as project from '@/utils/project'; -import { log, spin } from '@/utils/prompts'; -import { Remote } from '@/utils/remote'; +import { log } from '@/utils/prompts'; import { VERSION } from '@/utils/version'; import { commonOptions, defaultCommandOptionsSchema, + load, parseOptions, + remoteIf, requireProjectKey, tryCommand, settleRefresh @@ -17,7 +19,8 @@ import { const schema = defaultCommandOptionsSchema.extend({ project: z.string().optional(), - copy: z.boolean().optional() + copy: z.boolean().optional(), + sync: z.boolean().optional() }); /** The inverse of `add`. Non destructive — `delete` is the one that removes a skill. */ @@ -26,19 +29,19 @@ export const remove = new Command('remove') .argument('', 'Skills to remove.') .option('--copy', 'Write real files instead of symlinking into the store.') .addOption(commonOptions.project) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (names: string[], raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); + const remote = remoteIf(options.sync); const key = requireProjectKey(options.cwd, options.project); - const [library, bound] = await spin('Loading your library', async () => { - await flush(remote); - return [await readLibrary(remote), new Set(await readBindings(remote, key))] as const; - }); + const [library, bound] = await load(remote, () => + Promise.all([readLibrary(remote), readBindings(remote, key).then((b) => new Set(b))]) + ); const globals = new Set( library.entries.filter((skill) => skill.global).map((skill) => skill.name) ); @@ -61,17 +64,15 @@ export const remove = new Command('remove') } if (removing.length === 0) { - remote.report(); + remote?.report(); return; } queueUnbind(key, removing); + pushInBackground(); const root = project.projectRoot(options.cwd); - const result = await spin('Updating this project', async () => { - await flush(remote); - return refreshProject(remote, root, key, { copy: options.copy }); - }); + const result = await refreshProject(null, root, key, { copy: options.copy }); for (const name of removing) log.step(`Removed ${name} from this project.`); @@ -84,6 +85,6 @@ export const remove = new Command('remove') }.` ); - remote.report(); + remote?.report(); }); }); diff --git a/packages/skilless/src/commands/sync.ts b/packages/skilless/src/commands/sync.ts index 45eef4b..6d44c61 100644 --- a/packages/skilless/src/commands/sync.ts +++ b/packages/skilless/src/commands/sync.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { z } from 'zod'; +import { clearStatus } from '@/utils/background'; import { cacheLibrary } from '@/utils/cache'; import * as git from '@/utils/git'; import { installProject, linkGlobals } from '@/utils/install'; @@ -82,6 +83,8 @@ export const sync = new Command('sync') cacheLibrary(latest); adoptSources(latest); + // everything is sent and fetched, so nothing the background noticed still holds + clearStatus(); // inside a project, bring its links up to date too — the store is // current now, so this is what makes a website edit show up here. diff --git a/packages/skilless/src/commands/update.ts b/packages/skilless/src/commands/update.ts index 3365d72..704e49a 100644 --- a/packages/skilless/src/commands/update.ts +++ b/packages/skilless/src/commands/update.ts @@ -2,8 +2,8 @@ import { Command } from 'commander'; import { z } from 'zod'; import { SkillessError } from '@/utils/errors'; import { normalizeRemote } from '@/utils/git'; -import { flush, type LibraryEntry, readLibrary, saveToLibrary } from '@/utils/library'; -import { confirm, isInteractive, log, spin } from '@/utils/prompts'; +import { type LibraryEntry, readLibrary, saveToLibrary } from '@/utils/library'; +import { confirm, isInteractive, log } from '@/utils/prompts'; import { Remote } from '@/utils/remote'; import { readSkill } from '@/utils/skill'; import { setSource } from '@/utils/sources'; @@ -11,11 +11,20 @@ import { locateSkill, withClone } from '@/utils/source'; import { stashFiles } from '@/utils/sync'; import type { LocalSkill, SkillSource } from '@/utils/types'; import { VERSION } from '@/utils/version'; -import { commonOptions, defaultCommandOptionsSchema, parseOptions, tryCommand } from './utils'; +import { + commonOptions, + defaultCommandOptionsSchema, + fetchFiles, + load, + parseOptions, + remoteIf, + tryCommand +} from './utils'; const schema = defaultCommandOptionsSchema.extend({ yes: z.boolean(), - force: z.boolean() + force: z.boolean(), + sync: z.boolean().optional() }); type Tracked = LibraryEntry & { source: SkillSource }; @@ -30,17 +39,15 @@ export const update = new Command('update') .argument('[skills...]', 'Skills to update. Omit to update every skill added from a repository.') .option('-f, --force', 'Replace skills you have edited too. Your copy is kept first.', false) .addOption(commonOptions.yes) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (names: string[], raw) => { const options = parseOptions(schema, raw); log.intro(VERSION); await tryCommand(async () => { - const remote = new Remote(); - const library = await spin('Loading your library', async () => { - await flush(remote); - return readLibrary(remote); - }); + let remote = remoteIf(options.sync); + const library = await load(remote, () => readLibrary(remote)); const existing = new Map(library.entries.map((skill) => [skill.name, skill])); const tracked = library.entries.filter((skill): skill is Tracked => skill.source !== null); @@ -65,7 +72,7 @@ export const update = new Command('update') if (targets.length === 0) { log.info('None of your skills were added from a repository.'); log.dim('Run `skilless add owner/repo` to add some.'); - remote.report(); + remote?.report(); return; } @@ -135,11 +142,11 @@ export const update = new Command('update') const files = skill.local?.files ?? - ( - await spin(`Fetching your copy of ${skill.name}`, () => - remote.try((api) => api.getSkill(skill.name)) - ) - )?.files; + (await fetchFiles( + (remote ??= new Remote()), + skill.name, + `Fetching your copy of ${skill.name}` + )); if (!files) { log.warn( @@ -170,9 +177,7 @@ export const update = new Command('update') log.blank(); - const saved = await spin(`Saving ${toSave.length} skill(s) to your library`, () => - saveToLibrary(remote, toSave, existing, sources) - ); + const saved = saveToLibrary(toSave, existing, sources); for (const name of saved) { log.step(`Updated ${name}.`); @@ -202,6 +207,6 @@ export const update = new Command('update') for (const stash of stashed) log.dim(` ${stash.dir}`); } - remote.report(); + remote?.report(); }); }); diff --git a/packages/skilless/src/commands/utils.ts b/packages/skilless/src/commands/utils.ts index b760ecc..0746412 100644 --- a/packages/skilless/src/commands/utils.ts +++ b/packages/skilless/src/commands/utils.ts @@ -5,12 +5,16 @@ import pc from 'picocolors'; import { z } from 'zod'; import { ApiClient } from '@/utils/api'; import { getApiUrl, getToken } from '@/utils/auth'; +import type { LibraryEntry } from '@/utils/library'; import { NotAProjectError, NotAuthenticatedError, SkillessError } from '@/utils/errors'; import * as git from '@/utils/git'; import { shouldCopy } from '@/utils/install'; import * as project from '@/utils/project'; import type { Skipped } from '@/utils/project'; -import { confirm, isInteractive, log } from '@/utils/prompts'; +import { flush } from '@/utils/library'; +import { confirm, isInteractive, log, spin } from '@/utils/prompts'; +import { Remote } from '@/utils/remote'; +import type { SkillFile } from '@/utils/types'; export const TRACE_ENV_VAR = 'SKILLESS_TRACE'; @@ -21,6 +25,10 @@ export const defaultCommandOptionsSchema = z.object({ export const commonOptions = { cwd: new Option('--cwd ', 'The current working directory.').default(process.cwd()), yes: new Option('-y, --yes', 'Skip confirmation prompts.').default(false), + sync: new Option( + '-s, --sync', + 'Check skilless.dev for changes first. Without it, only what is on this machine is read.' + ), project: new Option( '--project ', 'Override the project key. Defaults to the normalized git remote.' @@ -71,6 +79,77 @@ export function requireApi(): ApiClient { return new ApiClient(token, getApiUrl()); } +/** + * The server, only when `--sync` asks for it. Everything a command reads is + * on this machine already; what it changes goes up in the background. + */ +export function remoteIf(sync: boolean | undefined): Remote | null { + return sync ? new Remote() : null; +} + +/** + * Runs a command's reads. With a remote, whatever is queued goes first, so + * what comes back includes it; without one this is just `run`. + */ +export async function load(remote: Remote | null, run: () => Promise): Promise { + if (!remote) return run(); + + return spin('Checking skilless.dev', async () => { + await flush(remote); + return run(); + }); +} + +/** + * The gray details beside a skill wherever it is listed: where it came from, + * and whether it is missing here or has changes that haven't been sent. + * `synced` is off when signed out, where "not synced" would tag every skill. + */ +export function skillDetails(skill: LibraryEntry, synced = getToken() !== null): string[] { + const details: string[] = []; + + if (skill.source) + details.push(`from ${git.normalizeRemote(skill.source.url) ?? skill.source.url}`); + if (!skill.local) details.push('not on this machine'); + else if (synced && skill.unsynced) details.push('not synced'); + + return details; +} + +/** + * Picker choices laid out like \`skilless list\`: names in a column, details + * beside them in gray. A \`disabled\` reason leads the details and the choice + * cannot be picked. + */ +export function skillChoices( + skills: { entry: LibraryEntry; disabled?: string }[] +): { name: string; message: string; hint?: string; disabled?: boolean }[] { + const width = Math.max(0, ...skills.map(({ entry }) => entry.name.length)) + 2; + + return skills.map(({ entry, disabled }) => { + const hint = [...(disabled ? [disabled] : []), ...skillDetails(entry)].join(' · '); + return { + name: entry.name, + message: hint ? entry.name.padEnd(width) : entry.name, + ...(hint ? { hint } : {}), + ...(disabled ? { disabled: true } : {}) + }; + }); +} + +/** + * A skill's files from the server, for one your library has that is not on + * this machine yet. Null when the server cannot be reached. + */ +export async function fetchFiles( + remote: Remote, + name: string, + message = `Fetching ${name}` +): Promise { + const fetched = await spin(message, () => remote.try((api) => api.getSkill(name))); + return fetched?.files ?? null; +} + /** * A project is identified by its git remote, because that is the only thing a * cloud agent in a fresh container can work out for itself. diff --git a/packages/skilless/src/commands/vendor.ts b/packages/skilless/src/commands/vendor.ts index bf20796..93991e7 100644 --- a/packages/skilless/src/commands/vendor.ts +++ b/packages/skilless/src/commands/vendor.ts @@ -2,19 +2,28 @@ import { Command } from 'commander'; import { z } from 'zod'; import { SkillessError } from '@/utils/errors'; import * as git from '@/utils/git'; -import { flush, readBindings, readLibrary } from '@/utils/library'; +import { pushInBackground } from '@/utils/background'; +import { readBindings, readLibrary } from '@/utils/library'; import { AGENTS_SKILLS, CLAUDE_SKILLS } from '@/utils/paths'; import { queueUnbind } from '@/utils/pending'; import * as project from '@/utils/project'; -import { confirm, isInteractive, log, multiselect, spin } from '@/utils/prompts'; +import { confirm, isInteractive, log, multiselect } from '@/utils/prompts'; import { Remote } from '@/utils/remote'; -import type { SkillFile } from '@/utils/types'; import { VERSION } from '@/utils/version'; -import { commonOptions, defaultCommandOptionsSchema, parseOptions, tryCommand } from './utils'; +import { + commonOptions, + defaultCommandOptionsSchema, + fetchFiles, + load, + parseOptions, + remoteIf, + tryCommand +} from './utils'; const schema = defaultCommandOptionsSchema.extend({ project: z.string().optional(), - yes: z.boolean() + yes: z.boolean(), + sync: z.boolean().optional() }); /** @@ -28,6 +37,7 @@ export const vendor = new Command('vendor') .argument('[skills...]', 'Skills to vendor. Omit to pick from a list.') .addOption(commonOptions.yes) .addOption(commonOptions.project) + .addOption(commonOptions.sync) .addOption(commonOptions.cwd) .action(async (names: string[], raw) => { const options = parseOptions(schema, raw); @@ -41,16 +51,15 @@ export const vendor = new Command('vendor') }); } - const remote = new Remote(); + let remote = remoteIf(options.sync); const key = options.project ?? git.projectKey(options.cwd); - const [library, bound] = await spin('Loading your library', async () => { - await flush(remote); - return [ - await readLibrary(remote), - new Set(key ? await readBindings(remote, key) : []) - ] as const; - }); + const [library, bound] = await load(remote, () => + Promise.all([ + readLibrary(remote), + key ? readBindings(remote, key).then((b) => new Set(b)) : new Set() + ]) + ); const known = new Map(library.entries.map((skill) => [skill.name, skill])); const unknown = names.filter((name) => !known.has(name)); @@ -66,7 +75,7 @@ export const vendor = new Command('vendor') if (selected.length === 0) { if (library.entries.length === 0) { log.info('Your library is empty.'); - remote.report(); + remote?.report(); return; } @@ -87,7 +96,8 @@ export const vendor = new Command('vendor') const vendored: string[] = []; for (const name of selected) { - const files = await spin(`Reading ${name}`, () => filesOf(remote, known.get(name)!)); + const entry = known.get(name)!; + const files = entry.local?.files ?? (await fetchFiles((remote ??= new Remote()), name)); if (!files) { log.warn(`${name} is not on this machine, and the server can't be reached to fetch it.`); @@ -114,7 +124,7 @@ export const vendor = new Command('vendor') } if (vendored.length === 0) { - remote.report(); + remote?.report(); return; } @@ -123,7 +133,7 @@ export const vendor = new Command('vendor') if (key && unbinding.length > 0) { queueUnbind(key, unbinding); - await spin('Updating this project', () => flush(remote)); + pushInBackground(); for (const name of unbinding) log.dim(`Took ${name} out of this project in skilless.`); } @@ -139,17 +149,6 @@ export const vendor = new Command('vendor') .join(', ')} to share ${vendored.length === 1 ? 'it' : 'them'}.` ); - remote.report(); + remote?.report(); }); }); - -/** A skill's files: from the store when it is on this machine, else from the server. */ -async function filesOf( - remote: Remote, - entry: { name: string; local: { files: SkillFile[] } | null } -): Promise { - if (entry.local) return entry.local.files; - - const fetched = await remote.try((api) => api.getSkill(entry.name)); - return fetched?.files ?? null; -} diff --git a/packages/skilless/src/utils/background.ts b/packages/skilless/src/utils/background.ts new file mode 100644 index 0000000..7a0c23b --- /dev/null +++ b/packages/skilless/src/utils/background.ts @@ -0,0 +1,311 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'pathe'; +import type { ApiClient } from '@/utils/api'; +import { getToken } from '@/utils/auth'; +import { cacheLibrary, readCache } from '@/utils/cache'; +import { NotAuthenticatedError, OfflineError } from '@/utils/errors'; +import * as fsu from '@/utils/fs'; +import { projectKey } from '@/utils/git'; +import { readLibrary } from '@/utils/library'; +import { SKILLESS_DIR } from '@/utils/paths'; +import { flushPending, isEmpty, readPending } from '@/utils/pending'; +import { log } from '@/utils/prompts'; +import { listLocalSkills } from '@/utils/skill'; +import { adoptSources } from '@/utils/sources'; +import { readState, writeState } from '@/utils/state'; +import { planSync } from '@/utils/sync'; +import type { LocalSkill, RemoteSkill } from '@/utils/types'; + +/** + * Commands change only this machine, then hand the server its copy of the + * change in a detached process, so the terminal is back the moment the local + * work is done. The same process looks for changes made elsewhere, and leaves + * what it found in `background.json` for the next command to mention. + */ + +/** `0` turns the background process off, for tests and scripts that want none. */ +export const BACKGROUND_ENV = 'SKILLESS_BACKGROUND'; + +export const BACKGROUND_COMMAND = '__background'; + +const STATUS_FILE = path.join(SKILLESS_DIR, 'background.json'); +const LOCK_FILE = path.join(SKILLESS_DIR, 'background.lock'); +/** Left by a push asked for while another was running, so that one goes again. */ +const AGAIN_FILE = path.join(SKILLESS_DIR, 'background.again'); + +/** A lock older than this belongs to a process that died without letting go. */ +const STALE_LOCK_MS = 5 * 60 * 1000; + +/** Changes elsewhere are rare, so checking for them can wait this long. */ +export const CHECK_INTERVAL_MS = 10 * 60 * 1000; + +export type Status = { + /** How the last push went. */ + push?: { at: number; error?: string; conflicts?: string[] }; + /** What the last check found waiting on the server. */ + remote?: { at: number; key: string | null; changes: number }; +}; + +export function readStatus(): Status { + return fsu.readJson(STATUS_FILE, {}); +} + +function updateStatus(change: (status: Status) => void): void { + const status = readStatus(); + change(status); + fsu.writeJson(STATUS_FILE, status); +} + +/** After a full sync there is nothing left to say about either. */ +export function clearStatus(): void { + fsu.remove(STATUS_FILE); +} + +let pushWanted = false; + +/** Asks for a push once the command is done. Cheap to call more than once. */ +export function pushInBackground(): void { + pushWanted = true; +} + +function enabled(): boolean { + return process.env[BACKGROUND_ENV] !== '0' && getToken() !== null; +} + +/** + * What the last background run left to say: a push that failed or hit a + * conflict, or changes waiting on the server. Shown after a command's own output. + */ +export function showNotices(): void { + const { push, remote } = readStatus(); + const lines: (() => void)[] = []; + + if (push?.error) { + lines.push(() => + log.warn( + `${push.error}, so your last changes are only on this machine. They go with your next command, or run \`skilless sync\`.` + ) + ); + } + + if (push?.conflicts?.length) { + const names = push.conflicts; + lines.push(() => + log.warn( + `${names.join(', ')} changed here and on skilless.dev. Run \`skilless sync\` to settle ${names.length === 1 ? 'it' : 'them'}.` + ) + ); + } + + if (remote && remote.changes > 0) { + lines.push(() => + log.warn('Your skills changed on skilless.dev. Run `skilless sync` to bring them here.') + ); + } + + if (lines.length === 0) return; + + log.blank(); + for (const line of lines) line(); +} + +/** + * Whether anything on this machine has not reached the server: a queued change, + * a skill made or edited here, or a push that failed last time. Any command + * then retries it, not only one that changed something. + */ +function unsent(): boolean { + if (!isEmpty(readPending()) || readStatus().push?.error) return true; + + const state = readState(); + return listLocalSkills().some( + (skill) => state.skills[skill.name]?.contentHash !== skill.contentHash + ); +} + +/** Due when it never ran, ran too long ago, or ran for another project. */ +function checkDue(key: string | null): boolean { + const last = readStatus().remote; + return !last || last.key !== key || Date.now() - last.at > CHECK_INTERVAL_MS; +} + +/** + * Starts the background process if there is anything for it to do: a push the + * command asked for, or a check that is due. Called once, after the command. + */ +export function startBackground(cwd: string): void { + if (!enabled()) return; + + const key = projectKey(cwd); + const push = pushWanted || unsent(); + const check = checkDue(key); + if (!push && !check) return; + + const script = process.argv[1]; + if (!script) return; + + const args = [script, BACKGROUND_COMMAND]; + if (push) args.push('--push'); + if (check) args.push('--check', ...(key ? ['--key', key] : [])); + + try { + spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: process.env }).unref(); + } catch { + // the next command tries again + } +} + +function lock(): boolean { + fsu.ensureDir(SKILLESS_DIR); + + try { + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' }); + return true; + } catch { + try { + if (Date.now() - fs.statSync(LOCK_FILE).mtimeMs < STALE_LOCK_MS) return false; + fsu.remove(LOCK_FILE); + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' }); + return true; + } catch { + return false; + } + } +} + +function describe(e: unknown): string { + if (e instanceof OfflineError) return `Couldn't reach ${e.url}`; + if (e instanceof NotAuthenticatedError) return 'Your sign-in was rejected'; + return e instanceof Error ? e.message : String(e); +} + +/** The body of `skilless __background`. Never prompts, never prints. */ +export async function runBackground( + api: ApiClient, + opts: { push: boolean; check: boolean; key: string | null } +): Promise { + if (!lock()) { + // someone else is at it; make sure they also send what this run was for + if (opts.push) fs.writeFileSync(AGAIN_FILE, ''); + return; + } + + try { + let push = opts.push; + + do { + fsu.remove(AGAIN_FILE); + + if (push) { + try { + const { conflicts } = await pushChanges(api); + updateStatus((status) => { + status.push = { at: Date.now(), ...(conflicts.length > 0 ? { conflicts } : {}) }; + }); + } catch (e) { + updateStatus((status) => { + status.push = { at: Date.now(), error: describe(e) }; + }); + return; + } + } + + push = fsu.exists(AGAIN_FILE); + } while (push); + + if (opts.check) { + const changes = await countRemoteChanges(api, opts.key).catch(() => null); + if (changes !== null) { + updateStatus((status) => { + status.remote = { at: Date.now(), key: opts.key, changes }; + }); + } + } + } finally { + fsu.remove(LOCK_FILE); + } +} + +/** + * Sends what changed here: new and edited skills, then every queued change. + * Only ever pushes — a skill that changed on the server as well is left for + * `skilless sync` to settle, and reported as a conflict. + */ +export async function pushChanges(api: ApiClient): Promise<{ conflicts: string[] }> { + const listed = await api.listSkills(); + const remote = new Map(listed.map((skill) => [skill.name, skill])); + const local = new Map(listLocalSkills().map((skill) => [skill.name, skill])); + + const actions = planSync(local, remote, readState()); + const conflicts = actions + .filter((action) => action.type === 'conflict-push' || action.type === 'conflict-pull') + .map((action) => action.name); + + for (const { name } of actions.filter((action) => action.type === 'push')) { + const skill = local.get(name)!; + remote.set(name, await api.putSkill(name, skill.files, skill.editedAt)); + + // read fresh: the command that queued this may have written since + const state = readState(); + state.skills[name] = { + contentHash: skill.contentHash, + editedAt: skill.editedAt, + syncedAt: Date.now() + }; + writeState(state); + } + + const sent = await flushPending(api, [...remote.values()]); + const latest = sent > 0 ? await api.listSkills() : [...remote.values()]; + cacheLibrary(latest); + adoptSources(latest); + + return { conflicts }; +} + +/** + * How many things `skilless sync` would bring down: skills changed or deleted + * on the server, and globals or this project's skills changed there. What + * this machine has queued to send does not count — it is on its way. + */ +export async function countRemoteChanges(api: ApiClient, key: string | null): Promise { + const [listed, resolved] = await Promise.all([ + api.listSkills(), + key ? api.getBindings(key) : Promise.resolve(null) + ]); + + const remote = new Map(listed.map((skill) => [skill.name, skill])); + const local = new Map(listLocalSkills().map((skill) => [skill.name, skill])); + const incoming = new Set(['pull', 'conflict-push', 'conflict-pull', 'delete-local']); + + let changes = planSync(local, remote, readState()).filter((action) => + incoming.has(action.type) + ).length; + + const pending = readPending(); + const library = new Map((await readLibrary(null)).entries.map((entry) => [entry.name, entry])); + + for (const skill of listed) { + const here = library.get(skill.name); + if (here && !(skill.name in pending.globals) && here.global !== skill.global) changes++; + } + + if (key && resolved) { + const change = pending.projects[key] ?? { add: [], remove: [] }; + const queued = new Set([...change.add, ...change.remove]); + const there = new Set(resolved.filter((skill) => !skill.global).map((skill) => skill.name)); + // only what this machine last heard; a project it never asked about has nothing to compare + const here = readCache().projects[key]; + + if (here) { + const differs = [...there] + .filter((name) => !here.includes(name)) + .concat(here.filter((name) => !there.has(name))) + .filter((name) => !queued.has(name)); + changes += differs.length; + } + } + + return changes; +} diff --git a/packages/skilless/src/utils/cache.ts b/packages/skilless/src/utils/cache.ts index 1a04f2b..f124f8b 100644 --- a/packages/skilless/src/utils/cache.ts +++ b/packages/skilless/src/utils/cache.ts @@ -44,3 +44,13 @@ export function cacheProject(key: string, resolved: RemoteSkill[]): void { cache.projects[key] = resolved.filter((skill) => !skill.global).map((skill) => skill.name); fsu.writeJson(CACHE_FILE, cache); } + +/** + * Applies a change the server just accepted, so what this machine reads + * locally stays in step without listing everything again. + */ +export function updateCache(change: (cache: Cache) => void): void { + const cache = readCache(); + change(cache); + fsu.writeJson(CACHE_FILE, cache); +} diff --git a/packages/skilless/src/utils/library.ts b/packages/skilless/src/utils/library.ts index 8883d33..bf1b96c 100644 --- a/packages/skilless/src/utils/library.ts +++ b/packages/skilless/src/utils/library.ts @@ -1,3 +1,4 @@ +import { pushInBackground } from '@/utils/background'; import { cacheLibrary, cacheProject, readCache } from '@/utils/cache'; import { installResolved, linkGlobals, shouldCopy } from '@/utils/install'; import { flushPending, type Pending, readPending } from '@/utils/pending'; @@ -39,9 +40,11 @@ export async function flush(remote: Remote): Promise { * Your library as best this machine can tell: the skills on disk, plus what * the server has — fresh when it answers, from the cache when it does not — * with any change still waiting to reach the server applied on top. + * + * A null `remote` reads only what is on this machine. */ -export async function readLibrary(remote: Remote): Promise { - const fetched = await remote.try((api) => api.listSkills()); +export async function readLibrary(remote: Remote | null): Promise { + const fetched = await remote?.try((api) => api.listSkills()); if (fetched) { cacheLibrary(fetched); adoptSources(fetched); @@ -75,9 +78,9 @@ export async function readLibrary(remote: Remote): Promise { return { entries, live: fetched !== undefined }; } -/** Names of the skills bound to a project, globals not included. */ -export async function readBindings(remote: Remote, key: string): Promise { - const fetched = await remote.try((api) => api.getBindings(key)); +/** Names of the skills bound to a project, globals not included. A null `remote` reads the cache. */ +export async function readBindings(remote: Remote | null, key: string): Promise { + const fetched = await remote?.try((api) => api.getBindings(key)); if (fetched) cacheProject(key, fetched); const bound = fetched @@ -124,12 +127,12 @@ export type ProjectRefresh = { * empty cache must never read as "this project has nothing". */ export async function refreshProject( - remote: Remote, + remote: Remote | null, root: string, key: string, opts: { copy?: boolean } = {} ): Promise { - const fetched = await remote.try((api) => api.getBindings(key)); + const fetched = await remote?.try((api) => api.getBindings(key)); if (fetched) { cacheProject(key, fetched); @@ -204,7 +207,7 @@ function refreshOffline( * this machine yet waits for the next sync. */ export async function refreshGlobals( - remote: Remote + remote: Remote | null ): Promise { const library = await readLibrary(remote); const globals = library.entries.filter((entry) => entry.global); @@ -224,18 +227,17 @@ export async function refreshGlobals( } /** - * Writes skills into `~/.skilless/skills` and pushes each one, replacing - * whatever of the same name was there. Returns their names, in order. + * Writes skills into `~/.skilless/skills`, replacing whatever of the same name + * was there, and has them pushed in the background. Returns their names, in order. * * A skill's source is set from `sources`; one saved without an entry there is * no longer the repo's copy, so any source it had is forgotten. */ -export async function saveToLibrary( - remote: Remote, +export function saveToLibrary( skills: LocalSkill[], existing: Map, sources: Map = new Map() -): Promise { +): string[] { const state = readState(); for (const skill of skills) { @@ -243,16 +245,9 @@ export async function saveToLibrary( writeSkill(skill.name, skill.files, skill.editedAt); - const pushed = await remote.try((api) => api.putSkill(skill.name, skill.files, skill.editedAt)); const overwritten = existing.get(skill.name)?.remote; - if (pushed) { - state.skills[skill.name] = { - contentHash: skill.contentHash, - editedAt: skill.editedAt, - syncedAt: Date.now() - }; - } else if (overwritten) { + if (overwritten) { // record the copy being replaced as the last synced one, so the next // sync reads this as an edit made here and pushes it — rather than // a conflict it might settle the other way @@ -268,9 +263,35 @@ export async function saveToLibrary( } writeState(state); - - // the sources can only go once the skills they describe are there - await flush(remote); + pushInBackground(); return skills.map((skill) => skill.name); } + +/** + * Brings skills that are only on the server down into the store, all at once, + * so they can be linked. Returns the ones that could not be fetched. + */ +export async function fetchMissing(remote: Remote, names: string[]): Promise { + const fetched = await Promise.all(names.map((name) => remote.try((api) => api.getSkill(name)))); + + const state = readState(); + const failed: string[] = []; + + fetched.forEach((skill, i) => { + if (!skill) { + failed.push(names[i]!); + return; + } + + writeSkill(skill.name, skill.files, skill.editedAt); + state.skills[skill.name] = { + contentHash: skill.contentHash, + editedAt: skill.editedAt, + syncedAt: Date.now() + }; + }); + + writeState(state); + return failed; +} diff --git a/packages/skilless/src/utils/pending.ts b/packages/skilless/src/utils/pending.ts index 6c271d5..317a81a 100644 --- a/packages/skilless/src/utils/pending.ts +++ b/packages/skilless/src/utils/pending.ts @@ -1,4 +1,5 @@ import type { ApiClient } from '@/utils/api'; +import { type Cache, updateCache } from '@/utils/cache'; import * as fsu from '@/utils/fs'; import { PENDING_FILE } from '@/utils/paths'; import { localSkillNames } from '@/utils/skill'; @@ -110,14 +111,28 @@ export function queueDelete(names: string[]): void { writePending(pending); } +/** + * Reads the queue fresh, applies `change`, and writes it back. Another + * process — a background push, or a command run meanwhile — may have queued + * something since this one last read it, and that must not be written over. + */ +function settle(change: (pending: Pending) => void): void { + const pending = readPending(); + change(pending); + writePending(pending); +} + /** * Sends everything queued to the server. Progress is saved after each step, so * losing the connection halfway leaves the rest queued rather than lost. * * A change about a skill the server does not have yet — one created offline — - * stays queued until the `sync` that pushes it. Once the skill is gone from + * stays queued until the push that sends it. Once the skill is gone from * this machine too, there is nothing left for it to wait on, so it is dropped. * + * Each change the server accepts is written into the cache as well, since + * that, plus the queue, is what every command reads without `--sync`. + * * Pass `library` when the caller has just listed it, to save listing it again. * * Returns how many changes were sent. @@ -128,17 +143,23 @@ export async function flushPending(api: ApiClient, library?: RemoteSkill[]): Pro const known = new Set((library ?? (await api.listSkills())).map((skill) => skill.name)); const waiting = (name: string) => localSkillNames().includes(name); + const inLibrary = (cache: Cache, name: string) => + cache.library?.find((skill) => skill.name === name); let sent = 0; - for (const name of [...pending.deletes]) { + for (const name of pending.deletes) { if (known.has(name)) { await api.deleteSkill(name); known.delete(name); sent++; } - pending.deletes = pending.deletes.filter((n) => n !== name); - writePending(pending); + updateCache((cache) => { + cache.library = cache.library?.filter((skill) => skill.name !== name) ?? null; + }); + settle((fresh) => { + fresh.deletes = fresh.deletes.filter((n) => n !== name); + }); } for (const [name, value] of Object.entries(pending.globals)) { @@ -146,11 +167,17 @@ export async function flushPending(api: ApiClient, library?: RemoteSkill[]): Pro if (waiting(name)) continue; } else { await api.setGlobal(name, value); + updateCache((cache) => { + const skill = inLibrary(cache, name); + if (skill) skill.global = value; + }); sent++; } - delete pending.globals[name]; - writePending(pending); + // changed again meanwhile, so that change is still to send + settle((fresh) => { + if (fresh.globals[name] === value) delete fresh.globals[name]; + }); } for (const [name, source] of Object.entries(pending.sources)) { @@ -158,11 +185,17 @@ export async function flushPending(api: ApiClient, library?: RemoteSkill[]): Pro if (waiting(name)) continue; } else { await api.setSource(name, source); + updateCache((cache) => { + const skill = inLibrary(cache, name); + if (skill) skill.source = source; + }); sent++; } - delete pending.sources[name]; - writePending(pending); + settle((fresh) => { + if (JSON.stringify(fresh.sources[name]) === JSON.stringify(source)) + delete fresh.sources[name]; + }); } for (const [key, entry] of Object.entries(pending.projects)) { @@ -179,11 +212,20 @@ export async function flushPending(api: ApiClient, library?: RemoteSkill[]): Pro sent += entry.add.filter((name) => !unknown.has(name)).length + entry.remove.length; - entry.add = entry.add.filter((name) => unknown.has(name) && waiting(name)); - entry.remove = []; + updateCache((cache) => { + cache.projects[key] = result.bound; + }); + settle((fresh) => { + const change = fresh.projects[key]; + if (!change) return; + + // only what was sent; anything queued since stays + const keep = (name: string) => unknown.has(name) && waiting(name); + change.add = change.add.filter((name) => !entry.add.includes(name) || keep(name)); + change.remove = change.remove.filter((name) => !entry.remove.includes(name)); - if (entry.add.length === 0) delete pending.projects[key]; - writePending(pending); + if (change.add.length === 0 && change.remove.length === 0) delete fresh.projects[key]; + }); } return sent; diff --git a/packages/skilless/src/utils/project.ts b/packages/skilless/src/utils/project.ts index f7eee63..f6a5d8a 100644 --- a/packages/skilless/src/utils/project.ts +++ b/packages/skilless/src/utils/project.ts @@ -10,7 +10,7 @@ import { userAgentsSkills, userClaudeSkills } from '@/utils/paths'; -import { writeSkill } from '@/utils/skill'; +import { SKILL_FILE, writeSkill } from '@/utils/skill'; import type { SkillFile } from '@/utils/types'; export type Materializable = { name: string; files?: SkillFile[]; editedAt?: number }; @@ -123,6 +123,27 @@ export function ownedSkills(target: Target): string[] { .sort(); } +/** + * Skills that sit in a project's skill directories that skilless did not put + * there — committed, vendored, or made by hand. Agents read them all the same. + */ +export function folderSkills(root: string): string[] { + const scope = projectScope(root); + const ours = new Set(ownedSkills(scope)); + const names = new Set(); + + for (const dir of [scope.agents, scope.claude]) { + if (!fsu.exists(dir)) continue; + + for (const name of fs.readdirSync(dir)) { + if (name.startsWith('.') || ours.has(name)) continue; + if (fsu.exists(path.join(dir, name, SKILL_FILE))) names.add(name); + } + } + + return [...names].sort(); +} + /** * True when the claude directory is itself a link to the agents one, as with * `~/.claude/skills -> ~/.agents/skills`. There is only one place to write then, diff --git a/packages/skilless/src/utils/prompts.ts b/packages/skilless/src/utils/prompts.ts index efcefbe..0f40730 100644 --- a/packages/skilless/src/utils/prompts.ts +++ b/packages/skilless/src/utils/prompts.ts @@ -25,11 +25,60 @@ type ListPrompt = { index: number; choices: { index: number }[]; visible: { index: number }[]; + focused: { index: number }; + isDisabled: (choice?: { index: number }) => boolean; render: () => unknown; scrollUp: () => unknown; scrollDown: () => unknown; }; +/** Moves the focus one place, stopping at the ends instead of wrapping. */ +function step(prompt: ListPrompt, direction: -1 | 1): void { + if (direction === -1) { + if (prompt.index > 0) prompt.index--; + else prompt.scrollUp(); + } else if (prompt.index < prompt.visible.length - 1) prompt.index++; + else prompt.scrollDown(); +} + +/** + * Scrolls the view one row while the focus stays on the same choice, so + * disabled choices past the last one that can be picked can still be read. + * Does nothing once that end of the list is showing. + */ +function reveal(prompt: ListPrompt, direction: -1 | 1): unknown { + const { choices } = prompt; + + if (direction === 1) { + if (prompt.index === 0 || prompt.visible.at(-1)?.index === choices.length - 1) return; + prompt.choices = [...choices.slice(1), choices[0]!]; + prompt.index--; + } else { + if (prompt.index === prompt.visible.length - 1 || choices[0]?.index === 0) return; + prompt.choices = [choices.at(-1)!, ...choices.slice(0, -1)]; + prompt.index++; + } + + return prompt.render(); +} + +/** + * Moves to the next choice that can be picked, skipping disabled ones. With + * none left that way, the view still scrolls to show what is there. + */ +function move(prompt: ListPrompt, direction: -1 | 1): unknown { + const from = prompt.focused.index; + const ahead = prompt.choices.some( + (choice) => (choice.index - from) * direction > 0 && !prompt.isDisabled(choice) + ); + if (!ahead) return reveal(prompt, direction); + + do step(prompt, direction); + while (prompt.isDisabled()); + + return prompt.render(); +} + /** * Enquirer scrolls a long list by rotating it, so moving past either end wraps * around to the other. These stop at the ends instead. Each choice keeps its @@ -38,22 +87,10 @@ type ListPrompt = { const list = { limit: 10, up(this: ListPrompt) { - if (this.index > 0) { - this.index--; - return this.render(); - } - - if (this.choices[0]?.index === 0) return; - return this.scrollUp(); + return move(this, -1); }, down(this: ListPrompt) { - if (this.index < this.visible.length - 1) { - this.index++; - return this.render(); - } - - if (this.visible.at(-1)?.index === this.choices.length - 1) return; - return this.scrollDown(); + return move(this, 1); } }; @@ -113,7 +150,7 @@ export async function input(message: string, opts: InputOptions = {}): Promise { if (choices.every((choice) => choice.disabled)) return []; @@ -226,11 +263,10 @@ const brand = (text: string): string => const MAX_WIDTH = 80; /** - * Breaks `words` into lines that fit the terminal, each one indented to sit - * under the text of a `·` line rather than its marker. + * Breaks `words` into lines that fit the terminal, each one indented — by + * default to sit under the text of a `·` line rather than its marker. */ -function wrap(words: string[], separator: string): string { - const indent = ' '; +export function wrap(words: string[], separator: string, indent = ' '): string { const width = Math.min(process.stdout.columns || MAX_WIDTH, MAX_WIDTH) - indent.length; const lines: string[] = []; let line = ''; @@ -265,6 +301,10 @@ export const log = { error(message: string) { write(process.stderr, `${pc.red('✖')} ${message}\n`); }, + /** A line as is, for output that does its own coloring. */ + raw(message: string) { + write(process.stdout, `${message}\n`); + }, dim(message: string) { write(process.stdout, `${pc.gray(message)}\n`); }, diff --git a/packages/skilless/tests/background.test.ts b/packages/skilless/tests/background.test.ts new file mode 100644 index 0000000..51e56d4 --- /dev/null +++ b/packages/skilless/tests/background.test.ts @@ -0,0 +1,189 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ApiClient } from '@/utils/api'; +import type { RemoteSkill, SkillFile } from '@/utils/types'; + +process.env.SKILLESS_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'skilless-background-')); + +const { countRemoteChanges, pushChanges, readStatus, runBackground } = + await import('@/utils/background'); +const { readBindings, readLibrary } = await import('@/utils/library'); +const { flushPending, queueBind, readPending } = await import('@/utils/pending'); +const { hashFiles, writeSkill } = await import('@/utils/skill'); +const { readState, writeState } = await import('@/utils/state'); +const { SKILLESS_DIR } = await import('@/utils/paths'); + +const files = (contents: string): SkillFile[] => [{ path: 'SKILL.md', contents }]; + +/** A server holding whole skills, with the binding rules of the real one. */ +function fakeApi(initial: Record = {}, bound: Record = {}) { + const skills = new Map(); + const put = (name: string, contents: string) => { + const skill = { + name, + files: files(contents), + contentHash: hashFiles(files(contents)), + editedAt: 1, + updatedAt: 1, + global: false, + source: null + }; + skills.set(name, skill); + return skill; + }; + for (const [name, contents] of Object.entries(initial)) put(name, contents); + + const strip = ({ files: _, ...skill }: RemoteSkill & { files: SkillFile[] }) => skill; + + const api = { + listSkills: vi.fn(async () => [...skills.values()].map(strip)), + putSkill: vi.fn(async (name: string, f: SkillFile[]) => strip(put(name, f[0]!.contents))), + deleteSkill: vi.fn(async (name: string) => void skills.delete(name)), + setGlobal: vi.fn(async (name: string, value: boolean) => { + skills.get(name)!.global = value; + }), + setSource: vi.fn(async () => {}), + getBindings: vi.fn(async (key: string) => + [...skills.values()].filter((s) => s.global || (bound[key] ?? []).includes(s.name)) + ), + setBindings: vi.fn(async (key: string, next: string[]) => { + bound[key] = next.filter((name) => skills.has(name)); + return { bound: bound[key], unknown: next.filter((name) => !skills.has(name)) }; + }) + }; + + return { api: api as unknown as ApiClient & typeof api, skills, bound, put }; +} + +/** A skill on this machine and the server, recorded as synced. */ +function synced(name: string, contents: string) { + writeSkill(name, files(contents)); + const state = readState(); + state.skills[name] = { contentHash: hashFiles(files(contents)), editedAt: 1, syncedAt: 1 }; + writeState(state); +} + +beforeEach(() => { + fs.rmSync(SKILLESS_DIR, { recursive: true, force: true }); +}); + +describe('pushChanges', () => { + it('pushes a skill made here, then the binding that was waiting on it', async () => { + const { api, bound } = fakeApi(); + + writeSkill('new', files('# new')); + queueBind('p', ['new']); + + await pushChanges(api); + + expect(api.putSkill).toHaveBeenCalledWith('new', files('# new'), expect.any(Number)); + expect(bound.p).toEqual(['new']); + expect(readState().skills.new?.contentHash).toBe(hashFiles(files('# new'))); + expect(readPending().projects).toEqual({}); + + // with the queue empty, the cache is what still knows it is in the project + expect(await readBindings(null, 'p')).toEqual(['new']); + expect((await readLibrary(null)).entries.find((e) => e.name === 'new')?.remote).not.toBeNull(); + }); + + it('pushes an edit, but leaves a skill changed on both sides for sync', async () => { + const { api, put } = fakeApi({ edited: '# v1', both: '# v1' }); + synced('edited', '# v1'); + synced('both', '# v1'); + + writeSkill('edited', files('# v2')); + writeSkill('both', files('# mine')); + put('both', '# theirs'); + + const { conflicts } = await pushChanges(api); + + expect(api.putSkill).toHaveBeenCalledTimes(1); + expect(api.putSkill).toHaveBeenCalledWith('edited', files('# v2'), expect.any(Number)); + expect(conflicts).toEqual(['both']); + }); +}); + +describe('flushPending', () => { + it('keeps a change queued while it was sending', async () => { + const { api } = fakeApi({ a: '# a', b: '# b' }); + const setBindings = api.setBindings.getMockImplementation()!; + api.setBindings.mockImplementationOnce(async (key, next) => { + // another command, mid-flush + queueBind('p', ['b']); + return setBindings(key, next); + }); + + queueBind('p', ['a']); + await flushPending(api); + + expect(readPending().projects.p).toEqual({ add: ['b'], remove: [] }); + }); +}); + +describe('countRemoteChanges', () => { + it('counts what sync would bring down, but not what is queued to go up', async () => { + const { api, put, bound } = fakeApi({ a: '# a' }); + synced('a', '# a'); + await pushChanges(api); + await flushPending(api); + expect(await countRemoteChanges(api, 'p')).toBe(0); + + // made elsewhere: a new skill, and an edit + put('b', '# b'); + put('a', '# a2'); + expect(await countRemoteChanges(api, null)).toBe(2); + + // queued here and not sent yet, so not a change from elsewhere + queueBind('p', ['a']); + bound.p = []; + expect(await countRemoteChanges(api, 'p')).toBe(2); + }); + + it('counts a skill added to the project elsewhere', async () => { + const { api, bound } = fakeApi({ a: '# a' }); + synced('a', '# a'); + queueBind('p', []); + await pushChanges(api); + + // the cache only knows a project once something here has asked about it + const { cacheProject } = await import('@/utils/cache'); + cacheProject('p', []); + + bound.p = ['a']; + expect(await countRemoteChanges(api, 'p')).toBe(1); + }); +}); + +describe('runBackground', () => { + it('records a failed push for the next command to mention', async () => { + const { api } = fakeApi(); + api.listSkills.mockRejectedValueOnce(new Error('boom')); + + await runBackground(api, { push: true, check: false, key: null }); + + expect(readStatus().push?.error).toBe('boom'); + expect(fs.existsSync(path.join(SKILLESS_DIR, 'background.lock'))).toBe(false); + }); + + it('leaves a push asked for while another runs to that one', async () => { + const { api } = fakeApi(); + fs.mkdirSync(SKILLESS_DIR, { recursive: true }); + fs.writeFileSync(path.join(SKILLESS_DIR, 'background.lock'), '1'); + + await runBackground(api, { push: true, check: false, key: null }); + + expect(api.listSkills).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(SKILLESS_DIR, 'background.again'))).toBe(true); + }); + + it('records what the check found', async () => { + const { api, put } = fakeApi(); + put('elsewhere', '# e'); + + await runBackground(api, { push: false, check: true, key: null }); + + expect(readStatus().remote).toMatchObject({ key: null, changes: 1 }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60cc0ac..531a617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,10 +375,17 @@ importers: version: 5.9.3 packages/skilless: + dependencies: + update-notifier: + specifier: ^7.3.1 + version: 7.3.1 devDependencies: '@types/node': specifier: 'catalog:' version: 26.6.2 + '@types/update-notifier': + specifier: ^6.0.8 + version: 6.0.8 commander: specifier: 'catalog:' version: 14.0.3 @@ -1317,10 +1324,22 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + '@pnpm/deps.graph-sequencer@1100.0.1': resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} engines: {node: '>=22.13'} + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@3.0.3': + resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} + engines: {node: '>=12'} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1830,6 +1849,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/configstore@6.0.2': + resolution: {integrity: sha512-OS//b51j9uyR3zvwD04Kfs5kHpve2qalQ18JhY/ho3voGYUTPLEG90/ocfKPI48hyHH8T04f7KEEbK6Ue60oZQ==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -1863,6 +1885,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/update-notifier@6.0.8': + resolution: {integrity: sha512-IlDFnfSVfYQD+cKIg63DEXn3RFmd7W1iYtKQsJodcHK9R1yr8aKbKaPKfBxzPpcHCq2DU8zUq4PIPmy19Thjfg==} + '@typescript-eslint/eslint-plugin@8.70.1': resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1990,6 +2015,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2002,6 +2030,10 @@ packages: resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.4.0: resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} engines: {node: '>=14'} @@ -2022,6 +2054,9 @@ packages: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2121,6 +2156,14 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@7.1.1: + resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} + engines: {node: '>=14.16'} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + brace-expansion@5.0.12: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} @@ -2148,6 +2191,14 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2172,6 +2223,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2211,6 +2266,13 @@ packages: resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} engines: {node: '>=12'} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + configstore@7.1.0: + resolution: {integrity: sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==} + engines: {node: '>=18'} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -2320,6 +2382,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2356,6 +2422,10 @@ packages: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} + dot-prop@9.0.0: + resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} + engines: {node: '>=18'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2373,9 +2443,21 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.1.0: resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} engines: {node: '>=14'} @@ -2428,6 +2510,10 @@ packages: engines: {node: '>=18'} hasBin: true + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -2659,6 +2745,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + globals@16.5.0: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} @@ -2671,6 +2761,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2767,6 +2860,13 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2787,10 +2887,23 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ci@1.0.0: + resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} + engines: {node: '>=18'} + hasBin: true + + is-installed-globally@1.0.1: + resolution: {integrity: sha512-li88H4QGRoNMX27jEmY8nR515oJVspAgC/WFMlhHfFV8RJ4QyGpHhXnwAe9gH5RIgKmdYRUmrOhQobcgEjIdSw==} + engines: {node: '>=18'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -2799,10 +2912,18 @@ packages: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} + is-npm@6.1.0: + resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -2875,10 +2996,18 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} + engines: {node: '>=18'} + kysely@0.29.6: resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} engines: {node: '>=22.0.0'} + latest-version@9.0.0: + resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} + engines: {node: '>=18'} + launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} @@ -3230,6 +3359,9 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mode-watcher@1.1.0: resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==} peerDependencies: @@ -3341,6 +3473,10 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json@10.0.1: + resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} + engines: {node: '>=18'} + package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -3493,6 +3629,9 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proxy-addr@2.0.8: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} @@ -3501,6 +3640,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pupa@3.3.0: + resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==} + engines: {node: '>=12.20'} + qified@0.10.1: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} @@ -3520,6 +3663,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} @@ -3537,6 +3684,14 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + + registry-url@6.0.1: + resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} + engines: {node: '>=12'} + rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} @@ -3763,6 +3918,18 @@ packages: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string-width@8.2.2: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} @@ -3782,10 +3949,20 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + style-mod@4.1.4: resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} @@ -3952,6 +4129,14 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.10.0: resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} @@ -4018,6 +4203,10 @@ packages: synckit: optional: true + update-notifier@7.3.1: + resolution: {integrity: sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==} + engines: {node: '>=18'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4139,6 +4328,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4154,10 +4346,26 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4173,6 +4381,10 @@ packages: utf-8-validate: optional: true + xdg-basedir@5.1.0: + resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} + engines: {node: '>=12'} + yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} @@ -5184,8 +5396,20 @@ snapshots: '@pkgr/core@0.3.6': {} + '@pnpm/config.env-replace@1.1.0': {} + '@pnpm/deps.graph-sequencer@1100.0.1': {} + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@3.0.3': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + '@polka/url@1.0.0-next.29': {} '@quansync/fs@1.1.0': @@ -5546,6 +5770,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/configstore@6.0.2': {} + '@types/cookie@0.6.0': {} '@types/debug@4.1.13': @@ -5576,6 +5802,11 @@ snapshots: '@types/unist@3.0.3': {} + '@types/update-notifier@6.0.8': + dependencies: + '@types/configstore': 6.0.2 + boxen: 7.1.1 + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5743,12 +5974,18 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} ansi-regex@6.3.0: {} + ansi-styles@6.2.3: {} + ansis@4.4.0: {} aria-query@5.3.1: {} @@ -5762,6 +5999,11 @@ snapshots: atomically@1.7.0: {} + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -5835,6 +6077,28 @@ snapshots: bowser@2.14.1: {} + boxen@7.1.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -5863,6 +6127,10 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase@7.0.1: {} + + camelcase@8.0.0: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -5879,6 +6147,8 @@ snapshots: dependencies: readdirp: 4.1.2 + cli-boxes@3.0.0: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -5920,6 +6190,18 @@ snapshots: pkg-up: 3.1.0 semver: 7.8.5 + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + configstore@7.1.0: + dependencies: + atomically: 2.1.1 + dot-prop: 9.0.0 + graceful-fs: 4.2.11 + xdg-basedir: 5.1.0 + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -5994,6 +6276,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -6018,6 +6302,10 @@ snapshots: dependencies: is-obj: 2.0.0 + dot-prop@9.0.0: + dependencies: + type-fest: 4.41.0 + dotenv@17.4.2: {} dts-resolver@2.1.3: {} @@ -6028,8 +6316,16 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + empathic@2.1.0: {} encodeurl@2.0.0: {} @@ -6118,6 +6414,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escape-goat@4.0.0: {} + escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} @@ -6412,12 +6710,18 @@ snapshots: dependencies: is-glob: 4.0.3 + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + globals@16.5.0: {} globals@17.12.0: {} gopd@1.2.0: {} + graceful-fs@4.2.10: {} + graceful-fs@4.2.11: {} has-symbols@1.1.0: {} @@ -6528,6 +6832,10 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + + ini@4.1.1: {} + inline-style-parser@0.2.7: {} ip-address@10.7.2: {} @@ -6538,16 +6846,29 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-in-ci@1.0.0: {} + + is-installed-globally@1.0.1: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + is-interactive@2.0.0: {} is-network-error@1.3.2: {} + is-npm@6.1.0: {} + is-obj@2.0.0: {} + is-path-inside@4.0.0: {} + is-plain-obj@4.1.0: {} is-promise@4.0.0: {} @@ -6596,8 +6917,14 @@ snapshots: known-css-properties@0.37.0: {} + ky@1.14.3: {} + kysely@0.29.6: {} + latest-version@9.0.0: + dependencies: + package-json: 10.0.1 + launch-editor@2.14.1: dependencies: picocolors: 1.1.1 @@ -7081,6 +7408,8 @@ snapshots: dependencies: brace-expansion: 5.0.12 + minimist@1.2.8: {} + mode-watcher@1.1.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)): dependencies: runed: 0.25.0(svelte@5.57.1(@typescript-eslint/types@8.70.1)) @@ -7189,6 +7518,13 @@ snapshots: p-try@2.2.0: {} + package-json@10.0.1: + dependencies: + ky: 1.14.3 + registry-auth-token: 5.1.1 + registry-url: 6.0.1 + semver: 7.8.5 + package-manager-detector@1.8.0: {} parse-numeric-range@1.3.0: {} @@ -7262,6 +7598,8 @@ snapshots: property-information@7.2.0: {} + proto-list@1.2.4: {} + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 @@ -7269,6 +7607,10 @@ snapshots: punycode@2.3.1: {} + pupa@3.3.0: + dependencies: + escape-goat: 4.0.0 + qified@0.10.1: dependencies: hookified: 2.2.0 @@ -7289,6 +7631,13 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react@19.3.0: {} readdirp@4.1.2: {} @@ -7303,6 +7652,14 @@ snapshots: dependencies: regex-utilities: 2.3.0 + registry-auth-token@5.1.1: + dependencies: + '@pnpm/npm-conf': 3.0.3 + + registry-url@6.0.1: + dependencies: + rc: 1.2.8 + rehype-parse@9.0.1: dependencies: '@types/hast': 3.0.5 @@ -7626,6 +7983,24 @@ snapshots: stdin-discarder@0.3.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + string-width@8.2.2: dependencies: get-east-asian-width: 1.7.0 @@ -7646,10 +8021,18 @@ snapshots: strip-final-newline@2.0.0: {} + strip-json-comments@2.0.1: {} + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + style-mod@4.1.4: {} style-to-object@1.0.14: @@ -7817,6 +8200,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@2.19.0: {} + + type-fest@4.41.0: {} + type-fest@5.10.0: dependencies: tagged-tag: 1.0.0 @@ -7892,6 +8279,19 @@ snapshots: optionalDependencies: synckit: 0.11.13 + update-notifier@7.3.1: + dependencies: + boxen: 8.0.1 + chalk: 5.6.2 + configstore: 7.1.0 + is-in-ci: 1.0.0 + is-installed-globally: 1.0.1 + is-npm: 6.1.0 + latest-version: 9.0.0 + pupa: 3.3.0 + semver: 7.8.5 + xdg-basedir: 5.1.0 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -7967,6 +8367,8 @@ snapshots: web-namespaces@2.0.1: {} + when-exit@2.1.5: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7980,12 +8382,34 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + word-wrap@1.2.5: {} + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.21.0: {} + xdg-basedir@5.1.0: {} + yaml@1.10.3: {} yaml@2.9.1: {}