feat(cli): add AStudio agent profile (#840)

* feat(cli): add AstronStudio agent profile

Signed-off-by: dongmucat <1127093059@qq.com>

* test(cli): make AstronStudio path assertions portable

Signed-off-by: dongmucat <1127093059@qq.com>

* docs(cli): document AstronStudio install target

Signed-off-by: dongmucat <1127093059@qq.com>

* fix(cli): rename AstronStudio profile to AStudio

Signed-off-by: dongmucat <1127093059@qq.com>

* fix(cli): use stable lowercase AStudio agent id

Signed-off-by: dongmucat <1127093059@qq.com>

---------

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-09-09 17:26:34 +08:00 committed by GitHub
parent 1e5fe097fe
commit ccacb530e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 220 additions and 20 deletions

View file

@ -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 <slug>` selection for non-interactive and JSON workflows, with
interactive multi-select in a TTY.
- Add `skillhub upgrade <coordinate...>` for bounded, explicit upgrades of already-installed Skills,

View file

@ -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` | `<project>/.claude/skills/` | `~/.claude/skills/` |
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
@ -217,7 +221,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.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

View file

@ -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

View file

@ -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'
}]
}
}

View file

@ -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<boolean> {
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,

View file

@ -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 }) {

View file

@ -24,6 +24,15 @@ export async function pathExists(path: string): Promise<boolean> {
}
}
export async function directoryExists(path: string): Promise<boolean> {
const { stat } = await import('node:fs/promises')
try {
return (await stat(path)).isDirectory()
} catch {
return false
}
}
export async function canonicalizeExistingPath(path: string): Promise<string> {
const { realpath } = await import('node:fs/promises')
try {

View file

@ -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({

View file

@ -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 })
}
})
})

View file

@ -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' },

View file

@ -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',

View file

@ -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` | `<project>/.claude/skills/` | `~/.claude/skills/` |
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
@ -202,7 +206,7 @@ Each Agent has both project-level and user-level skills directories. Use `--scop
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.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

View file

@ -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` | `<project>/.claude/skills/` | `~/.claude/skills/` |
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
@ -198,7 +202,7 @@ CLI 按以下逻辑确定安装位置:
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.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_` 行。
### 安装后的文件结构