Skip to content

Commit f52adc4

Browse files
committed
Speed up lightweight CLI commands and reuse output declarations
1 parent 2e6a8a0 commit f52adc4

File tree

7 files changed

+231
-32
lines changed

7 files changed

+231
-32
lines changed

cli/src/cli-runtime.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {afterEach, describe, expect, it, vi} from 'vitest'
2+
3+
const {
4+
createDefaultPluginConfigMock,
5+
pipelineRunMock,
6+
pluginPipelineCtorMock
7+
} = vi.hoisted(() => ({
8+
createDefaultPluginConfigMock: vi.fn(),
9+
pipelineRunMock: vi.fn(),
10+
pluginPipelineCtorMock: vi.fn()
11+
}))
12+
13+
vi.mock('./plugin.config', () => ({
14+
createDefaultPluginConfig: createDefaultPluginConfigMock
15+
}))
16+
17+
vi.mock('./PluginPipeline', () => ({
18+
PluginPipeline: function MockPluginPipeline(...args: unknown[]) {
19+
pluginPipelineCtorMock(...args)
20+
return {
21+
run: pipelineRunMock
22+
}
23+
}
24+
}))
25+
26+
afterEach(() => {
27+
vi.clearAllMocks()
28+
vi.resetModules()
29+
})
30+
31+
describe('cli runtime lightweight commands', () => {
32+
it('does not load plugin config for --version', async () => {
33+
const {runCli} = await import('./cli-runtime')
34+
35+
const exitCode = await runCli(['node', 'tnmsc', '--version'])
36+
37+
expect(exitCode).toBe(0)
38+
expect(createDefaultPluginConfigMock).not.toHaveBeenCalled()
39+
expect(pluginPipelineCtorMock).not.toHaveBeenCalled()
40+
expect(pipelineRunMock).not.toHaveBeenCalled()
41+
})
42+
43+
it('emits JSON for --version --json without loading plugin config', async () => {
44+
const {runCli} = await import('./cli-runtime')
45+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
46+
47+
try {
48+
const exitCode = await runCli(['node', 'tnmsc', '--version', '--json'])
49+
50+
expect(exitCode).toBe(0)
51+
expect(createDefaultPluginConfigMock).not.toHaveBeenCalled()
52+
expect(pluginPipelineCtorMock).not.toHaveBeenCalled()
53+
expect(pipelineRunMock).not.toHaveBeenCalled()
54+
55+
const payload = JSON.parse(String(writeSpy.mock.calls[0]?.[0])) as {
56+
readonly success: boolean
57+
readonly message?: string
58+
}
59+
60+
expect(payload.success).toBe(true)
61+
expect(payload.message).toBe('Version displayed')
62+
}
63+
finally {
64+
writeSpy.mockRestore()
65+
}
66+
})
67+
})

cli/src/cli-runtime.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import type {Command, CommandContext, CommandResult} from '@/commands/Command'
2+
import * as path from 'node:path'
13
import process from 'node:process'
2-
import {toJsonCommandResult} from '@/commands/JsonOutputCommand'
4+
import {JsonOutputCommand, toJsonCommandResult} from '@/commands/JsonOutputCommand'
35
import {buildUnhandledExceptionDiagnostic} from '@/diagnostics'
46
import {PluginPipeline} from '@/PluginPipeline'
7+
import {mergeConfig} from './config'
8+
import {extractUserArgs, parseArgs, resolveCommand} from './pipeline/CliArgumentParser'
59
import {createDefaultPluginConfig} from './plugin.config'
6-
import {createLogger, drainBufferedDiagnostics} from './plugins/plugin-core'
10+
import {createLogger, drainBufferedDiagnostics, FilePathKind, setGlobalLogLevel} from './plugins/plugin-core'
11+
12+
const LIGHTWEIGHT_COMMAND_NAMES = new Set(['help', 'version', 'unknown'])
713

814
export function isJsonMode(argv: readonly string[]): boolean {
915
return argv.some(arg => arg === '--json' || arg === '-j' || /^-[^-]*j/.test(arg))
@@ -21,8 +27,67 @@ function writeJsonFailure(error: unknown): void {
2127
}, drainBufferedDiagnostics()))}\n`)
2228
}
2329

30+
function createUnavailableContext(kind: 'cleanup' | 'write'): never {
31+
throw new Error(`${kind} context is unavailable for lightweight commands`)
32+
}
33+
34+
function createLightweightCommandContext(logLevel: ReturnType<typeof parseArgs>['logLevel']): CommandContext {
35+
const workspaceDir = process.cwd()
36+
const userConfigOptions = mergeConfig({
37+
workspaceDir,
38+
...logLevel != null ? {logLevel} : {}
39+
})
40+
41+
return {
42+
logger: createLogger('PluginPipeline', logLevel),
43+
outputPlugins: [],
44+
collectedOutputContext: {
45+
workspace: {
46+
directory: {
47+
pathKind: FilePathKind.Absolute,
48+
path: workspaceDir,
49+
getDirectoryName: () => path.basename(workspaceDir)
50+
},
51+
projects: []
52+
}
53+
},
54+
userConfigOptions,
55+
createCleanContext: () => createUnavailableContext('cleanup'),
56+
createWriteContext: () => createUnavailableContext('write')
57+
}
58+
}
59+
60+
function resolveLightweightCommand(argv: readonly string[]): {
61+
readonly command: Command
62+
readonly context: CommandContext
63+
} | undefined {
64+
const filteredArgs = argv.filter((arg): arg is string => arg != null)
65+
const parsedArgs = parseArgs(extractUserArgs(filteredArgs))
66+
let command: Command = resolveCommand(parsedArgs)
67+
68+
if (!LIGHTWEIGHT_COMMAND_NAMES.has(command.name)) return void 0
69+
70+
if (parsedArgs.logLevel != null) setGlobalLogLevel(parsedArgs.logLevel)
71+
72+
if (parsedArgs.jsonFlag) {
73+
setGlobalLogLevel('silent')
74+
command = new JsonOutputCommand(command)
75+
}
76+
77+
return {
78+
command,
79+
context: createLightweightCommandContext(parsedArgs.logLevel)
80+
}
81+
}
82+
2483
export async function runCli(argv: readonly string[] = process.argv): Promise<number> {
2584
try {
85+
const lightweightCommand = resolveLightweightCommand(argv)
86+
if (lightweightCommand != null) {
87+
const result: CommandResult = await lightweightCommand.command.execute(lightweightCommand.context)
88+
return result.success ? 0 : 1
89+
}
90+
2691
const pipeline = new PluginPipeline(...argv)
2792
const userPluginConfig = await createDefaultPluginConfig(argv)
2893
const result = await pipeline.run(userPluginConfig)

cli/src/commands/CleanupUtils.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputPlugin, PluginOptions} from '../plugins/plugin-core'
1+
import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputFileDeclaration, OutputPlugin, PluginOptions} from '../plugins/plugin-core'
22
import type {ProtectedPathRule, ProtectionMode, ProtectionRuleMatcher} from '../ProtectedDeletionGuard'
33
import * as fs from 'node:fs'
44
import * as path from 'node:path'
@@ -130,14 +130,18 @@ async function collectPluginCleanupDeclarations(
130130

131131
async function collectPluginCleanupSnapshot(
132132
plugin: OutputPlugin,
133-
cleanCtx: OutputCleanContext
133+
cleanCtx: OutputCleanContext,
134+
predeclaredOutputs?: ReadonlyMap<OutputPlugin, readonly OutputFileDeclaration[]>
134135
): Promise<{
135136
readonly plugin: OutputPlugin
136137
readonly outputs: Awaited<ReturnType<OutputPlugin['declareOutputFiles']>>
137138
readonly cleanup: OutputCleanupDeclarations
138139
}> {
140+
const existingOutputDeclarations = predeclaredOutputs?.get(plugin)
139141
const [outputs, cleanup] = await Promise.all([
140-
plugin.declareOutputFiles({...cleanCtx, dryRun: true}),
142+
existingOutputDeclarations != null
143+
? Promise.resolve(existingOutputDeclarations)
144+
: plugin.declareOutputFiles({...cleanCtx, dryRun: true}),
141145
collectPluginCleanupDeclarations(plugin, cleanCtx)
142146
])
143147

@@ -258,7 +262,8 @@ function logCleanupProtectionConflicts(
258262
*/
259263
export async function collectDeletionTargets(
260264
outputPlugins: readonly OutputPlugin[],
261-
cleanCtx: OutputCleanContext
265+
cleanCtx: OutputCleanContext,
266+
predeclaredOutputs?: ReadonlyMap<OutputPlugin, readonly OutputFileDeclaration[]>
262267
): Promise<{
263268
filesToDelete: string[]
264269
dirsToDelete: string[]
@@ -272,7 +277,9 @@ export async function collectDeletionTargets(
272277
const excludeScanGlobSet = new Set<string>(DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS)
273278
const outputPathOwners = new Map<string, string[]>()
274279

275-
const pluginSnapshots = await Promise.all(outputPlugins.map(async plugin => collectPluginCleanupSnapshot(plugin, cleanCtx)))
280+
const pluginSnapshots = await Promise.all(
281+
outputPlugins.map(async plugin => collectPluginCleanupSnapshot(plugin, cleanCtx, predeclaredOutputs))
282+
)
276283

277284
const addDeletePath = (rawPath: string, kind: 'file' | 'directory'): void => {
278285
if (kind === 'directory') deleteDirs.add(resolveAbsolutePath(rawPath))
@@ -491,19 +498,22 @@ function logCleanupPlanDiagnostics(
491498
export async function performCleanup(
492499
outputPlugins: readonly OutputPlugin[],
493500
cleanCtx: OutputCleanContext,
494-
logger: ILogger
501+
logger: ILogger,
502+
predeclaredOutputs?: ReadonlyMap<OutputPlugin, readonly OutputFileDeclaration[]>
495503
): Promise<CleanupResult> {
496-
const outputs = await collectAllPluginOutputs(outputPlugins, cleanCtx) // Collect outputs for logging
497-
logger.debug('Collected outputs for cleanup', {
498-
projectDirs: outputs.projectDirs.length,
499-
projectFiles: outputs.projectFiles.length,
500-
globalDirs: outputs.globalDirs.length,
501-
globalFiles: outputs.globalFiles.length
502-
})
504+
if (predeclaredOutputs != null) {
505+
const outputs = await collectAllPluginOutputs(outputPlugins, cleanCtx, predeclaredOutputs)
506+
logger.debug('Collected outputs for cleanup', {
507+
projectDirs: outputs.projectDirs.length,
508+
projectFiles: outputs.projectFiles.length,
509+
globalDirs: outputs.globalDirs.length,
510+
globalFiles: outputs.globalFiles.length
511+
})
512+
}
503513

504514
let targets: Awaited<ReturnType<typeof collectDeletionTargets>>
505515
try {
506-
targets = await collectDeletionTargets(outputPlugins, cleanCtx)
516+
targets = await collectDeletionTargets(outputPlugins, cleanCtx, predeclaredOutputs)
507517
}
508518
catch (error) {
509519
if (error instanceof CleanupProtectionConflictError) {

cli/src/commands/ExecuteCommand.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {Command, CommandContext, CommandResult} from './Command'
22
import {
3+
collectOutputDeclarations,
34
executeDeclarativeWriteOutputs
45
} from '../plugins/plugin-core'
56
import {performCleanup} from './CleanupUtils'
@@ -15,8 +16,10 @@ export class ExecuteCommand implements Command {
1516
const {logger, outputPlugins, createCleanContext, createWriteContext} = ctx
1617
logger.info('started', {command: 'execute'})
1718

19+
const writeCtx = createWriteContext(false)
20+
const predeclaredOutputs = await collectOutputDeclarations(outputPlugins, writeCtx)
1821
const cleanCtx = createCleanContext(false) // Step 1: Pre-cleanup (non-dry-run only)
19-
const cleanupResult = await performCleanup(outputPlugins, cleanCtx, logger)
22+
const cleanupResult = await performCleanup(outputPlugins, cleanCtx, logger, predeclaredOutputs)
2023

2124
if (cleanupResult.violations.length > 0 || cleanupResult.conflicts.length > 0) {
2225
return {
@@ -29,8 +32,7 @@ export class ExecuteCommand implements Command {
2932

3033
logger.info('cleanup complete', {deletedFiles: cleanupResult.deletedFiles, deletedDirs: cleanupResult.deletedDirs})
3134

32-
const writeCtx = createWriteContext(false) // Step 2: Write outputs
33-
const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx)
35+
const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx, predeclaredOutputs) // Step 2: Write outputs
3436

3537
let totalFiles = 0
3638
let totalDirs = 0

cli/src/commands/ProtectedDeletionCommands.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ function createMockOutputPlugin(
4444
}
4545
}
4646

47-
function createCommandContext(outputPlugins: readonly OutputPlugin[]): CommandContext {
48-
const workspaceDir = path.resolve('tmp-workspace-command')
47+
function createCommandContext(
48+
outputPlugins: readonly OutputPlugin[],
49+
workspaceDir: string = path.resolve('tmp-workspace-command')
50+
): CommandContext {
4951
const aindexDir = path.join(workspaceDir, 'aindex')
5052
const collectedOutputContext = {
5153
workspace: {
@@ -180,6 +182,44 @@ describe('protected deletion commands', () => {
180182
}))
181183
})
182184

185+
it('reuses declared outputs across cleanup and write during execute', async () => {
186+
const workspaceDir = path.resolve('tmp-workspace-command-cached')
187+
const outputPath = path.join(workspaceDir, 'project-a', 'AGENTS.md')
188+
let declareOutputFilesCalls = 0
189+
const plugin: OutputPlugin = {
190+
type: PluginKind.Output,
191+
name: 'CachedOutputPlugin',
192+
log: createMockLogger(),
193+
declarativeOutput: true,
194+
outputCapabilities: {},
195+
async declareOutputFiles() {
196+
declareOutputFilesCalls += 1
197+
return [{path: outputPath, source: {}}]
198+
},
199+
async declareCleanupPaths() {
200+
return {}
201+
},
202+
async convertContent() {
203+
return 'cached-output'
204+
}
205+
}
206+
207+
fs.rmSync(workspaceDir, {recursive: true, force: true})
208+
fs.mkdirSync(path.join(workspaceDir, 'project-a'), {recursive: true})
209+
210+
try {
211+
const ctx = createCommandContext([plugin], workspaceDir)
212+
const result = await new ExecuteCommand().execute(ctx)
213+
214+
expect(result.success).toBe(true)
215+
expect(declareOutputFilesCalls).toBe(1)
216+
expect(fs.readFileSync(outputPath, 'utf8')).toBe('cached-output')
217+
}
218+
finally {
219+
fs.rmSync(workspaceDir, {recursive: true, force: true})
220+
}
221+
})
222+
183223
it('includes structured diagnostics in JSON output errors', async () => {
184224
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
185225
const command = new JsonOutputCommand({

cli/src/inputs/input-agentskills.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,7 @@ async function createSkillPrompt(
628628
) // Merge fallback export parsing with compiled metadata so empty metadata objects do not mask valid fields
629629

630630
const authoredNames = new Set<string>()
631-
const yamlName = parsed?.yamlFrontMatter?.['name']
631+
const yamlName = parsed?.yamlFrontMatter?.name
632632
if (typeof yamlName === 'string' && yamlName.trim().length > 0) authoredNames.add(yamlName)
633633
const exportedName = exportMetadata.name
634634
if (typeof exportedName === 'string' && exportedName.trim().length > 0) authoredNames.add(exportedName)

cli/src/plugins/plugin-core/plugin.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -389,20 +389,33 @@ export function validateOutputScopeOverridesForPlugins(
389389
}
390390
}
391391

392+
export async function collectOutputDeclarations(
393+
plugins: readonly OutputPlugin[],
394+
ctx: OutputWriteContext
395+
): Promise<Map<OutputPlugin, readonly OutputFileDeclaration[]>> {
396+
validateOutputScopeOverridesForPlugins(plugins, ctx.pluginOptions)
397+
398+
const declarationEntries = await Promise.all(
399+
plugins.map(async plugin => [plugin, await plugin.declareOutputFiles(ctx)] as const)
400+
)
401+
402+
return new Map(declarationEntries)
403+
}
404+
392405
/**
393406
* Execute declarative write operations for output plugins.
394407
* Core runtime owns file system writes; plugins only declare and convert content.
395408
*/
396409
export async function executeDeclarativeWriteOutputs(
397410
plugins: readonly OutputPlugin[],
398-
ctx: OutputWriteContext
411+
ctx: OutputWriteContext,
412+
predeclaredOutputs?: ReadonlyMap<OutputPlugin, readonly OutputFileDeclaration[]>
399413
): Promise<Map<string, WriteResults>> {
400414
const results = new Map<string, WriteResults>()
401-
402-
validateOutputScopeOverridesForPlugins(plugins, ctx.pluginOptions)
415+
const outputDeclarations = predeclaredOutputs ?? await collectOutputDeclarations(plugins, ctx)
403416

404417
for (const plugin of plugins) {
405-
const declarations = await plugin.declareOutputFiles(ctx)
418+
const declarations = outputDeclarations.get(plugin) ?? []
406419
const fileResults: WriteResult[] = []
407420

408421
for (const declaration of declarations) {
@@ -464,18 +477,20 @@ export interface CollectedOutputs {
464477
*/
465478
export async function collectAllPluginOutputs(
466479
plugins: readonly OutputPlugin[],
467-
ctx: OutputPluginContext
480+
ctx: OutputPluginContext,
481+
predeclaredOutputs?: ReadonlyMap<OutputPlugin, readonly OutputFileDeclaration[]>
468482
): Promise<CollectedOutputs> {
469483
const projectDirs: string[] = []
470484
const projectFiles: string[] = []
471485
const globalDirs: string[] = []
472486
const globalFiles: string[] = []
473487

474-
validateOutputScopeOverridesForPlugins(plugins, ctx.pluginOptions)
475-
476-
const declarationGroups = await Promise.all(
477-
plugins.map(async plugin => plugin.declareOutputFiles({...ctx, dryRun: true}))
478-
)
488+
const declarationGroups = predeclaredOutputs != null
489+
? [...predeclaredOutputs.values()]
490+
: Array.from(
491+
await collectOutputDeclarations(plugins, {...ctx, dryRun: true}),
492+
([, declarations]) => declarations
493+
)
479494

480495
for (const declarations of declarationGroups) {
481496
for (const declaration of declarations) {

0 commit comments

Comments
 (0)