From bac9fc29bb580dbf612d38e4b52b195ecddb0bf6 Mon Sep 17 00:00:00 2001 From: Jaime Wren Date: Thu, 5 Mar 2026 08:32:29 -0800 Subject: [PATCH 1/3] Add --dry-run flag Add --dry-run flag to generate, update, and validate commands to allow previewing fetched resources and configurations without triggering Gemini API calls or file modifications. --- tool/lib/src/commands/base_skill_command.dart | 20 +++-- .../src/commands/generate_skill_command.dart | 10 +++ .../src/commands/update_skill_command.dart | 10 +++ .../src/commands/validate_skill_command.dart | 10 +++ tool/test/generate_skills_test.dart | 89 ++++++++++++++++--- tool/test/update_skills_test.dart | 63 +++++++++++++ tool/test/validate_skills_test.dart | 66 ++++++++++++++ 7 files changed, 248 insertions(+), 20 deletions(-) diff --git a/tool/lib/src/commands/base_skill_command.dart b/tool/lib/src/commands/base_skill_command.dart index 98b61a4b..52ca0ad7 100644 --- a/tool/lib/src/commands/base_skill_command.dart +++ b/tool/lib/src/commands/base_skill_command.dart @@ -19,12 +19,20 @@ abstract class BaseSkillCommand extends BaseYamlCommand { super.outputDir, this.environment, }) { - argParser.addOption( - 'thinking-budget', - help: - 'The token budget for the model to "think". Defaults to ${GeminiService.defaultThinkingBudget} (recommended for technical documentation).', - defaultsTo: GeminiService.defaultThinkingBudget.toString(), - ); + argParser + ..addOption( + 'thinking-budget', + help: + 'The token budget for the model to "think". Defaults to ${GeminiService.defaultThinkingBudget} (recommended for technical documentation).', + defaultsTo: GeminiService.defaultThinkingBudget.toString(), + ) + ..addFlag( + 'dry-run', + abbr: 'n', + help: + 'Simulate the command without making API calls or modifying files.', + negatable: false, + ); } /// The HTTP client used for fetching resources. diff --git a/tool/lib/src/commands/generate_skill_command.dart b/tool/lib/src/commands/generate_skill_command.dart index 1b2b5732..0a0eb2e8 100644 --- a/tool/lib/src/commands/generate_skill_command.dart +++ b/tool/lib/src/commands/generate_skill_command.dart @@ -61,6 +61,16 @@ class GenerateSkillCommand extends BaseSkillCommand { return; } + final dryRun = argResults?['dry-run'] as bool? ?? false; + if (dryRun) { + logger + ..info(' [DRY RUN] Would generate skill: ${skill.name}') + ..info( + ' [DRY RUN] Prompt size: ${combinedMarkdown.length} characters.', + ); + return; + } + final generatedContent = await gemini.generateSkillContent( combinedMarkdown, skill.name, diff --git a/tool/lib/src/commands/update_skill_command.dart b/tool/lib/src/commands/update_skill_command.dart index 0f76d289..4ddb92b4 100644 --- a/tool/lib/src/commands/update_skill_command.dart +++ b/tool/lib/src/commands/update_skill_command.dart @@ -72,6 +72,16 @@ class UpdateSkillCommand extends BaseSkillCommand { return; } + final dryRun = argResults?['dry-run'] as bool? ?? false; + if (dryRun) { + logger + ..info(' [DRY RUN] Would update skill: ${skill.name}') + ..info( + ' [DRY RUN] Original file size: ${existingContent.length} characters -> Raw content size: ${combinedMarkdown.length} characters.', + ); + return; + } + final generatedContent = await gemini.updateSkillContent( existingContent, combinedMarkdown, diff --git a/tool/lib/src/commands/validate_skill_command.dart b/tool/lib/src/commands/validate_skill_command.dart index 893dbd31..ca174af5 100644 --- a/tool/lib/src/commands/validate_skill_command.dart +++ b/tool/lib/src/commands/validate_skill_command.dart @@ -103,6 +103,16 @@ class ValidateSkillCommand extends BaseSkillCommand { 'Unknown'; // Compare + final dryRun = argResults?['dry-run'] as bool? ?? false; + if (dryRun) { + logger + ..info(' [DRY RUN] Would validate skill: ${skill.name}') + ..info( + ' [DRY RUN] existing file size: ${existingSkillFileContent.length} characters -> new fetched content size: ${markdown.length} characters.', + ); + return; + } + logger.info(' Comparing versions...'); final result = await gemini.validateExistingSkillContent( markdown, diff --git a/tool/test/generate_skills_test.dart b/tool/test/generate_skills_test.dart index 24259921..0df99dea 100644 --- a/tool/test/generate_skills_test.dart +++ b/tool/test/generate_skills_test.dart @@ -17,11 +17,11 @@ void main() { group('GenerateSkillsCommand', () { late CommandRunner runner; late Directory tempDir; - late File videoFile; + late File inputYamlFile; setUp(() async { tempDir = await Directory.systemTemp.createTemp('skills_gen_test'); - videoFile = File(p.join(tempDir.path, 'input.yaml')); + inputYamlFile = File(p.join(tempDir.path, 'input.yaml')); runner = CommandRunner('skills', 'Test runner'); }); @@ -31,7 +31,7 @@ void main() { test('generates skill from YAML input with dart-docs- prefix', () async { // Create input YAML in a file named dart_dev.yaml to trigger prefixing - videoFile = File(p.join(tempDir.path, 'dart_dev.yaml')); + inputYamlFile = File(p.join(tempDir.path, 'dart_dev.yaml')); final inputData = [ { 'name': 'foo', @@ -44,7 +44,7 @@ void main() { 'resources': ['https://example.com/'], }, ]; - videoFile.writeAsStringSync(jsonEncode(inputData)); + inputYamlFile.writeAsStringSync(jsonEncode(inputData)); final geminiRequests = []; // Mock HTTP Client @@ -90,7 +90,7 @@ void main() { runner.addCommand(command); // Run command - await runner.run(['generate-skill', videoFile.path]); + await runner.run(['generate-skill', inputYamlFile.path]); // Just verify file creation for now final skillDirFoo = Directory(p.join(tempDir.path, 'foo')); @@ -108,7 +108,7 @@ void main() { }); test('logs progress and summary', () async { - videoFile = File(p.join(tempDir.path, 'dart_dev.yaml')); + inputYamlFile = File(p.join(tempDir.path, 'dart_dev.yaml')); final inputData = [ { 'name': 'success', @@ -121,7 +121,7 @@ void main() { 'resources': ['https://example.com/fail_404'], }, ]; - videoFile.writeAsStringSync(jsonEncode(inputData)); + inputYamlFile.writeAsStringSync(jsonEncode(inputData)); final logs = []; final sub = Logger.root.onRecord.listen((record) { @@ -165,7 +165,7 @@ void main() { ); runner.addCommand(command); - await runner.run(['generate-skill', videoFile.path]); + await runner.run(['generate-skill', inputYamlFile.path]); // Verify Logs expect(logs, contains(contains('Generating skill: success...'))); @@ -337,7 +337,7 @@ void main() { 'resources': [], }, ]; - final videoFile = File(p.join(tempDir.path, 'empty_fetch.yaml')) + final inputYamlFile = File(p.join(tempDir.path, 'empty_fetch.yaml')) ..writeAsStringSync(jsonEncode(inputData)); final logs = []; @@ -357,7 +357,7 @@ void main() { ); runner.addCommand(command); - await runner.run(['generate-skill', videoFile.path]); + await runner.run(['generate-skill', inputYamlFile.path]); expect( logs, contains(' No content fetched for empty-fetch. Skipping.'), @@ -373,7 +373,7 @@ void main() { 'resources': ['https://example.com/source'], }, ]; - final videoFile = File(p.join(tempDir.path, 'empty_gemini.yaml')) + final inputYamlFile = File(p.join(tempDir.path, 'empty_gemini.yaml')) ..writeAsStringSync(jsonEncode(inputData)); final logs = []; @@ -412,7 +412,7 @@ void main() { ); runner.addCommand(command); - await runner.run(['generate-skill', videoFile.path]); + await runner.run(['generate-skill', inputYamlFile.path]); expect(logs, contains(' Failed to generate content for empty-gemini')); }); @@ -424,7 +424,7 @@ void main() { 'resources': ['https://example.com/source'], }, ]; - final videoFile = File(p.join(tempDir.path, 'exception_gemini.yaml')) + final inputYamlFile = File(p.join(tempDir.path, 'exception_gemini.yaml')) ..writeAsStringSync(jsonEncode(inputData)); final logs = []; @@ -444,7 +444,7 @@ void main() { ); runner.addCommand(command); - await runner.run(['generate-skill', videoFile.path]); + await runner.run(['generate-skill', inputYamlFile.path]); expect( logs, contains( @@ -454,5 +454,66 @@ void main() { ), ); }); + + test('dry run fetches content but skips Gemini and file writes', () async { + final inputData = [ + { + 'name': 'dry-run-skill', + 'description': 'Description', + 'resources': ['https://example.com/source'], + }, + ]; + final inputYamlFile = File(p.join(tempDir.path, 'dry_run.yaml')) + ..writeAsStringSync(jsonEncode(inputData)); + + final logs = []; + final sub = Logger.root.onRecord.listen( + (record) => logs.add(record.message), + ); + addTearDown(sub.cancel); + + var geminiCalled = false; + final mockClient = MockClient((request) async { + if (request.url.toString() == 'https://example.com/source') { + return http.Response('Content', 200); + } + if (request.url.toString().contains('generativelanguage')) { + geminiCalled = true; + return http.Response( + jsonEncode({ + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'Gen'}, + ], + }, + }, + ], + }), + 200, + ); + } + return http.Response('Error', 500); + }); + + final command = GenerateSkillCommand( + environment: {'GEMINI_API_KEY': 'test-key'}, + httpClient: mockClient, + outputDir: tempDir, + ); + runner.addCommand(command); + + await runner.run(['generate-skill', '--dry-run', inputYamlFile.path]); + + expect(geminiCalled, isFalse); + expect( + logs, + contains(contains('[DRY RUN] Would generate skill: dry-run-skill')), + ); + + final skillDir = Directory(p.join(tempDir.path, 'dry-run-skill')); + expect(skillDir.existsSync(), isFalse); + }); }); } diff --git a/tool/test/update_skills_test.dart b/tool/test/update_skills_test.dart index 78f7e772..d1f8ce48 100644 --- a/tool/test/update_skills_test.dart +++ b/tool/test/update_skills_test.dart @@ -174,5 +174,68 @@ void main() { ); }, ); + + test('dry run fetches content but skips Gemini and file writes', () async { + final inputData = [ + { + 'name': 'dry-update', + 'description': 'Description', + 'resources': ['https://example.com/source'], + }, + ]; + inputYamlFile.writeAsStringSync(jsonEncode(inputData)); + + final skillDir = Directory(p.join(tempDir.path, 'dry-update')) + ..createSync(); + final skillFile = File(p.join(skillDir.path, 'SKILL.md')) + ..writeAsStringSync('Existing content'); + + final logs = []; + final sub = Logger.root.onRecord.listen((record) { + logs.add(record.message); + }); + addTearDown(sub.cancel); + + var geminiCalled = false; + final mockClient = MockClient((request) async { + if (request.url.toString() == 'https://example.com/source') { + return http.Response('New Content', 200); + } + if (request.url.toString().contains('generativelanguage')) { + geminiCalled = true; + return http.Response( + jsonEncode({ + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'Gen'}, + ], + }, + }, + ], + }), + 200, + ); + } + return http.Response('Error', 500); + }); + + final command = UpdateSkillCommand( + environment: {'GEMINI_API_KEY': 'test-key'}, + httpClient: mockClient, + outputDir: tempDir, + ); + runner.addCommand(command); + + await runner.run(['update-skill', '--dry-run', inputYamlFile.path]); + + expect(geminiCalled, isFalse); + expect( + logs, + contains(contains('[DRY RUN] Would update skill: dry-update')), + ); + expect(skillFile.readAsStringSync(), equals('Existing content')); + }); }); } diff --git a/tool/test/validate_skills_test.dart b/tool/test/validate_skills_test.dart index 470c555a..54e8e069 100644 --- a/tool/test/validate_skills_test.dart +++ b/tool/test/validate_skills_test.dart @@ -664,5 +664,71 @@ Content await runner.run(['validate-skill', configFile.path]); expect(logs, contains(contains('Validation report written to'))); }); + + test('dry run fetches content but skips gemini and file writes', () async { + const skillName = 'dry-validate'; + final skillDir = Directory(p.join(skillsDir.path, skillName)); + await skillDir.create(); + File( + p.join(skillDir.path, 'SKILL.md'), + ).writeAsStringSync('name: $skillName\nExisting content'); + + final configFile = File(p.join(tempDir.path, 'config.yaml')); + await configFile.writeAsString( + jsonEncode([ + { + 'name': skillName, + 'description': 'Desc', + 'resources': ['https://example.com/source'], + }, + ]), + ); + + var geminiCalled = false; + final mockClient = MockClient((request) async { + if (request.url.toString() == 'https://example.com/source') { + return http.Response('# Source', 200); + } + if (request.url.toString().contains('generativelanguage')) { + geminiCalled = true; + return http.Response( + jsonEncode({ + 'candidates': [ + { + 'content': { + 'parts': [ + {'text': 'Generated Content\nGrade: 100'}, + ], + }, + }, + ], + }), + 200, + ); + } + return http.Response('Not Found', 404); + }); + + runner = CommandRunner('skills', 'Test runner') + ..addCommand( + ValidateSkillCommand( + environment: {'GEMINI_API_KEY': 'test-key'}, + outputDir: skillsDir, + validationDir: validationDir, + httpClient: mockClient, + ), + ); + + await runner.run(['validate-skill', '--dry-run', configFile.path]); + + expect(geminiCalled, isFalse); + expect( + logs, + contains(contains('[DRY RUN] Would validate skill: $skillName')), + ); + + final valDir = Directory(p.join(validationDir.path, skillName)); + expect(valDir.existsSync(), isFalse); + }); }); } From 06a0be1beca0f02a635dd5e37de59d4309e00d5d Mon Sep 17 00:00:00 2001 From: Jaime Wren Date: Mon, 9 Mar 2026 23:33:47 -0700 Subject: [PATCH 2/3] Fix analyzer warnings in update_readme_command.dart --- tool/lib/src/commands/update_readme_command.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tool/lib/src/commands/update_readme_command.dart b/tool/lib/src/commands/update_readme_command.dart index c23d6005..1126881e 100644 --- a/tool/lib/src/commands/update_readme_command.dart +++ b/tool/lib/src/commands/update_readme_command.dart @@ -45,9 +45,9 @@ class UpdateReadmeCommand extends BaseYamlCommand { final content = readmeFile.readAsStringSync(); // Generate the table - final buffer = StringBuffer(); - buffer.writeln('| Skill | Description |'); - buffer.writeln('|---|---|'); + final buffer = StringBuffer() + ..writeln('| Skill | Description |') + ..writeln('|---|---|'); // Sort skills by name for consistency final sortedSkills = List.from(skills) @@ -70,7 +70,7 @@ class UpdateReadmeCommand extends BaseYamlCommand { // Find where to insert the table final sectionRegex = RegExp( - r'^## (Available Skills|List of Skills|Skills List|Skill Index)', + '^## (Available Skills|List of Skills|Skills List|Skill Index)', caseSensitive: false, multiLine: true, ); From fb060bff46153da55f5428bdc791877c3a7ab803 Mon Sep 17 00:00:00 2001 From: Jaime Wren Date: Mon, 9 Mar 2026 23:41:10 -0700 Subject: [PATCH 3/3] feat: report dry run sizes in tokens instead of characters --- tool/lib/src/commands/generate_skill_command.dart | 2 +- tool/lib/src/commands/update_skill_command.dart | 2 +- tool/lib/src/commands/validate_skill_command.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tool/lib/src/commands/generate_skill_command.dart b/tool/lib/src/commands/generate_skill_command.dart index 0a0eb2e8..6642f977 100644 --- a/tool/lib/src/commands/generate_skill_command.dart +++ b/tool/lib/src/commands/generate_skill_command.dart @@ -66,7 +66,7 @@ class GenerateSkillCommand extends BaseSkillCommand { logger ..info(' [DRY RUN] Would generate skill: ${skill.name}') ..info( - ' [DRY RUN] Prompt size: ${combinedMarkdown.length} characters.', + ' [DRY RUN] Prompt size: ${combinedMarkdown.split(' ').length} tokens.', ); return; } diff --git a/tool/lib/src/commands/update_skill_command.dart b/tool/lib/src/commands/update_skill_command.dart index 4ddb92b4..b688c0d5 100644 --- a/tool/lib/src/commands/update_skill_command.dart +++ b/tool/lib/src/commands/update_skill_command.dart @@ -77,7 +77,7 @@ class UpdateSkillCommand extends BaseSkillCommand { logger ..info(' [DRY RUN] Would update skill: ${skill.name}') ..info( - ' [DRY RUN] Original file size: ${existingContent.length} characters -> Raw content size: ${combinedMarkdown.length} characters.', + ' [DRY RUN] Original file size: ${existingContent.split(' ').length} tokens -> Raw content size: ${combinedMarkdown.split(' ').length} tokens.', ); return; } diff --git a/tool/lib/src/commands/validate_skill_command.dart b/tool/lib/src/commands/validate_skill_command.dart index ca174af5..0df5cc5e 100644 --- a/tool/lib/src/commands/validate_skill_command.dart +++ b/tool/lib/src/commands/validate_skill_command.dart @@ -108,7 +108,7 @@ class ValidateSkillCommand extends BaseSkillCommand { logger ..info(' [DRY RUN] Would validate skill: ${skill.name}') ..info( - ' [DRY RUN] existing file size: ${existingSkillFileContent.length} characters -> new fetched content size: ${markdown.length} characters.', + ' [DRY RUN] existing file size: ${existingSkillFileContent.split(' ').length} tokens -> new fetched content size: ${markdown.split(' ').length} tokens.', ); return; }