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
7 changes: 4 additions & 3 deletions tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ This tool requires the `GEMINI_API_KEY` environment variable to be set.

### `generate-skill`

Generates `SKILL.md` files from a JSON configuration file. Use the `--skill` option to generate a specific skill.
Generates `SKILL.md` files from a YAML configuration file. Use the `--skill` option to generate a specific skill.

**Usage:**
```bash
dart run skills generate-skill [options] [config_file]
```

**Arguments:**
* `[config_file]`: Path to the JSON configuration file. Defaults to `resources/flutter_skills.yaml`.
* `[config_file]`: Path to the YAML configuration file. Defaults to `resources/flutter_skills.yaml`.

**Options:**
* `--skill`: Filter to generate only the specified skill by name.
Expand All @@ -38,7 +38,7 @@ dart run skills validate-skill [options] [config_file]
```

**Arguments:**
* `[config_file]`: Path to the JSON configuration file. Defaults to `resources/flutter_skills.json`.
* `[config_file]`: Path to the YAML configuration file. Defaults to `resources/flutter_skills.yaml`.

**Options:**
* `--skill`: Validate only the specified skill by name.
Expand Down Expand Up @@ -92,4 +92,5 @@ The default configuration file is located at `tool/resources/flutter_skills.yaml
resources:
- https://docs.flutter.dev/ui/widgets/layout
- https://docs.flutter.dev/ui/layout
- ../packages/flutter/lib/src/widgets/layout.md
```
19 changes: 15 additions & 4 deletions tool/lib/src/commands/base_skill_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ abstract class BaseSkillCommand extends Command {
required this.httpClient,
required this.logger,
this.outputDir,
this.environment,
}) {
argParser
..addOption('skill', help: 'Process only the specified skill by name.')
Expand All @@ -42,6 +43,9 @@ abstract class BaseSkillCommand extends Command {
/// The directory to output or find generated skills.
final Directory? outputDir;

/// Optional override for the environment variables, for testing.
final Map<String, String>? environment;

/// The logger for this command.
final Logger logger;

Expand Down Expand Up @@ -77,7 +81,7 @@ abstract class BaseSkillCommand extends Command {
return;
}

final apiKey = Platform.environment['GEMINI_API_KEY'];
final apiKey = (environment ?? Platform.environment)['GEMINI_API_KEY'];
if (apiKey == null) {
logger.severe('GEMINI_API_KEY environment variable not set.');
return;
Expand All @@ -100,7 +104,13 @@ abstract class BaseSkillCommand extends Command {
}

for (final skill in targetSkills) {
await runSkill(skill, gemini, outDir, thinkingBudget);
await runSkill(
skill,
gemini,
outDir,
thinkingBudget,
configDir: file.parent,
);
}
}

Expand All @@ -109,6 +119,7 @@ abstract class BaseSkillCommand extends Command {
SkillParams skill,
GeminiService gemini,
Directory outputDir,
int thinkingBudget,
);
int thinkingBudget, {
Directory? configDir,
});
}
8 changes: 5 additions & 3 deletions tool/lib/src/commands/generate_skill_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@ class GenerateSkillCommand extends BaseSkillCommand {
SkillParams skill,
GeminiService gemini,
Directory outputDir,
int thinkingBudget,
) async {
int thinkingBudget, {
Directory? configDir,
}) async {
logger.info('Generating skill: ${skill.name}...');

try {
final combinedMarkdown = await fetchAndConvertContent(
skill.resources,
httpClient,
logger,
configDir: configDir,
);

if (combinedMarkdown.isEmpty) {
Expand All @@ -49,7 +51,7 @@ class GenerateSkillCommand extends BaseSkillCommand {
skill.name,
skill.description,
instructions: skill.instructions,
urls: skill.resources,
resources: skill.resources,
thinkingBudget: thinkingBudget,
);

Expand Down
6 changes: 4 additions & 2 deletions tool/lib/src/commands/validate_skill_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ class ValidateSkillCommand extends BaseSkillCommand {
SkillParams skill,
GeminiService gemini,
Directory outputDir,
int thinkingBudget,
) async {
int thinkingBudget, {
Directory? configDir,
}) async {
logger.info('Validating skill: ${skill.name}...');

try {
Expand All @@ -45,6 +46,7 @@ class ValidateSkillCommand extends BaseSkillCommand {
skill.resources,
httpClient,
logger,
configDir: configDir,
);

if (markdown.isEmpty) {
Expand Down
4 changes: 2 additions & 2 deletions tool/lib/src/models/skill_params.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// Parameters for generating a skill from a URL.
/// Parameters for generating a skill from resources.
class SkillParams {
/// Creates a new [SkillParams] instance.
SkillParams({
Expand Down Expand Up @@ -31,6 +31,6 @@ class SkillParams {
/// Optional instructions for generating the skill.
final String? instructions;

/// The resources/URLs to fetch content from.
/// The resources to fetch content from.
final List<String> resources;
}
84 changes: 53 additions & 31 deletions tool/lib/src/services/gemini_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';
import 'dart:io' as io;

import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:retry/retry.dart';
import 'package:yaml_writer/yaml_writer.dart';

Expand Down Expand Up @@ -66,11 +67,11 @@ class GeminiService {
String skillName,
String description, {
String? instructions,
List<String> urls = const [],
List<String> resources = const [],
int thinkingBudget = defaultThinkingBudget,
}) async {
final service = GenerativeService(client: _client);
final lastModified = HttpDate.format(DateTime.now());
final lastModified = io.HttpDate.format(DateTime.now());
final prompt = _createSkillPrompt(rawMarkdown, instructions);

final request = _createRequest(
Expand Down Expand Up @@ -115,7 +116,7 @@ class GeminiService {
'name': skillName,
'description': description,
'metadata': {
'urls': urls,
'resources': resources,
'model': _model,
'last_modified': lastModified,
},
Expand Down Expand Up @@ -293,18 +294,6 @@ $markdown
}
}

/// Result of a skill validation.
class ValidationResult {
/// Creates a new [ValidationResult].
ValidationResult(this.report, this.score);

/// The markdown validation report.
final String report;

/// The similarity score (0-100).
final int score;
}

class _ApiKeyClient extends http.BaseClient {
_ApiKeyClient(this._inner, this._apiKey);

Expand All @@ -318,31 +307,64 @@ class _ApiKeyClient extends http.BaseClient {
}
}

/// Fetches and converts content from a list of URLs.
/// Fetches and converts content from a list of resources.
///
/// Throws an [Exception] if fetching any URL fails. This strict behavior
/// Throws an [Exception] if fetching any resource fails. This strict behavior
/// prevents wasting Gemini tokens on generating low-quality skills when
/// source material is missing.
Future<String> fetchAndConvertContent(
List<String> urls,
List<String> resources,
http.Client httpClient,
Logger logger,
) async {
Logger logger, {
io.Directory? configDir,
}) async {
final converter = MarkdownConverter();
final sb = StringBuffer();
for (final url in urls) {
logger.info(' Fetching $url...');
final response = await httpClient.get(Uri.parse(url));
if (response.statusCode == 200) {
sb
..writeln('--- Raw content from $url ---')
..writeln(converter.convert(response.body));
} else {
for (final resource in resources) {
logger.info(' Fetching $resource...');

if (resource.startsWith('http://')) {
throw Exception(
'Failed to fetch $url: HTTP ${response.statusCode}. '
'Failing fast to save Gemini tokens.',
'Insecure HTTP URL found: $resource. '
'Only HTTPS URLs or relative file paths are allowed.',
);
}

if (resource.startsWith('https://')) {
final response = await httpClient.get(Uri.parse(resource));
if (response.statusCode == 200) {
sb
..writeln('--- Raw content from $resource ---')
..writeln(converter.convert(response.body));
} else {
throw Exception(
'Failed to fetch $resource: HTTP ${response.statusCode}. '
'Failing fast to save Gemini tokens.',
);
}
} else {
if (configDir == null) {
throw Exception(
'Relative resource "$resource" found, but no configuration '
'directory was provided to resolve it.',
);
}
final file = io.File(p.join(configDir.path, resource));
if (!file.existsSync()) {
throw Exception('Local resource file not found: ${file.path}');
}

final String content;
try {
content = file.readAsStringSync();
} on io.FileSystemException {
throw Exception('Local resource file is not readable: ${file.path}');
}

sb
..writeln('--- Raw content from $resource ---')
..writeln(content);
}
}
return sb.toString();
}
Loading