Skip to content

Commit 236c670

Browse files
committed
fix(cli): unbox sync output
1 parent e617345 commit 236c670

File tree

3 files changed

+46
-80
lines changed

3 files changed

+46
-80
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
### Changed
1010
- CLI sync: default `--concurrency` is now 4 (was 8).
11+
- CLI sync: replace boxed notes with plain output for long lists.
1112

1213
### Fixed
1314
- CLI sync: wrap note output to avoid terminal overflow; cap list lengths.

packages/clawdhub/src/cli/commands/sync.test.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { GlobalOpts } from '../types'
55

66
const mockIntro = vi.fn()
77
const mockOutro = vi.fn()
8-
const mockNote = vi.fn()
8+
const mockLog = vi.fn()
99
const mockMultiselect = vi.fn(async (_args?: unknown) => [] as string[])
1010
let interactive = false
1111

@@ -21,7 +21,6 @@ const defaultFindSkillFolders = async (root: string) => {
2121
vi.mock('@clack/prompts', () => ({
2222
intro: (value: string) => mockIntro(value),
2323
outro: (value: string) => mockOutro(value),
24-
note: (message: string, body?: string) => mockNote(message, body),
2524
multiselect: (args: unknown) => mockMultiselect(args),
2625
text: vi.fn(async () => ''),
2726
isCancel: () => false,
@@ -90,6 +89,10 @@ afterEach(async () => {
9089
vi.mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders)
9190
})
9291

92+
vi.spyOn(console, 'log').mockImplementation((...args) => {
93+
mockLog(args.map(String).join(' '))
94+
})
95+
9396
describe('cmdSync', () => {
9497
it('classifies skills as new/update/synced (dry-run, mocked HTTP)', async () => {
9598
interactive = false
@@ -115,8 +118,9 @@ describe('cmdSync', () => {
115118

116119
expect(mockCmdPublish).not.toHaveBeenCalled()
117120

118-
const alreadySyncedNote = mockNote.mock.calls.find((call) => call[0] === 'Already synced')
119-
expect(alreadySyncedNote?.[1]).toMatch(/synced-skill/)
121+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
122+
expect(output).toMatch(/Already synced/)
123+
expect(output).toMatch(/synced-skill/)
120124

121125
const dryRunOutro = mockOutro.mock.calls.at(-1)?.[0]
122126
expect(String(dryRunOutro)).toMatch(/Dry run: would upload 2 skill/)
@@ -148,12 +152,12 @@ describe('cmdSync', () => {
148152

149153
await cmdSync(makeOpts(), { root: ['/scan'], all: false, dryRun: false, bump: 'patch' }, true)
150154

151-
const toSyncNote = mockNote.mock.calls.find((call) => call[0] === 'To sync')
152-
expect(toSyncNote?.[1]).toMatch(/- new-skill/)
153-
expect(toSyncNote?.[1]).toMatch(/- update-skill/)
154-
155-
const syncedNote = mockNote.mock.calls.find((call) => call[0] === 'Already synced')
156-
expect(syncedNote?.[1]).toMatch(/- synced-skill/)
155+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
156+
expect(output).toMatch(/To sync/)
157+
expect(output).toMatch(/- new-skill/)
158+
expect(output).toMatch(/- update-skill/)
159+
expect(output).toMatch(/Already synced/)
160+
expect(output).toMatch(/- synced-skill/)
157161

158162
const lastCall = mockMultiselect.mock.calls.at(-1)
159163
const promptArgs = lastCall ? (lastCall[0] as { initialValues: string[] }) : undefined
@@ -173,10 +177,11 @@ describe('cmdSync', () => {
173177

174178
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false }, true)
175179

176-
const syncedNote = mockNote.mock.calls.find((call) => call[0] === 'Already synced')
177-
expect(syncedNote?.[1]).toMatch(/new-skill@1.0.0/)
178-
expect(syncedNote?.[1]).toMatch(/synced-skill@1.0.0/)
179-
expect(String(syncedNote?.[1])).not.toMatch(/\n-/)
180+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
181+
expect(output).toMatch(/Already synced/)
182+
expect(output).toMatch(/new-skill@1.0.0/)
183+
expect(output).toMatch(/synced-skill@1.0.0/)
184+
expect(output).not.toMatch(/\n-/)
180185

181186
const outro = mockOutro.mock.calls.at(-1)?.[0]
182187
expect(String(outro)).toMatch(/Nothing to sync/)
@@ -204,8 +209,9 @@ describe('cmdSync', () => {
204209
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false }, true)
205210

206211
expect(mockCmdPublish).toHaveBeenCalledTimes(1)
207-
const duplicateNote = mockNote.mock.calls.find((call) => call[0] === 'Skipped duplicate slugs')
208-
expect(duplicateNote?.[1]).toMatch(/dup-skill/)
212+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
213+
expect(output).toMatch(/Skipped duplicate slugs/)
214+
expect(output).toMatch(/dup-skill/)
209215
})
210216

211217
it('allows empty changelog for updates (interactive)', async () => {

packages/clawdhub/src/cli/commands/sync.ts

Lines changed: 23 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { realpath } from 'node:fs/promises'
22
import { homedir } from 'node:os'
33
import { resolve } from 'node:path'
4-
import { intro, isCancel, multiselect, note, outro, text } from '@clack/prompts'
4+
import { intro, isCancel, multiselect, outro, text } from '@clack/prompts'
55
import semver from 'semver'
66
import { readGlobalConfig } from '../../config.js'
77
import { apiRequest, downloadZip } from '../../http.js'
@@ -61,17 +61,17 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
6161
spinner.stop()
6262
if (scan.skills.length === 0)
6363
fail('No skills found (checked workdir and known Clawdis/Clawd locations)')
64-
note(
64+
printSection(
6565
`No skills in workdir. Found ${scan.skills.length} in fallback locations.`,
66-
wrapNoteBody(formatList(scan.rootsWithSkills, 10)),
66+
formatList(scan.rootsWithSkills, 10),
6767
)
6868
} else {
6969
spinner.stop()
7070
}
7171
const deduped = dedupeSkillsBySlug(scan.skills)
7272
const skills = deduped.skills
7373
if (deduped.duplicates.length > 0) {
74-
note('Skipped duplicate slugs', wrapNoteBody(formatCommaList(deduped.duplicates, 16)))
74+
printSection('Skipped duplicate slugs', formatCommaList(deduped.duplicates, 16))
7575
}
7676
const parsingSpinner = createSpinner('Parsing local skills')
7777
const locals: LocalSkill[] = []
@@ -123,23 +123,21 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
123123

124124
if (actionable.length === 0) {
125125
if (synced.length > 0) {
126-
note('Already synced', wrapNoteBody(formatCommaList(synced.map(formatSyncedSummary), 16)))
126+
printSection('Already synced', formatCommaList(synced.map(formatSyncedSummary), 16))
127127
}
128128
outro('Nothing to sync.')
129129
return
130130
}
131131

132-
note(
132+
printSection(
133133
'To sync',
134-
wrapNoteBody(
135-
formatBulletList(
136-
actionable.map((candidate) => formatActionableLine(candidate, bump)),
137-
20,
138-
),
134+
formatBulletList(
135+
actionable.map((candidate) => formatActionableLine(candidate, bump)),
136+
20,
139137
),
140138
)
141139
if (synced.length > 0) {
142-
note('Already synced', wrapNoteBody(formatSyncedDisplay(synced)))
140+
printSection('Already synced', formatSyncedDisplay(synced))
143141
}
144142

145143
const selected = await selectToUpload(actionable, {
@@ -392,6 +390,19 @@ function formatList(values: string[], max: number) {
392390
return [...head, `… +${rest} more`].join('\n')
393391
}
394392

393+
function printSection(title: string, body?: string) {
394+
const trimmed = body?.trim()
395+
if (!trimmed) {
396+
console.log(title)
397+
return
398+
}
399+
if (trimmed.includes('\n')) {
400+
console.log(`\n${title}\n${trimmed}`)
401+
return
402+
}
403+
console.log(`${title}: ${trimmed}`)
404+
}
405+
395406
function abbreviatePath(value: string) {
396407
const home = homedir()
397408
if (value.startsWith(home)) return `~${value.slice(home.length)}`
@@ -449,58 +460,6 @@ function formatSyncedDisplay(synced: Candidate[]) {
449460
return formatCommaList(synced.map(formatSyncedSummary), 24)
450461
}
451462

452-
function wrapNoteBody(text: string) {
453-
const width = noteWrapWidth()
454-
return text
455-
.split('\n')
456-
.map((line) => wrapLine(line, width))
457-
.join('\n')
458-
}
459-
460-
function noteWrapWidth() {
461-
const columns = process.stdout.columns ?? 80
462-
return Math.min(80, Math.max(20, columns - 4))
463-
}
464-
465-
function wrapLine(line: string, width: number) {
466-
if (line.length <= width) return line
467-
if (line.startsWith('- ')) {
468-
return wrapWords(line.slice(2), width - 2, '- ', ' ')
469-
}
470-
return wrapWords(line, width, '', '')
471-
}
472-
473-
function wrapWords(text: string, width: number, firstPrefix: string, nextPrefix: string) {
474-
const words = text.trim().split(/\s+/).filter(Boolean)
475-
if (words.length === 0) return firstPrefix.trimEnd()
476-
const lines: string[] = []
477-
let current = ''
478-
for (const word of words) {
479-
if (word.length > width) {
480-
if (current) {
481-
lines.push(current)
482-
current = ''
483-
}
484-
for (let i = 0; i < word.length; i += width) {
485-
lines.push(word.slice(i, i + width))
486-
}
487-
continue
488-
}
489-
if (!current) {
490-
current = word
491-
continue
492-
}
493-
if (current.length + 1 + word.length <= width) {
494-
current = `${current} ${word}`
495-
continue
496-
}
497-
lines.push(current)
498-
current = word
499-
}
500-
if (current) lines.push(current)
501-
return lines.map((line, index) => `${index === 0 ? firstPrefix : nextPrefix}${line}`).join('\n')
502-
}
503-
504463
function formatCommaList(values: string[], max: number) {
505464
if (values.length === 0) return ''
506465
if (values.length <= max) return values.join(', ')

0 commit comments

Comments
 (0)