Skip to content

Commit aaacdb7

Browse files
authored
feat(cli): add install progress reporting and improve add install feedback (#8)
* feat: update * fix(cli): tighten install progress reporter behavior
1 parent 68e44af commit aaacdb7

File tree

10 files changed

+514
-34
lines changed

10 files changed

+514
-34
lines changed

packages/skills-package-manager/src/commands/add.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ export async function addCommand(options: AddCommandOptions) {
9696
const skillPath = found?.path ?? `/${skill}`
9797
const gitSpecifier = buildGitHubSpecifier(owner, repo, skillPath)
9898
const result = await addSingleSkill(cwd, gitSpecifier)
99+
spinner.start('Installing skills...')
99100
await installSkills(cwd)
101+
spinner.stop('Installed skills')
100102
p.outro(`Added ${pc.cyan(result.skillName)}`)
101103
return result
102104
}
@@ -127,13 +129,18 @@ export async function addCommand(options: AddCommandOptions) {
127129
p.log.success(`Added ${pc.cyan(result.skillName)}`)
128130
}
129131

132+
spinner.start('Installing skills...')
130133
await installSkills(cwd)
134+
spinner.stop('Installed skills')
131135
p.outro('Done')
132136
return results.length === 1 ? results[0] : results
133137
}
134138

135139
// Protocol specifier (file:, npm:, git URL with fragment, etc.) — direct add
136140
const result = await addSingleSkill(cwd, specifier)
141+
const spinner = p.spinner()
142+
spinner.start('Installing skills...')
137143
await installSkills(cwd)
144+
spinner.stop('Installed skills')
138145
return result
139146
}

packages/skills-package-manager/src/commands/install.ts

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { InstallCommandOptions } from '../config/types'
66
import { writeSkillsLock } from '../config/writeSkillsLock'
77
import { ErrorCode, ManifestError } from '../errors'
88
import { fetchSkillsFromLock, linkSkillsFromLock } from '../install/installSkills'
9+
import { createInstallProgressReporter } from '../install/progressReporter'
910

1011
export async function installCommand(options: InstallCommandOptions) {
1112
const manifest = await readSkillsManifest(options.cwd)
@@ -18,39 +19,68 @@ export async function installCommand(options: InstallCommandOptions) {
1819
}
1920

2021
const currentLock = await readSkillsLock(options.cwd)
22+
const totalSkills = Object.keys(manifest.skills).length
23+
const reporter = createInstallProgressReporter()
24+
const onProgress = (event: Parameters<typeof reporter.onProgress>[0]) =>
25+
reporter.onProgress(event)
26+
let started = false
2127

22-
if (options.frozenLockfile) {
23-
// Frozen mode: lock must exist and be in sync
24-
if (!currentLock) {
25-
throw new ManifestError({
26-
code: ErrorCode.LOCKFILE_NOT_FOUND,
27-
filePath: `${options.cwd}/skills-lock.yaml`,
28-
message: 'Lockfile is required in frozen mode but none was found. Run "spm install" first.',
29-
})
30-
}
31-
if (!isLockInSync(manifest, currentLock)) {
32-
throw new ManifestError({
33-
code: ErrorCode.LOCKFILE_OUTDATED,
34-
filePath: `${options.cwd}/skills-lock.yaml`,
35-
message:
36-
'Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.',
37-
})
38-
}
28+
try {
29+
if (options.frozenLockfile) {
30+
// Frozen mode: lock must exist and be in sync
31+
if (!currentLock) {
32+
throw new ManifestError({
33+
code: ErrorCode.LOCKFILE_NOT_FOUND,
34+
filePath: `${options.cwd}/skills-lock.yaml`,
35+
message:
36+
'Lockfile is required in frozen mode but none was found. Run "spm install" first.',
37+
})
38+
}
39+
if (!isLockInSync(manifest, currentLock)) {
40+
throw new ManifestError({
41+
code: ErrorCode.LOCKFILE_OUTDATED,
42+
filePath: `${options.cwd}/skills-lock.yaml`,
43+
message:
44+
'Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.',
45+
})
46+
}
3947

40-
await fetchSkillsFromLock(options.cwd, manifest, currentLock)
41-
await linkSkillsFromLock(options.cwd, manifest, currentLock)
48+
reporter.start(totalSkills)
49+
started = true
50+
for (const skillName of Object.keys(currentLock.skills)) {
51+
onProgress({ type: 'resolved', skillName })
52+
}
4253

43-
return { status: 'installed', installed: Object.keys(currentLock.skills) } as const
44-
}
54+
reporter.setPhase('fetching')
55+
await fetchSkillsFromLock(options.cwd, manifest, currentLock, { onProgress })
56+
reporter.setPhase('linking')
57+
await linkSkillsFromLock(options.cwd, manifest, currentLock, { onProgress })
58+
reporter.setPhase('finalizing')
59+
reporter.complete()
4560

46-
// Normal mode: sync lock with manifest (may trigger network requests)
47-
const lockfile = await syncSkillsLock(options.cwd, manifest, currentLock)
61+
return { status: 'installed', installed: Object.keys(currentLock.skills) } as const
62+
}
4863

49-
await fetchSkillsFromLock(options.cwd, manifest, lockfile)
50-
await linkSkillsFromLock(options.cwd, manifest, lockfile)
64+
// Normal mode: sync lock with manifest (may trigger network requests)
65+
reporter.start(totalSkills)
66+
started = true
67+
const lockfile = await syncSkillsLock(options.cwd, manifest, currentLock, { onProgress })
5168

52-
// Write lockfile only after all operations succeed (atomicity)
53-
await writeSkillsLock(options.cwd, lockfile)
69+
reporter.setPhase('fetching')
70+
await fetchSkillsFromLock(options.cwd, manifest, lockfile, { onProgress })
71+
reporter.setPhase('linking')
72+
await linkSkillsFromLock(options.cwd, manifest, lockfile, { onProgress })
5473

55-
return { status: 'installed', installed: Object.keys(lockfile.skills) } as const
74+
// Write lockfile only after all operations succeed (atomicity)
75+
reporter.setPhase('finalizing')
76+
await writeSkillsLock(options.cwd, lockfile)
77+
reporter.complete()
78+
79+
return { status: 'installed', installed: Object.keys(lockfile.skills) } as const
80+
} catch (error) {
81+
if (started) {
82+
reporter.fail()
83+
}
84+
throw error
85+
}
5686
}

packages/skills-package-manager/src/config/syncSkillsLock.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { promisify } from 'node:util'
66
import { ErrorCode, GitError, ParseError } from '../errors'
77
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
88
import { sha256 } from '../utils/hash'
9-
import type { SkillsLock, SkillsLockEntry, SkillsManifest } from './types'
9+
import type { InstallProgressListener, SkillsLock, SkillsLockEntry, SkillsManifest } from './types'
1010

1111
const execFileAsync = promisify(execFile)
1212

@@ -132,10 +132,14 @@ export async function syncSkillsLock(
132132
cwd: string,
133133
manifest: SkillsManifest,
134134
_existingLock: SkillsLock | null,
135+
options?: {
136+
onProgress?: InstallProgressListener
137+
},
135138
): Promise<SkillsLock> {
136139
const entries = await Promise.all(
137140
Object.entries(manifest.skills).map(async ([skillName, specifier]) => {
138141
const { skillName: resolvedName, entry } = await resolveLockEntry(cwd, specifier, skillName)
142+
options?.onProgress?.({ type: 'resolved', skillName: resolvedName })
139143
return [resolvedName, entry] as const
140144
}),
141145
)

packages/skills-package-manager/src/config/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,10 @@ export type InstallCommandOptions = {
5858
cwd: string
5959
frozenLockfile?: boolean
6060
}
61+
62+
export type InstallProgressEvent =
63+
| { type: 'resolved'; skillName: string }
64+
| { type: 'added'; skillName: string }
65+
| { type: 'installed'; skillName: string }
66+
67+
export type InstallProgressListener = (event: InstallProgressEvent) => void

packages/skills-package-manager/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type {
1515
AddCommandOptions,
1616
InitCommandOptions,
1717
InstallCommandOptions,
18+
InstallProgressEvent,
19+
InstallProgressListener,
1820
NormalizedSpecifier,
1921
SkillsLock,
2022
SkillsLockEntry,
@@ -56,6 +58,7 @@ export {
5658
installStageHooks,
5759
linkSkillsFromLock,
5860
} from './install/installSkills'
61+
export { createInstallProgressReporter } from './install/progressReporter'
5962
// Specifiers
6063
export { normalizeSpecifier } from './specifiers/normalizeSpecifier'
6164
export { parseSpecifier } from './specifiers/parseSpecifier'

packages/skills-package-manager/src/install/installSkills.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { isLockInSync } from '../config/compareSkillsLock'
33
import { readSkillsLock } from '../config/readSkillsLock'
44
import { readSkillsManifest } from '../config/readSkillsManifest'
55
import { syncSkillsLock } from '../config/syncSkillsLock'
6-
import type { SkillsLock, SkillsManifest } from '../config/types'
6+
import type { InstallProgressListener, SkillsLock, SkillsManifest } from '../config/types'
77
import { writeSkillsLock } from '../config/writeSkillsLock'
88
import { sha256 } from '../utils/hash'
99
import { readInstallState, writeInstallState } from './installState'
@@ -29,6 +29,9 @@ export async function fetchSkillsFromLock(
2929
rootDir: string,
3030
manifest: SkillsManifest,
3131
lockfile: SkillsLock,
32+
options?: {
33+
onProgress?: InstallProgressListener
34+
},
3235
) {
3336
await installStageHooks.beforeFetch(rootDir, manifest, lockfile)
3437

@@ -52,6 +55,7 @@ export async function fetchSkillsFromLock(
5255
extractSkillPath(entry.specifier, skillName),
5356
installDir,
5457
)
58+
options?.onProgress?.({ type: 'added', skillName })
5559
continue
5660
}
5761

@@ -64,6 +68,7 @@ export async function fetchSkillsFromLock(
6468
entry.resolution.path,
6569
installDir,
6670
)
71+
options?.onProgress?.({ type: 'added', skillName })
6772
continue
6873
}
6974

@@ -85,6 +90,9 @@ export async function linkSkillsFromLock(
8590
rootDir: string,
8691
manifest: SkillsManifest,
8792
lockfile: SkillsLock,
93+
options?: {
94+
onProgress?: InstallProgressListener
95+
},
8896
) {
8997
const installDir = manifest.installDir ?? '.agents/skills'
9098
const linkTargets = manifest.linkTargets ?? []
@@ -93,12 +101,16 @@ export async function linkSkillsFromLock(
93101
for (const linkTarget of linkTargets) {
94102
await linkSkill(rootDir, installDir, linkTarget, skillName)
95103
}
104+
options?.onProgress?.({ type: 'installed', skillName })
96105
}
97106

98107
return { status: 'linked', linked: Object.keys(lockfile.skills) } as const
99108
}
100109

101-
export async function installSkills(rootDir: string, options?: { frozenLockfile?: boolean }) {
110+
export async function installSkills(
111+
rootDir: string,
112+
options?: { frozenLockfile?: boolean; onProgress?: InstallProgressListener },
113+
) {
102114
const manifest = await readSkillsManifest(rootDir)
103115
if (!manifest) {
104116
return { status: 'skipped', reason: 'manifest-missing' } as const
@@ -119,13 +131,18 @@ export async function installSkills(rootDir: string, options?: { frozenLockfile?
119131
)
120132
}
121133
lockfile = currentLock
134+
for (const skillName of Object.keys(lockfile.skills)) {
135+
options?.onProgress?.({ type: 'resolved', skillName })
136+
}
122137
} else {
123138
// Normal mode: sync lock with manifest (may trigger network requests)
124-
lockfile = await syncSkillsLock(rootDir, manifest, currentLock)
139+
lockfile = await syncSkillsLock(rootDir, manifest, currentLock, {
140+
onProgress: options?.onProgress,
141+
})
125142
}
126143

127-
await fetchSkillsFromLock(rootDir, manifest, lockfile)
128-
await linkSkillsFromLock(rootDir, manifest, lockfile)
144+
await fetchSkillsFromLock(rootDir, manifest, lockfile, { onProgress: options?.onProgress })
145+
await linkSkillsFromLock(rootDir, manifest, lockfile, { onProgress: options?.onProgress })
129146

130147
// Write lockfile only after all operations succeed (atomicity)
131148
if (!options?.frozenLockfile) {

0 commit comments

Comments
 (0)