Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agent/skills
2 changes: 1 addition & 1 deletion .agents/skills
2 changes: 1 addition & 1 deletion .cursor/skills
2 changes: 1 addition & 1 deletion .github/skills
2 changes: 1 addition & 1 deletion .windsurf/skills
3 changes: 1 addition & 2 deletions apps/pair-cli/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,5 @@
{ "path": ".windsurf/skills/", "mode": "symlink" }
]
}
},
"default_target_folders": { "pair": ".pair", "github": ".github" }
}
}
4 changes: 3 additions & 1 deletion apps/pair-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"#kb-manager/*": "./src/kb-manager/*.ts",
"#diagnostics": "./src/diagnostics.ts",
"#test-utils": "./src/test-utils/index.ts",
"#test-utils/*": "./src/test-utils/*.ts"
"#test-utils/*": "./src/test-utils/*.ts",
"#ui": "./src/ui/index.ts",
"#ui/*": "./src/ui/*.ts"
},
"scripts": {
"build": "tsc -b tsconfig.build.json",
Expand Down
8 changes: 6 additions & 2 deletions apps/pair-cli/src/cli.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ describe('pair-cli e2e - list-targets', () => {
it('list-targets shows available registries', async () => {
const cwd = '/test-project'
const fs = createDevScenarioFs(cwd)
// Add KB marker so local source validation passes when source='.'
await fs.writeFile(cwd + '/AGENTS.md', 'this is agents.md')

await withTempConfig(fs, createTestConfig(), async () => {
// Mock the CLI execution by calling the update command with listTargets option
Expand Down Expand Up @@ -708,8 +710,9 @@ describe('pair-cli e2e - error scenarios', () => {
}),
}
const fs = new InMemoryFileSystemService(seed, cwd, cwd)
await handleUpdateCommand(parseUpdateCommand({ source: '/nonexistent/path' }), fs)
// Should fail gracefully when source doesn't exist
await expect(
handleUpdateCommand(parseUpdateCommand({ source: '/nonexistent/path' }), fs),
).rejects.toThrow('KB source path not found')
})

it('install from ZIP fails gracefully when ZIP is corrupted', async () => {
Expand Down Expand Up @@ -845,6 +848,7 @@ describe('pair-cli e2e - disjoint installation (source and target disjoint)', ()
version: '1.0.0',
}),
// KB Source content in a disjoint directory
[`${kbSourceDir}/AGENTS.md`]: '# KB source marker',
[`${kbSourceDir}/knowledge/index.md`]: '# Knowledge Index',
[`${kbSourceDir}/knowledge/guide.md`]: 'Follow the [Index](./index.md)',
}
Expand Down
20 changes: 16 additions & 4 deletions apps/pair-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ export async function runCli(
.option('--no-kb', 'Skip knowledge base download')
// Prevent Commander from calling process.exit() automatically
.exitOverride()
.configureHelp({ sortSubcommands: true })

program.addHelpText(
'beforeAll',
`\n ${chalk.bold(pkg.name)} ${chalk.dim(`v${pkg.version}`)}\n ${chalk.dim(pkg.description)}\n`,
)
program.addHelpText(
'afterAll',
`\n Run ${chalk.dim('pair <command> --help')} for detailed usage of a specific command.\n`,
)

runDiagnostics(fsService)
setupCommands(program, { fsService, httpClient, version: pkg.version })
Expand Down Expand Up @@ -135,12 +145,14 @@ function addCommandOptions(
}

function buildCommandHelpText(examples: readonly string[], notes: readonly string[]): string {
const exLines = examples.map((ex: string) => ` ${chalk.dim('$')} ${ex}`).join('\n')
const noteLines = notes.map((note: string) => ` ${chalk.dim('•')} ${note}`).join('\n')
return `
Examples:
${examples.map((ex: string) => ` $ ${ex}`).join('\n')}
${chalk.bold('Examples:')}
${exLines}

Usage Notes:
${notes.map((note: string) => ` • ${note}`).join('\n')}
${chalk.bold('Usage Notes:')}
${noteLines}
`
}

Expand Down
1 change: 1 addition & 0 deletions apps/pair-cli/src/commands/install/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('handleInstallCommand - real services integration', () => {
await fs.mkdir(externalKbPath, { recursive: true })
await fs.mkdir(`${externalKbPath}/my-reg`, { recursive: true })
await fs.writeFile(`${externalKbPath}/my-reg/file.txt`, 'local content')
await fs.writeFile(`${externalKbPath}/AGENTS.md`, '# KB marker')

const localConfig = {
asset_registries: {
Expand Down
79 changes: 56 additions & 23 deletions apps/pair-cli/src/commands/install/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { applyLinkTransformation } from '../update-link/logic'
import type { HttpClientService } from '@pair/content-ops'
import { type SkillNameMap } from '@pair/content-ops'
import { createCliPresenter, type CliPresenter, type RegistryResult } from '#ui'

/**
* Install options for handler
Expand All @@ -31,6 +32,7 @@ interface InstallHandlerOptions {
minLogLevel?: LogEntry['level']
httpClient?: HttpClientService
cliVersion?: string
presenter?: CliPresenter
}

/**
Expand All @@ -47,12 +49,12 @@ export async function handleInstallCommand(
options?.minLogLevel ??
'info'
const { pushLog } = createLogger(logLevel as LogEntry['level'])
const presenter = options?.presenter ?? createCliPresenter(pushLog)

try {
const { datasetRoot, registries, baseTarget } = await setupInstallContext(fs, config, options)
await validateInstallContext(fs, registries, baseTarget)
await executeInstall({ fs, datasetRoot, registries, baseTarget, options, pushLog })
pushLog('info', 'Installation completed successfully')
await executeInstall({ fs, datasetRoot, registries, baseTarget, options, pushLog, presenter })
} catch (err) {
pushLog('error', `Installation failed: ${String(err)}`)
throw err
Expand Down Expand Up @@ -104,8 +106,11 @@ async function installRegistry(ctx: {
datasetRoot: string
baseTarget: string
pushLog: (level: LogEntry['level'], message: string) => void
}): Promise<SkillNameMap | undefined> {
const { fs, registryName, registryConfig, datasetRoot, baseTarget, pushLog } = ctx
presenter: CliPresenter
index: number
total: number
}): Promise<{ skillNameMap?: SkillNameMap | undefined; result: RegistryResult }> {
const { fs, registryName, registryConfig, datasetRoot, baseTarget, presenter, index, total } = ctx
const resolved = resolveRegistryPaths({
name: registryName,
config: registryConfig,
Expand All @@ -123,55 +128,83 @@ async function installRegistry(ctx: {
const effectiveDatasetRoot =
registryConfig.flatten || registryConfig.prefix ? baseTarget : datasetRoot

pushLog('info', `Installing '${registryName}' from '${datasetPath}' to '${effectiveTarget}'`)
const result = await doCopyAndUpdateLinks(fs, {
presenter.registryStart({
name: registryName,
index,
total,
source: datasetPath,
target: effectiveTarget,
})
const copyResult = await doCopyAndUpdateLinks(fs, {
source: datasetPath,
target: effectiveTarget,
datasetRoot: effectiveDatasetRoot,
options: copyOptions,
})

await postCopyOps({ fs, registryConfig, effectiveTarget, datasetPath, baseTarget })
pushLog('info', `Successfully installed registry '${registryName}'`)
return result['skillNameMap'] as SkillNameMap | undefined
presenter.registryDone(registryName)
return {
skillNameMap: copyResult['skillNameMap'] as SkillNameMap | undefined,
result: { name: registryName, target: effectiveTarget, ok: true },
}
}

async function executeInstall(context: {
type InstallContext = {
fs: FileSystemService
datasetRoot: string
registries: Record<string, RegistryConfig>
baseTarget: string
options: InstallHandlerOptions | undefined
pushLog: (level: LogEntry['level'], message: string) => void
}): Promise<void> {
const { fs, datasetRoot, registries, baseTarget, options, pushLog } = context
const accumulatedSkillNameMap: SkillNameMap = new Map()
presenter: CliPresenter
}

async function installAllRegistries(ctx: InstallContext): Promise<{
results: RegistryResult[]
skillNameMap: SkillNameMap
}> {
const { fs, datasetRoot, registries, baseTarget, pushLog, presenter } = ctx
const accumulated: SkillNameMap = new Map()
const total = Object.keys(registries).length

await forEachRegistry(registries, async (registryName, registryConfig) => {
const skillNameMap = await installRegistry({
const results = await forEachRegistry(registries, async (registryName, registryConfig, index) => {
const out = await installRegistry({
fs,
registryName,
registryConfig,
datasetRoot,
baseTarget,
pushLog,
presenter,
index,
total,
})
if (skillNameMap) {
for (const [k, v] of skillNameMap) accumulatedSkillNameMap.set(k, v)
if (out.skillNameMap) {
for (const [k, v] of out.skillNameMap) accumulated.set(k, v)
}
return out.result
})
return { results, skillNameMap: accumulated }
}

if (accumulatedSkillNameMap.size > 0) {
await applySkillRefsToNonSkillRegistries(
{ fs, baseTarget, pushLog },
registries,
accumulatedSkillNameMap,
)
}
async function executeInstall(context: InstallContext): Promise<void> {
const { fs, registries, baseTarget, options, pushLog, presenter } = context
const total = Object.keys(registries).length
const startTime = Date.now()

presenter.startOperation('install', total)

const { results, skillNameMap } = await installAllRegistries(context)

if (skillNameMap.size > 0) {
await applySkillRefsToNonSkillRegistries({ fs, baseTarget, pushLog }, registries, skillNameMap)
}
if (options?.linkStyle) {
await applyLinkTransformation(fs, { linkStyle: options.linkStyle }, pushLog, 'install')
}

presenter.summary(results, 'install', Date.now() - startTime)
}

/**
Expand Down
12 changes: 12 additions & 0 deletions apps/pair-cli/src/commands/install/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,17 @@ describe('parseInstallCommand', () => {
parseInstallCommand({ source: '' })
}).toThrow('Source path/URL cannot be empty')
})

it('throws on unsupported ftp:// protocol', () => {
expect(() => {
parseInstallCommand({ source: 'ftp://example.com/kb.zip' })
}).toThrow('Unsupported source protocol')
})

it('throws on unsupported file:// protocol', () => {
expect(() => {
parseInstallCommand({ source: 'file:///tmp/kb.zip' })
}).toThrow('Unsupported source protocol')
})
})
})
10 changes: 7 additions & 3 deletions apps/pair-cli/src/commands/install/parser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { detectSourceType, SourceType } from '@pair/content-ops'
import { validateCommandOptions } from '#config/cli'
import { isRemoteUrl, isUnsupportedProtocol } from '@pair/content-ops'

/**
* Discriminated union for install command with default resolution
Expand Down Expand Up @@ -83,9 +83,13 @@ export function parseInstallCommand(
}
}

// Reject unsupported protocols early
if (isUnsupportedProtocol(source)) {
throw new Error(`Unsupported source protocol: ${source}`)
}

// Remote source
const sourceType = detectSourceType(source)
if (sourceType === SourceType.REMOTE_URL) {
if (isRemoteUrl(source)) {
return {
command: 'install',
resolution: 'remote',
Expand Down
Loading