-
-
Notifications
You must be signed in to change notification settings - Fork 104
Feat/dart native skills install #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Pratikdate
wants to merge
11
commits into
StacDev:dev
Choose a base branch
from
Pratikdate:feat/dart-native-skills-install
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
8f8f208
feat(stac_cli): add unit testing infrastructure and core command tests
Pratikdate 4db325e
docs(test): add descriptive comments and improve reliability of CLI u…
Pratikdate 84f4abe
Merge branch 'StacDev:dev' into dev
Pratikdate cdda08c
style(stac_cli): fix formatting in test files
Pratikdate 6d422b8
Merge branch 'dev' of https://github.com/Pratikdate/stac into dev
Pratikdate f48feea
Merge branch 'dev' into dev
divyanshub024 0e2a6e1
test(cli): add environment teardown and validate home/config director…
Pratikdate 4d50d4f
Merge branch 'dev' into dev
Pratikdate dd01270
feat: add Dart/Flutter-native install path for Stac skills
Pratikdate 30d4eb6
fix: address CodeRabbit review issues in skills install
Pratikdate 533b66c
feat: Add catalog entry validation, path containment checks, and copy…
Pratikdate File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
249 changes: 249 additions & 0 deletions
249
packages/stac_cli/lib/src/commands/skills/add_command.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,249 @@ | ||
| import 'dart:convert'; | ||
| import 'dart:io'; | ||
|
|
||
| import 'package:archive/archive_io.dart'; | ||
| import 'package:dio/dio.dart'; | ||
| import 'package:path/path.dart' as path; | ||
|
|
||
| import '../../utils/console_logger.dart'; | ||
| import '../base_command.dart'; | ||
|
|
||
| /// Command to add Stac AI agent skills | ||
| class AddCommand extends BaseCommand { | ||
| @override | ||
| String get name => 'add'; | ||
|
|
||
| @override | ||
| String get description => 'Add Stac AI agent skills to your project'; | ||
|
|
||
| @override | ||
| bool get requiresAuth => false; | ||
|
|
||
| /// Optional target directory; defaults to [Directory.current]. | ||
| final String? targetDirectory; | ||
|
|
||
| AddCommand({this.targetDirectory}); | ||
|
|
||
| @override | ||
| Future<int> execute() async { | ||
| String repoUrl = 'https://github.com/StacDev/stac'; | ||
|
|
||
| if (argResults?.rest.isNotEmpty == true) { | ||
| repoUrl = argResults!.rest.first; | ||
| } | ||
|
|
||
| if (!repoUrl.contains('github.com')) { | ||
| ConsoleLogger.error('Currently only github.com URLs are supported.'); | ||
| return 1; | ||
| } | ||
|
|
||
| // Extract owner/repo | ||
| final uri = Uri.parse(repoUrl); | ||
| final segments = uri.pathSegments; | ||
| if (segments.length < 2) { | ||
| ConsoleLogger.error('Invalid GitHub URL format.'); | ||
| return 1; | ||
| } | ||
|
|
||
| final owner = segments[0]; | ||
| final repo = segments[1].replaceAll('.git', ''); | ||
|
|
||
| final zipUrl = 'https://github.com/$owner/$repo/archive/HEAD.zip'; | ||
|
|
||
| ConsoleLogger.info('Fetching skills from $repoUrl...'); | ||
|
|
||
| final tempDir = await Directory.systemTemp.createTemp('stac_skills_'); | ||
| try { | ||
| final dio = Dio(); | ||
| final zipFile = File(path.join(tempDir.path, 'repo.zip')); | ||
|
|
||
| await dio.download(zipUrl, zipFile.path); | ||
|
|
||
| // Extract ZIP | ||
| final archive = ZipDecoder().decodeBytes(zipFile.readAsBytesSync()); | ||
| final extractDir = Directory(path.join(tempDir.path, 'extracted')); | ||
| extractArchiveToDisk(archive, extractDir.path); | ||
|
|
||
| // Find skills/catalog.json | ||
| // The extracted folder usually has a root folder named <repo>-<branch> | ||
| final rootDirs = extractDir.listSync().whereType<Directory>().toList(); | ||
| if (rootDirs.isEmpty) { | ||
| ConsoleLogger.error('Empty repository archive.'); | ||
| return 1; | ||
| } | ||
|
|
||
| final repoRoot = rootDirs.first; | ||
| ConsoleLogger.info('Extracted root: ${repoRoot.path}'); | ||
|
|
||
| final catalogFile = File( | ||
| path.join(repoRoot.path, 'skills', 'catalog.json'), | ||
| ); | ||
| ConsoleLogger.info('Looking for catalog at: ${catalogFile.path}'); | ||
|
|
||
| if (!await catalogFile.exists()) { | ||
| ConsoleLogger.error('skills/catalog.json not found in repository.'); | ||
|
|
||
| ConsoleLogger.info('Contents of extracted:'); | ||
| for (var e in extractDir.listSync(recursive: true)) { | ||
| ConsoleLogger.info(e.path); | ||
| } | ||
|
|
||
| return 1; | ||
| } | ||
|
|
||
| // Parse catalog.json | ||
| final catalogContent = await catalogFile.readAsString(); | ||
| final List<dynamic> catalog = jsonDecode(catalogContent); | ||
|
|
||
| final installDir = targetDirectory ?? Directory.current.path; | ||
| final targetAgentsDir = Directory( | ||
| path.join(installDir, '.agents', 'skills'), | ||
| ); | ||
| if (!await targetAgentsDir.exists()) { | ||
| await targetAgentsDir.create(recursive: true); | ||
| } | ||
|
|
||
| // Canonical boundary paths for security checks | ||
| final repoRootCanonical = path.canonicalize(repoRoot.path); | ||
| final targetCanonical = path.canonicalize(targetAgentsDir.path); | ||
|
|
||
| int installedCount = 0; | ||
| for (final skill in catalog) { | ||
| if (skill is! Map) { | ||
| ConsoleLogger.warning('Skipping invalid catalog entry (not a map): $skill'); | ||
| continue; | ||
| } | ||
| final skillName = skill['name']; | ||
| final skillPath = skill['path']; | ||
|
|
||
| if (skillName is! String || skillPath is! String) { | ||
| ConsoleLogger.warning('Skipping invalid catalog entry: $skill'); | ||
| continue; | ||
| } | ||
|
|
||
| // Guard against path-traversal in catalog entries | ||
| if (containsPathTraversal(skillName) || | ||
| containsPathTraversal(skillPath)) { | ||
| ConsoleLogger.warning( | ||
| 'Skipping skill with suspicious name/path: $skillName / $skillPath', | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| final sourceSkillDir = Directory( | ||
| path.join(repoRoot.path, skillPath), | ||
| ); | ||
|
|
||
| // Ensure the resolved source is still inside the repo root | ||
| final sourceCanonical = path.canonicalize(sourceSkillDir.path); | ||
| if (!path.equals(repoRootCanonical, sourceCanonical) && | ||
| !path.isWithin(repoRootCanonical, sourceCanonical)) { | ||
| ConsoleLogger.warning( | ||
| 'Skill path $skillPath escapes repo root. Skipping.', | ||
| ); | ||
| continue; | ||
| } | ||
|
Pratikdate marked this conversation as resolved.
|
||
|
|
||
| if (!await sourceSkillDir.exists()) { | ||
| ConsoleLogger.warning( | ||
| 'Skill directory $skillPath not found, skipping.', | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| final targetSkillDir = Directory( | ||
| path.join(targetAgentsDir.path, skillName), | ||
| ); | ||
|
|
||
| // Ensure the resolved target is still inside .agents/skills | ||
| final targetSkillCanonical = path.canonicalize(targetSkillDir.path); | ||
| if (!path.equals(targetCanonical, targetSkillCanonical) && | ||
| !path.isWithin(targetCanonical, targetSkillCanonical)) { | ||
| ConsoleLogger.warning( | ||
| 'Skill name $skillName escapes target directory. Skipping.', | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| if (await targetSkillDir.exists()) { | ||
| await targetSkillDir.delete(recursive: true); | ||
| } | ||
| await targetSkillDir.create(recursive: true); | ||
|
|
||
| // Copy directory contents | ||
| await _copyDirectory( | ||
| sourceSkillDir, | ||
| targetSkillDir, | ||
| sourceCanonical, | ||
| targetSkillCanonical, | ||
| ); | ||
| ConsoleLogger.success('✓ $skillName (copied)'); | ||
| installedCount++; | ||
| } | ||
|
|
||
| ConsoleLogger.success( | ||
| 'Installed $installedCount skills to .agents/skills', | ||
| ); | ||
| return 0; | ||
| } catch (e) { | ||
| ConsoleLogger.error('Failed to install skills: $e'); | ||
| return 1; | ||
| } finally { | ||
| // Always clean up temp files regardless of success or failure | ||
| if (await tempDir.exists()) { | ||
| await tempDir.delete(recursive: true); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Returns true if a name or path segment contains traversal patterns. | ||
| bool containsPathTraversal(String value) { | ||
| return value.contains('..') || | ||
| path.isAbsolute(value) || | ||
| value.contains(r'\'); | ||
| } | ||
|
|
||
| Future<void> _copyDirectory( | ||
| Directory source, | ||
| Directory destination, | ||
| String sourceRootCanonical, | ||
| String destinationRootCanonical, | ||
| ) async { | ||
| await for (var entity in source.list(recursive: false, followLinks: false)) { | ||
| if (entity is Link) { | ||
| ConsoleLogger.warning('Skipping symlink: ${entity.path}'); | ||
| continue; | ||
| } | ||
|
|
||
| final entityCanonical = path.canonicalize(entity.path); | ||
| // Ensure the source entity is within the allowed source root | ||
| if (!path.equals(sourceRootCanonical, entityCanonical) && | ||
| !path.isWithin(sourceRootCanonical, entityCanonical)) { | ||
| ConsoleLogger.warning('Skipping out-of-bounds source entity: ${entity.path}'); | ||
| continue; | ||
| } | ||
|
|
||
| final targetPath = path.join(destination.path, path.basename(entity.path)); | ||
| final targetCanonical = path.canonicalize(targetPath); | ||
| // Ensure the destination path is within the allowed target root | ||
| if (!path.equals(destinationRootCanonical, targetCanonical) && | ||
| !path.isWithin(destinationRootCanonical, targetCanonical)) { | ||
| ConsoleLogger.warning('Skipping out-of-bounds destination path: $targetPath'); | ||
| continue; | ||
| } | ||
|
|
||
| if (entity is Directory) { | ||
| final newDirectory = Directory(targetPath); | ||
| await newDirectory.create(recursive: true); | ||
| await _copyDirectory( | ||
| entity, | ||
| newDirectory, | ||
| sourceRootCanonical, | ||
| destinationRootCanonical, | ||
| ); | ||
| } else if (entity is File) { | ||
| await entity.copy(targetPath); | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import 'package:args/command_runner.dart'; | ||
| import 'skills/add_command.dart'; | ||
|
|
||
| /// Command for managing Stac AI agent skills | ||
| class SkillsCommand extends Command<int> { | ||
| @override | ||
| String get name => 'skills'; | ||
|
|
||
| @override | ||
| String get description => 'Manage Stac AI agent skills'; | ||
|
|
||
| SkillsCommand() { | ||
| addSubcommand(AddCommand()); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.