diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 5fc06b5d..e762f7d9 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -14,6 +14,9 @@ All notable CLI behavior changes are documented in this file. ### Fixed +- Preserve unknown fields in shared `~/.skillhub/config.json` and `credentials.json` files, and + treat a compatible credentials document without first-party `tokens` as logged out instead of + failing. Login and logout now modify only the first-party registry state. - Return structured JSON from `help --json` and topic help, report unknown help topics as usage errors, and support `--version` / `-v` alongside the existing `version` command. - Report successful publish and sync push requests as submissions, preserving the registry's raw diff --git a/cli/README.md b/cli/README.md index c5f80a33..8841e61c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -86,6 +86,10 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com `login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`. +Both files are updated non-destructively: SkillHub CLI changes only its own `tokens` and `registry` +fields and preserves unknown fields written by other compatible tools. This allows tools that share +the `~/.skillhub` directory to keep independently named state in the same JSON documents. + ### Check Current Identity ```bash diff --git a/cli/src/services/skill-target-lock.ts b/cli/src/services/skill-target-lock.ts index cd32cfd1..b2b3ad85 100644 --- a/cli/src/services/skill-target-lock.ts +++ b/cli/src/services/skill-target-lock.ts @@ -1,5 +1,5 @@ -import { createHash } from 'node:crypto' -import { chmod, lstat, mkdir } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' +import { chmod, lstat, mkdir, readdir, rename, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { lock } from 'proper-lockfile' @@ -7,23 +7,203 @@ import { canonicalizeExistingPath } from '../platform/paths' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' +const ACQUISITION_GATE_MAX_POLL_MS = 100 + /** Serializes every local lifecycle mutation for one Skill target directory. */ export async function acquireSkillTargetLock(rootDir: string, slug: string): Promise<() => Promise> { const lockPath = await skillTargetLockPath(rootDir, slug) + // proper-lockfile's stale deletion is not serialized. Gate only the acquisition attempt so two + // recoverers cannot remove and replace the same target lock concurrently. + const acquisitionGatePath = `${lockPath}.acquire` + let releaseAcquisitionGate: () => Promise try { - return await lock(lockPath, { - lockfilePath: lockPath, - realpath: false, - stale: 10_000, - update: 3_000, - retries: 0 - }) + releaseAcquisitionGate = await acquireAcquisitionGate(acquisitionGatePath) } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ELOCKED') { - throw targetBusyError(rootDir, slug) - } + if (hasErrorCode(error, 'EEXIST')) throw targetBusyError(rootDir, slug) throw error } + + let releaseTarget: () => Promise + try { + releaseTarget = await acquireTargetLock(lockPath) + } catch (operationError) { + try { + await releaseAcquisitionGate() + } catch (cleanupError) { + throw new AggregateError([operationError, cleanupError], 'target lock acquisition and gate cleanup both failed') + } + if (hasErrorCode(operationError, 'ELOCKED')) throw targetBusyError(rootDir, slug) + throw operationError + } + + try { + await releaseAcquisitionGate() + } catch (gateCleanupError) { + try { + await releaseTarget() + } catch (targetCleanupError) { + throw new AggregateError([gateCleanupError, targetCleanupError], 'target and acquisition gate cleanup both failed') + } + throw gateCleanupError + } + + return releaseTarget +} + +function acquireTargetLock(lockPath: string): Promise<() => Promise> { + return lock(lockPath, { + lockfilePath: lockPath, + realpath: false, + stale: 10_000, + update: 3_000, + retries: 0 + }) +} + +async function acquireAcquisitionGate(gatePath: string): Promise<() => Promise> { + await ensureAcquisitionGateDirectory(gatePath) + // A per-target Lamport bakery queue avoids deleting a shared stale gate. Every removable path + // contains a nonce and is owned by one PID, so crash recovery cannot unlink a replacement owner. + const contenderId = `${process.pid}-${randomUUID()}` + const choosingPath = join(gatePath, `choosing.${contenderId}`) + let ticketPath: string | null = null + let ticket: number | null = null + + await writeFile(choosingPath, '', { flag: 'wx', mode: 0o600 }) + try { + const { tickets } = await readGateState(gatePath, contenderId) + ticket = Math.max(0, ...tickets.map(contender => contender.ticket)) + 1 + ticketPath = join(gatePath, `ticket.${ticket}.${contenderId}`) + await rename(choosingPath, ticketPath) + + await waitForAcquisitionTurn(gatePath, { id: contenderId, ticket }) + } catch (operationError) { + const cleanupErrors = await removeContenderFiles(choosingPath, ...(ticketPath === null ? [] : [ticketPath])) + if (cleanupErrors.length > 0) { + throw new AggregateError([operationError, ...cleanupErrors], 'acquisition gate attempt and cleanup both failed') + } + throw operationError + } + + return async () => { + try { + await unlink(ticketPath!) + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) throw compromisedGateError(ticketPath!) + throw error + } + } +} + +async function ensureAcquisitionGateDirectory(gatePath: string): Promise { + try { + await mkdir(gatePath, { mode: 0o700 }) + } catch (error) { + if (!hasErrorCode(error, 'EEXIST')) throw error + } + const details = await lstat(gatePath) + if (!details.isDirectory() || details.isSymbolicLink()) { + throw new Error(`unsafe SkillHub CLI acquisition gate directory: ${gatePath}`) + } +} + +interface AcquisitionContender { + id: string + ticket: number +} + +interface AcquisitionGateState { + tickets: AcquisitionContender[] + hasLiveChoosing: boolean +} + +async function readGateState(gatePath: string, contenderId: string): Promise { + const entries = await readdir(gatePath) + const tickets: AcquisitionContender[] = [] + let hasLiveChoosing = false + for (const entry of entries) { + const path = join(gatePath, entry) + const ticketMatch = /^ticket\.(\d+)\.(\d+)-([^.]+)$/.exec(entry) + if (ticketMatch) { + const ticket = Number(ticketMatch[1]) + const pid = Number(ticketMatch[2]) + if (!isProcessAlive(pid)) { + await unlinkIfPresent(path) + continue + } + if (!Number.isSafeInteger(ticket) || ticket < 1) throw compromisedGateError(path) + tickets.push({ id: `${ticketMatch[2]}-${ticketMatch[3]}`, ticket }) + continue + } + const choosingMatch = /^choosing\.(\d+)-([^.]+)$/.exec(entry) + if (choosingMatch && `${choosingMatch[1]}-${choosingMatch[2]}` !== contenderId) { + if (!isProcessAlive(Number(choosingMatch[1]))) { + await unlinkIfPresent(path) + } else { + hasLiveChoosing = true + } + } + } + return { tickets, hasLiveChoosing } +} + +async function waitForAcquisitionTurn( + gatePath: string, + contender: AcquisitionContender +): Promise { + let delayMs = 5 + for (;;) { + const state = await readGateState(gatePath, contender.id) + const hasEarlierTicket = state.tickets.some(candidate => compareContenders(candidate, contender) < 0) + if (!state.hasLiveChoosing && !hasEarlierTicket) return + await new Promise(resolve => setTimeout(resolve, delayMs)) + delayMs = Math.min(delayMs * 2, ACQUISITION_GATE_MAX_POLL_MS) + } +} + +function compareContenders(left: AcquisitionContender, right: AcquisitionContender): number { + if (left.ticket !== right.ticket) return left.ticket < right.ticket ? -1 : 1 + if (left.id === right.id) return 0 + return left.id < right.id ? -1 : 1 +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !hasErrorCode(error, 'ESRCH') + } +} + +async function removeContenderFiles(...paths: string[]): Promise { + const errors: Error[] = [] + for (const path of paths) { + try { + await unlink(path) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) errors.push(error instanceof Error ? error : new Error(String(error))) + } + } + return errors +} + +async function unlinkIfPresent(path: string): Promise { + try { + await unlink(path) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) throw error + } +} + +function compromisedGateError(gatePath: string): Error { + return Object.assign(new Error(`SkillHub CLI acquisition gate was replaced: ${gatePath}`), { + code: 'ECOMPROMISED' + }) +} + +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code } export async function skillTargetLockPath(rootDir: string, slug: string): Promise { diff --git a/cli/src/stores/config-store.ts b/cli/src/stores/config-store.ts index 2470848b..b234e07f 100644 --- a/cli/src/stores/config-store.ts +++ b/cli/src/stores/config-store.ts @@ -2,7 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { joinPath, userStateDir, ensureDir, pathExists } from '../platform/paths' -export interface CliConfig { +export interface CliConfig extends Record { registry?: string defaultAgent?: string lastUpdateCheckAt?: string diff --git a/cli/src/stores/credentials-store.ts b/cli/src/stores/credentials-store.ts index b6b703e8..b2b92e5a 100644 --- a/cli/src/stores/credentials-store.ts +++ b/cli/src/stores/credentials-store.ts @@ -2,8 +2,8 @@ import { readFile, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { joinPath, userStateDir, ensureDir, applyCredentialPermissions, pathExists } from '../platform/paths' -interface CredentialsFile { - tokens: Record +interface CredentialsFile extends Record { + tokens?: Record } export class CredentialsStore { @@ -14,18 +14,22 @@ export class CredentialsStore { } async read(): Promise { - if (!(await pathExists(this.path))) return { tokens: {} } + if (!(await pathExists(this.path))) return {} return JSON.parse(await readFile(this.path, 'utf-8')) as CredentialsFile } async getToken(registry: string): Promise { - return (await this.read()).tokens[registry] + const token = (await this.read()).tokens?.[registry] + return typeof token === 'string' ? token : undefined } async setToken(registry: string, token: string): Promise { const current = await this.read() await ensureDir(dirname(this.path)) - await writeFile(this.path, JSON.stringify({ tokens: { ...current.tokens, [registry]: token } }, null, 2)) + await writeFile(this.path, JSON.stringify({ + ...current, + tokens: { ...current.tokens, [registry]: token } + }, null, 2)) await applyCredentialPermissions(this.path) } @@ -34,7 +38,7 @@ export class CredentialsStore { const tokens = { ...current.tokens } delete tokens[registry] await ensureDir(dirname(this.path)) - await writeFile(this.path, JSON.stringify({ tokens }, null, 2)) + await writeFile(this.path, JSON.stringify({ ...current, tokens }, null, 2)) await applyCredentialPermissions(this.path) } } diff --git a/cli/test/integration/auth-commands.test.ts b/cli/test/integration/auth-commands.test.ts index 172cd6d5..d862224e 100644 --- a/cli/test/integration/auth-commands.test.ts +++ b/cli/test/integration/auth-commands.test.ts @@ -30,6 +30,42 @@ describe('auth commands', () => { expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json()).toMatchObject({ tokens: { [registry.url]: 'sk_ok' } }) }) + test('login and logout preserve compatible third-party state', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' } }) + const thirdPartyUser = { token: 'third-party-token', host: 'https://api.skillhub.cn' } + await Bun.write(`${env.home}/.skillhub/config.json`, JSON.stringify({ + self_update_url: 'https://skillhub.example.com/version.json', + auto_self_upgrade: false + })) + await Bun.write(`${env.home}/.skillhub/credentials.json`, JSON.stringify({ user: thirdPartyUser })) + + const login = await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(login.exitCode).toBe(0) + expect(await Bun.file(`${env.home}/.skillhub/config.json`).json()).toEqual({ + self_update_url: 'https://skillhub.example.com/version.json', + auto_self_upgrade: false, + registry: registry.url + }) + expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json()).toEqual({ + user: thirdPartyUser, + tokens: { [registry.url]: 'sk_ok' } + }) + + const logout = await runCli(['logout', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(logout.exitCode).toBe(0) + expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json()).toEqual({ + user: thirdPartyUser, + tokens: {} + }) + }) + test('login fails with invalid token', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) diff --git a/cli/test/unit/services/skill-target-lock.test.ts b/cli/test/unit/services/skill-target-lock.test.ts index ddee6418..7ceae909 100644 --- a/cli/test/unit/services/skill-target-lock.test.ts +++ b/cli/test/unit/services/skill-target-lock.test.ts @@ -1,4 +1,4 @@ -import { access, chmod, lstat, mkdir, mkdtemp, symlink, unlink, utimes, writeFile } from 'node:fs/promises' +import { access, chmod, lstat, mkdir, mkdtemp, readdir, symlink, unlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -27,6 +27,21 @@ async function waitForFile(path: string): Promise { throw new Error(`timed out waiting for ${path}`) } +async function waitForExitCount( + processes: Array<{ exited: Promise }>, + count: number +): Promise { + const exitCodes: number[] = [] + for (const process of processes) { + void process.exited.then(exitCode => exitCodes.push(exitCode)) + } + for (let attempt = 0; attempt < 500; attempt++) { + if (exitCodes.length >= count) return exitCodes + await Bun.sleep(10) + } + throw new Error(`timed out waiting for ${count} lock contenders to exit`) +} + describe('skill target lifecycle lock', () => { test('creates or repairs a private lock root and rejects unsafe roots', async () => { const parent = await mkdtemp(join(tmpdir(), 'skillhub-lock-root-')) @@ -65,12 +80,14 @@ describe('skill target lifecycle lock', () => { await mkdir(lockPath) const staleTime = new Date(Date.now() - 60_000) await utimes(lockPath, staleTime, staleTime) + const acquisitionGatePath = `${lockPath}.acquire` const worker = fileURLToPath(new URL('../../helpers/target-lock-worker.ts', import.meta.url)) const bunPath = (await Bun.which('bun')) ?? process.execPath const acquiredPath = join(rootDir, 'acquired') const releasePath = join(rootDir, 'release') const startPath = join(rootDir, 'start') - const readyPaths = [join(rootDir, 'ready-0'), join(rootDir, 'ready-1')] + const workerCount = 8 + const readyPaths = Array.from({ length: workerCount }, (_, index) => join(rootDir, `ready-${index}`)) const processes = readyPaths.map(readyPath => Bun.spawn({ cmd: [bunPath, worker, rootDir, 'demo', readyPath, startPath, acquiredPath, releasePath], @@ -81,11 +98,8 @@ describe('skill target lifecycle lock', () => { await Promise.all(readyPaths.map(waitForFile)) await writeFile(startPath, 'start') await waitForFile(acquiredPath) - const loserExitCode = await Promise.race([ - ...processes.map(process => process.exited), - Bun.sleep(5_000).then(() => { throw new Error('timed out waiting for the lock loser') }) - ]) - expect(loserExitCode).toBe(4) + const loserExitCodes = await waitForExitCount(processes, workerCount - 1) + expect(loserExitCodes).toEqual(Array(workerCount - 1).fill(4)) } finally { try { await writeFile(releasePath, 'release') @@ -106,14 +120,83 @@ describe('skill target lifecycle lock', () => { stderr: (await new Response(process.stderr).text()).trim() }))) - expect(results.map(result => result.exitCode).sort()).toEqual([0, 4]) + expect(results.map(result => result.exitCode).sort()).toEqual([0, ...Array(workerCount - 1).fill(4)]) expect(results.filter(result => result.stdout === 'acquired')).toHaveLength(1) + for (const loser of results.filter(result => result.exitCode === 4)) { + expect(loser.stderr).toContain('install target is busy') + } expect(await exists(lockPath)).toBe(false) + expect(await readdir(acquisitionGatePath)).toEqual([]) + const releaseAfterContention = await acquireSkillTargetLock(rootDir, 'demo') + await releaseAfterContention() if (process.platform !== 'win32') { expect((await lstat(dirname(lockPath))).mode & 0o077).toBe(0) } }, 15_000) + test('does not recover a live acquisition contender solely because its file is old', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-live-gate-')) + const lockPath = await skillTargetLockPath(rootDir, 'demo') + const acquisitionGatePath = `${lockPath}.acquire` + await mkdir(acquisitionGatePath) + const liveContenderPath = join(acquisitionGatePath, `choosing.${process.pid}-suspended`) + await writeFile(liveContenderPath, '') + const staleTime = new Date(Date.now() - 60_000) + await utimes(liveContenderPath, staleTime, staleTime) + + const acquisition = acquireSkillTargetLock(rootDir, 'demo') + const state = await Promise.race([ + acquisition.then(() => 'acquired', () => 'rejected'), + Bun.sleep(50).then(() => 'waiting') + ]) + expect(state).toBe('waiting') + expect(await exists(liveContenderPath)).toBe(true) + await unlink(liveContenderPath) + const release = await acquisition + await release() + }) + + test('does not pass a live acquisition ticket', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-live-ticket-')) + const lockPath = await skillTargetLockPath(rootDir, 'demo') + const acquisitionGatePath = `${lockPath}.acquire` + await mkdir(acquisitionGatePath) + const liveTicketPath = join(acquisitionGatePath, `ticket.1.${process.pid}-suspended`) + await writeFile(liveTicketPath, '') + + const acquisition = acquireSkillTargetLock(rootDir, 'demo') + const state = await Promise.race([ + acquisition.then(() => 'acquired', () => 'rejected'), + Bun.sleep(50).then(() => 'waiting') + ]) + expect(state).toBe('waiting') + expect(await exists(liveTicketPath)).toBe(true) + await unlink(liveTicketPath) + const release = await acquisition + await release() + }) + + test('recovers acquisition contenders whose owner process exited', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-dead-gate-')) + const lockPath = await skillTargetLockPath(rootDir, 'demo') + await mkdir(lockPath) + const staleTime = new Date(Date.now() - 60_000) + await utimes(lockPath, staleTime, staleTime) + const acquisitionGatePath = `${lockPath}.acquire` + await mkdir(acquisitionGatePath) + const bunPath = (await Bun.which('bun')) ?? process.execPath + const exitedOwner = Bun.spawn({ cmd: [bunPath, '-e', ''], stdout: 'ignore', stderr: 'ignore' }) + const deadPid = exitedOwner.pid + expect(await exitedOwner.exited).toBe(0) + await writeFile(join(acquisitionGatePath, `choosing.${deadPid}-abandoned`), '') + await writeFile(join(acquisitionGatePath, `ticket.1.${deadPid}-abandoned`), '') + + const release = await acquireSkillTargetLock(rootDir, 'demo') + await release() + expect(await exists(lockPath)).toBe(false) + expect(await readdir(acquisitionGatePath)).toEqual([]) + }) + test('keeps one lock identity when a symlink target is removed', async () => { const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-root-')) const linkedDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-value-')) diff --git a/cli/test/unit/stores/config-store.test.ts b/cli/test/unit/stores/config-store.test.ts index 15e27be8..5d451c3b 100644 --- a/cli/test/unit/stores/config-store.test.ts +++ b/cli/test/unit/stores/config-store.test.ts @@ -42,4 +42,23 @@ describe('ConfigStore', () => { expect(config.registry).toBe('https://new.com') expect(config.defaultAgent).toBe('codex') }) + + test('setRegistry() preserves third-party and unknown fields', async () => { + const home = await makeTempHome() + const store = new ConfigStore(home) + await store.write({ + self_update_url: 'https://skillhub.example.com/version.json', + auto_self_upgrade: false, + futureField: { enabled: true } + }) + + await store.setRegistry('https://registry.example.com') + + expect(await store.read()).toEqual({ + self_update_url: 'https://skillhub.example.com/version.json', + auto_self_upgrade: false, + futureField: { enabled: true }, + registry: 'https://registry.example.com' + }) + }) }) diff --git a/cli/test/unit/stores/credentials-store.test.ts b/cli/test/unit/stores/credentials-store.test.ts index 6ab7da53..16817c4a 100644 --- a/cli/test/unit/stores/credentials-store.test.ts +++ b/cli/test/unit/stores/credentials-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { normalize } from 'path' +import { readFile } from 'node:fs/promises' +import { normalize } from 'node:path' import { createTempHome } from '../../helpers/temp-env' import { CredentialsStore } from '../../../src/stores/credentials-store' @@ -12,4 +13,52 @@ describe('CredentialsStore', () => { expect(normalize(store.path)).toBe(normalize(`${env.home}/.skillhub/credentials.json`)) expect(await Bun.file(`${env.cwd}/credentials.json`).exists()).toBe(false) }) + + test('treats a third-party credentials file without tokens as logged out', async () => { + const env = await createTempHome() + const store = new CredentialsStore(env.home) + await Bun.write(store.path, JSON.stringify({ + user: { token: 'third-party-token', host: 'https://api.skillhub.cn' } + })) + + expect(await store.getToken('https://registry.example.com')).toBeUndefined() + }) + + test('setToken preserves third-party and unknown fields', async () => { + const env = await createTempHome() + const store = new CredentialsStore(env.home) + await Bun.write(store.path, JSON.stringify({ + user: { token: 'third-party-token', host: 'https://api.skillhub.cn' }, + futureField: { enabled: true } + })) + + await store.setToken('https://registry.example.com', 'sk_test') + + const saved = JSON.parse(await readFile(store.path, 'utf-8')) + expect(saved).toEqual({ + user: { token: 'third-party-token', host: 'https://api.skillhub.cn' }, + futureField: { enabled: true }, + tokens: { 'https://registry.example.com': 'sk_test' } + }) + }) + + test('deleteToken removes only the selected first-party token', async () => { + const env = await createTempHome() + const store = new CredentialsStore(env.home) + await Bun.write(store.path, JSON.stringify({ + user: { token: 'third-party-token', host: 'https://api.skillhub.cn' }, + tokens: { + 'https://registry-a.example.com': 'sk_a', + 'https://registry-b.example.com': 'sk_b' + } + })) + + await store.deleteToken('https://registry-a.example.com') + + const saved = JSON.parse(await readFile(store.path, 'utf-8')) + expect(saved).toEqual({ + user: { token: 'third-party-token', host: 'https://api.skillhub.cn' }, + tokens: { 'https://registry-b.example.com': 'sk_b' } + }) + }) }) diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index aba8a429..fe0a5bbc 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -83,6 +83,9 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com `login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`. +Both files are updated non-destructively: SkillHub CLI changes only its own `tokens` and `registry` +fields and preserves unknown fields written by other compatible tools. + When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message. ### Check Current Identity @@ -495,6 +498,9 @@ skillhub login --token [--registry ] [--json] Save token and registry configuration. +The CLI preserves unknown fields in the shared configuration and credentials documents when it +updates its own `registry` and `tokens` fields. + ### logout ```bash diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index b4c00da7..d15d706b 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -461,6 +461,9 @@ skillhub login --token [--registry ] [--json] 保存 token 和 registry 配置。 +CLI 以非破坏方式更新 `~/.skillhub/credentials.json` 和 `~/.skillhub/config.json`:只修改 +自己使用的 `tokens` 和 `registry` 字段,保留其他兼容工具写入的未知字段。 + ### logout ```bash