diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index e762f7d9..20cb5cf8 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,8 @@ All notable CLI behavior changes are documented in this file. ### Added +- Add the user-level `astudio` agent profile, displayed as AStudio, with automatic detection of + `~/.acode/skills` on Linux, macOS, and Windows. - Add repeatable `sync pull --skill ` selection for non-interactive and JSON workflows, with interactive multi-select in a TTY. - Add `skillhub upgrade ` for bounded, explicit upgrades of already-installed Skills, diff --git a/cli/README.md b/cli/README.md index 8841e61c..42192b13 100644 --- a/cli/README.md +++ b/cli/README.md @@ -169,6 +169,9 @@ skillhub install pdf-parser --version 1.2.0 # Install to specific Agent skillhub install pdf-parser --agent codex +# Install to AStudio's fixed user-level directory +skillhub install pdf-parser --agent astudio + # Install to multiple Agents skillhub install pdf-parser --agent codex --agent claude-code @@ -197,10 +200,11 @@ The CLI determines the installation location using the following logic: ### Install Paths -Each Agent has both project-level and user-level skills directories. Use `--scope user|project` to control which one is used. +Most Agents have both project-level and user-level skills directories. Use `--scope user|project` to control which one is used. AStudio uses its fixed user-level directory only. | Agent | Project-level Path | User-level Path | |-------|-------------------|-----------------| +| `astudio` (AStudio) | Not supported | `~/.acode/skills/` | | `claude-code` | `/.claude/skills/` | `~/.claude/skills/` | | `codex` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | @@ -217,7 +221,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. +For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. AStudio appears in that selector when `~/.acode/skills/` exists. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. ### File Structure After Installation diff --git a/cli/src/agents/detector.ts b/cli/src/agents/detector.ts index 55a17d75..50abff75 100644 --- a/cli/src/agents/detector.ts +++ b/cli/src/agents/detector.ts @@ -1,4 +1,5 @@ import type { AgentProfile } from './types' +import { aStudioProfile } from './profiles/astudio' import { claudeCodeProfile } from './profiles/claude-code' import { codexProfile } from './profiles/codex' import { cursorProfile } from './profiles/cursor' @@ -15,14 +16,14 @@ import { opencodeProfile } from './profiles/opencode' import { kiloProfile } from './profiles/kilo' export { - claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile, + aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile, geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile, kiroCliProfile, rooProfile, traeProfile, traeCnProfile, opencodeProfile, kiloProfile } export const allProfiles: AgentProfile[] = [ - claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile, + aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile, geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile, kiroCliProfile, rooProfile, traeProfile, traeCnProfile, opencodeProfile, kiloProfile diff --git a/cli/src/agents/profiles/astudio.ts b/cli/src/agents/profiles/astudio.ts new file mode 100644 index 00000000..ed3232e0 --- /dev/null +++ b/cli/src/agents/profiles/astudio.ts @@ -0,0 +1,23 @@ +import { directoryExists } from '../../platform/paths' +import type { AgentProfile } from '../types' + +function userSkillsRoot(home: string): string { + return home.replace(/\\/g, '/').replace(/\/+$/, '') + '/.acode/skills' +} + +export const aStudioProfile: AgentProfile = { + id: 'astudio', + displayName: 'AStudio', + projectRoots: () => [], + userRoots: home => [userSkillsRoot(home)], + async detectInstalled(_cwd, home) { + const rootDir = userSkillsRoot(home) + if (!await directoryExists(rootDir)) return [] + return [{ + agent: this.id, + rootDir, + scope: 'user', + source: 'detected' + }] + } +} diff --git a/cli/src/agents/profiles/make-profile.ts b/cli/src/agents/profiles/make-profile.ts index 88d2e7ad..89c69844 100644 --- a/cli/src/agents/profiles/make-profile.ts +++ b/cli/src/agents/profiles/make-profile.ts @@ -1,10 +1,6 @@ -import { pathExists } from '../../platform/paths' +import { directoryExists } from '../../platform/paths' import type { AgentProfile, AgentCandidate } from '../types' -async function dirExists(path: string): Promise { - return pathExists(path) -} - export function makeProfile(id: string, displayName: string, projectSkills: string, userSkills: string): AgentProfile { return { id, @@ -15,7 +11,7 @@ export function makeProfile(id: string, displayName: string, projectSkills: stri const roots = [...this.projectRoots(cwd), ...this.userRoots(home)] const results: AgentCandidate[] = [] for (const root of roots) { - if (await dirExists(root)) { + if (await directoryExists(root)) { results.push({ agent: this.id, rootDir: root, diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 190e1a8d..7d095bfe 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' -import { canonicalizeExistingPath, pathExists } from '../platform/paths' +import { canonicalizeExistingPath, directoryExists } from '../platform/paths' import type { AgentCandidate } from './types' import { allProfiles, profileMap } from './detector' @@ -105,7 +105,7 @@ async function generateScopedCandidates( for (const profile of allProfiles) { const roots = scope === 'user' ? profile.userRoots(home) : profile.projectRoots(cwd) for (const root of roots) { - if (await pathExists(root)) { + if (await directoryExists(root)) { results.push({ agent: profile.id, rootDir: root, scope, source: 'detected' }) } } @@ -145,6 +145,11 @@ async function resolveExplicitAgents( const userRoots = home ? profile.userRoots(home) : [] roots = userRoots.length > 0 ? userRoots : profile.projectRoots(cwd) } + if (roots.length === 0) { + throw new CliError(`agent ${agentId} does not support ${scope} scope`, EXIT.usage, { + next: 'choose a supported scope or omit --scope' + }) + } const userRootSet = new Set(home ? profile.userRoots(home) : []) for (const root of roots) { const candidateScope: AgentCandidate['scope'] = scope !== undefined @@ -183,7 +188,7 @@ async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise name: 'selected', message: 'Select install targets', choices: candidates.map(c => ({ - title: `${c.agent} (${c.rootDir})`, + title: `${profileMap.get(c.agent)?.displayName ?? c.agent} (${c.rootDir})`, value: c })), onRender: function (this: { cursor?: number }) { diff --git a/cli/src/platform/paths.ts b/cli/src/platform/paths.ts index 7811a766..6e83d8a2 100644 --- a/cli/src/platform/paths.ts +++ b/cli/src/platform/paths.ts @@ -24,6 +24,15 @@ export async function pathExists(path: string): Promise { } } +export async function directoryExists(path: string): Promise { + const { stat } = await import('node:fs/promises') + try { + return (await stat(path)).isDirectory() + } catch { + return false + } +} + export async function canonicalizeExistingPath(path: string): Promise { const { realpath } = await import('node:fs/promises') try { diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index 9687e60a..f6b6f21c 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -667,6 +667,49 @@ describe('install command — server errors', () => { // --------------------------------------------------------------------------- describe('install command — multi-agent & auto-detect', () => { + test('--agent astudio installs and persists the stable lowercase agent id', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ + token: 'sk_ok', + user: { handle: 'u', displayName: 'U' }, + skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }] + }) + await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home }) + + const result = await runCli( + [ + 'install', 'pdf-parser', + '--agent', 'astudio', + '--registry', registry.url, + '--token', 'sk_ok', + '--json' + ], + { HOME: env.home, USERPROFILE: env.home } + ) + + expect(result.exitCode).toBe(0) + const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string; dir: string }> } + expect(parsed.installed).toEqual([{ + agent: 'astudio', + dir: join(env.home, '.acode', 'skills', 'pdf-parser') + }]) + const metadataPath = join( + env.home, + '.acode', + 'skills', + 'pdf-parser', + '.skillhub', + 'metadata.json' + ) + expect(await Bun.file(metadataPath).exists()).toBe(true) + expect(JSON.parse(await readFile(metadataPath, 'utf-8')).agent).toBe('astudio') + + const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) as { + items: Array<{ targets: Array<{ agent: string }> }> + } + expect(inventory.items[0]?.targets[0]?.agent).toBe('astudio') + }) + test('multi --agent installs the same skill into every specified user-level dir', async () => { const env = await createTempHome() registry = await startFakeRegistry({ diff --git a/cli/test/unit/agents/profiles.test.ts b/cli/test/unit/agents/profiles.test.ts index a93d0e8e..ec567fe7 100644 --- a/cli/test/unit/agents/profiles.test.ts +++ b/cli/test/unit/agents/profiles.test.ts @@ -1,9 +1,12 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, test } from 'bun:test' import { allProfiles, profileMap } from '../../../src/agents/detector' describe('agent profiles', () => { - test('has 14 tier 1 profiles', () => { - expect(allProfiles).toHaveLength(14) + test('has 15 tier 1 profiles', () => { + expect(allProfiles).toHaveLength(15) }) test('all profiles have unique ids', () => { @@ -12,7 +15,8 @@ describe('agent profiles', () => { }) test('profileMap contains all profiles', () => { - expect(profileMap.size).toBe(14) + expect(profileMap.size).toBe(15) + expect(profileMap.has('astudio')).toBe(true) expect(profileMap.has('claude-code')).toBe(true) expect(profileMap.has('codex')).toBe(true) expect(profileMap.has('cursor')).toBe(true) @@ -35,4 +39,41 @@ describe('agent profiles', () => { const profile = profileMap.get('cursor')! expect(profile.projectRoots('/repo')).toEqual(['/repo/.cursor/skills']) }) + + test('AStudio exposes only its fixed user-level directory on Linux, macOS, and Windows', () => { + const profile = profileMap.get('astudio')! + + expect(profile.displayName).toBe('AStudio') + expect(profile.projectRoots('/repo')).toEqual([]) + expect(profile.userRoots('/home/alice')).toEqual(['/home/alice/.acode/skills']) + expect(profile.userRoots('/Users/alice')).toEqual(['/Users/alice/.acode/skills']) + expect(profile.userRoots('C:\\Users\\alice')).toEqual(['C:/Users/alice/.acode/skills']) + }) + + test('AStudio is detected only when the user .acode skills directory exists', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-astudio-home-')) + const profile = profileMap.get('astudio')! + const nativeRootDir = join(home, '.acode', 'skills') + const profileRootDir = nativeRootDir.replace(/\\/g, '/') + + try { + expect(await profile.detectInstalled('/repo', home)).toEqual([]) + + await mkdir(join(home, '.acode'), { recursive: true }) + await writeFile(nativeRootDir, 'not a directory') + expect(await profile.detectInstalled('/repo', home)).toEqual([]) + + await rm(nativeRootDir) + await mkdir(nativeRootDir, { recursive: true }) + + expect(await profile.detectInstalled('/repo', home)).toEqual([{ + agent: 'astudio', + rootDir: profileRootDir, + scope: 'user', + source: 'detected' + }]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) }) diff --git a/cli/test/unit/agents/resolver-interactive.test.ts b/cli/test/unit/agents/resolver-interactive.test.ts index f669f80d..d2fdeb37 100644 --- a/cli/test/unit/agents/resolver-interactive.test.ts +++ b/cli/test/unit/agents/resolver-interactive.test.ts @@ -1,7 +1,11 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, mock, test } from 'bun:test' import type { AgentCandidate } from '../../../src/agents/types' interface PromptChoice { + title: string value: AgentCandidate } @@ -13,9 +17,11 @@ interface PromptOptions { const defaultSelectedTargets = (options: PromptOptions): AgentCandidate[] => options.format?.([]) ?? [] let selectPromptTargets = defaultSelectedTargets +let renderedChoices: PromptChoice[] = [] mock.module('prompts', () => ({ default: (options: PromptOptions) => { + renderedChoices = options.choices ?? [] options.onRender?.call({ cursor: 1 }) return { selected: selectPromptTargets(options) } } @@ -23,11 +29,62 @@ mock.module('prompts', () => ({ afterEach(() => { selectPromptTargets = defaultSelectedTargets + renderedChoices = [] }) const { resolveInstallTargets } = await import('../../../src/agents/resolver') describe('resolveInstallTargets interactive prompt', () => { + test('renders AStudio by display name when its directory was detected', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-astudio-resolver-')) + const nativeRootDir = join(home, '.acode', 'skills') + const profileRootDir = nativeRootDir.replace(/\\/g, '/') + + try { + await mkdir(nativeRootDir, { recursive: true }) + await resolveInstallTargets({ + cwd: '/repo', + home, + agents: [], + scope: 'user', + json: false, + interactive: true + }) + + expect(renderedChoices[0]?.title).toBe(`AStudio (${profileRootDir})`) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + test('does not render AStudio when .acode skills is a regular file', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-astudio-file-')) + const rootDir = join(home, '.acode', 'skills') + + try { + await mkdir(join(home, '.acode'), { recursive: true }) + await writeFile(rootDir, 'not a directory') + const targets = await resolveInstallTargets({ + cwd: '/repo', + home, + agents: [], + scope: 'user', + json: false, + interactive: true + }) + + expect(renderedChoices.some(choice => choice.value.agent === 'astudio')).toBe(false) + expect(targets).toEqual([{ + agent: 'generic', + rootDir: `${home}/.agents/skills`, + scope: 'user', + source: 'fallback' + }]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + test('uses the highlighted target when Enter submits an empty multiselect', async () => { const detected: AgentCandidate[] = [ { agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }, diff --git a/cli/test/unit/agents/resolver.test.ts b/cli/test/unit/agents/resolver.test.ts index 96eda770..2f1e8748 100644 --- a/cli/test/unit/agents/resolver.test.ts +++ b/cli/test/unit/agents/resolver.test.ts @@ -128,6 +128,17 @@ describe('resolveInstallTargets', () => { expect(targets).toEqual([{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'explicit' }]) }) + test('rejects project scope for the user-only AStudio profile', async () => { + await expect(resolveInstallTargets({ + cwd: '/repo', + home: '/home/u', + agents: ['astudio'], + scope: 'project', + json: false, + interactive: false + })).rejects.toThrow('agent astudio does not support project scope') + }) + test('scope=user + agent codex returns user root with user scope', async () => { const targets = await resolveInstallTargets({ cwd: '/repo', diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index fe0a5bbc..84a211dd 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -154,6 +154,9 @@ skillhub install pdf-parser --version 1.2.0 # Install to specific Agent skillhub install pdf-parser --agent codex +# Install to AStudio's fixed user-level directory +skillhub install pdf-parser --agent astudio + # Install to multiple Agents skillhub install pdf-parser --agent codex --agent claude-code @@ -182,10 +185,11 @@ The CLI determines the installation location using the following logic: ### Install Paths -Each Agent has both project-level and user-level skills directories. Use `--scope user|project` to control which one is used. +Most Agents have both project-level and user-level skills directories. Use `--scope user|project` to control which one is used. AStudio uses its fixed user-level directory only. | Agent | Project-level Path | User-level Path | |-------|-------------------|-----------------| +| `astudio` (AStudio) | Not supported | `~/.acode/skills/` | | `claude-code` | `/.claude/skills/` | `~/.claude/skills/` | | `codex` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | @@ -202,7 +206,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. +For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. AStudio appears in that selector when `~/.acode/skills/` exists. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above. ### File Structure After Installation diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index d15d706b..9c7bf3e9 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -150,6 +150,9 @@ skillhub install pdf-parser --version 1.2.0 # 安装到指定 Agent skillhub install pdf-parser --agent codex +# 安装到 AStudio 的固定用户级目录 +skillhub install pdf-parser --agent astudio + # 安装到多个 Agent skillhub install pdf-parser --agent codex --agent claude-code @@ -178,10 +181,11 @@ CLI 按以下逻辑确定安装位置: ### 安装路径 -每个 Agent 有项目级和用户级两个 skills 目录。`--scope user|project` 决定使用哪一个。 +大多数 Agent 都有项目级和用户级两个 skills 目录。`--scope user|project` 决定使用哪一个。AStudio 仅使用固定的用户级目录。 | Agent | 项目级路径 | 用户级路径 | |-------|-----------|-----------| +| `astudio`(AStudio) | 不支持 | `~/.acode/skills/` | | `claude-code` | `/.claude/skills/` | `~/.claude/skills/` | | `codex` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | @@ -198,7 +202,7 @@ CLI 按以下逻辑确定安装位置: | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | | _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。 +对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `~/.acode/skills/` 存在时,选择器会显示 AStudio。当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。 ### 安装后的文件结构