Skip to content
Open
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
8 changes: 7 additions & 1 deletion docs/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ Think of Stac Skills as expert assistants that know Stac inside and out. They he

## Installation

Install all Stac skills with one command:
Install all Stac skills natively with the Stac CLI:

```bash
stac skills add
```

Alternatively, you can install them using `npx`:

```bash
npx skills add https://github.com/StacDev/stac
Expand Down
2 changes: 2 additions & 0 deletions packages/stac_cli/bin/stac_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:stac_cli/src/commands/build_command.dart';
import 'package:stac_cli/src/commands/deploy_command.dart';
import 'package:stac_cli/src/commands/init_command.dart';
import 'package:stac_cli/src/commands/project_command.dart';
import 'package:stac_cli/src/commands/skills_command.dart';
import 'package:stac_cli/src/commands/upgrade_command.dart';
import 'package:stac_cli/src/config/env.dart';
import 'package:stac_cli/src/exceptions/stac_exception.dart';
Expand Down Expand Up @@ -67,6 +68,7 @@ void main(List<String> arguments) async {
..addCommand(ProjectCommand())
..addCommand(BuildCommand())
..addCommand(DeployCommand())
..addCommand(SkillsCommand())
..addCommand(UpgradeCommand());

// Add global flags
Expand Down
19 changes: 19 additions & 0 deletions packages/stac_cli/lib/src/commands/init_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import '../services/project_service.dart';
import '../utils/console_logger.dart';
import '../utils/file_utils.dart';
import 'base_command.dart';
import 'skills/add_command.dart';

/// Command for initializing a Stac project from cloud projects
class InitCommand extends BaseCommand {
Expand Down Expand Up @@ -84,6 +85,24 @@ class InitCommand extends BaseCommand {
// Create default_stac_options.dart configuration file
await _createStacConfigFile(targetDir, project);

// Ask to install skills
final shouldInstallSkills = Confirm(
prompt: 'Install Stac agent skills? (Recommended for AI-assisted development)',
defaultValue: true,
).interact();
if (shouldInstallSkills) {
ConsoleLogger.info('Installing skills...');
final skillsExitCode = await AddCommand(
targetDirectory: targetDir,
).execute();
if (skillsExitCode != 0) {
ConsoleLogger.warning(
'Skills installation encountered an issue. '
'You can retry later with: stac skills add',
);
}
}

ConsoleLogger.success('✓ Project initialized successfully!');
ConsoleLogger.info('Next steps:');
ConsoleLogger.info(' 1. Add your Stac widgets definitions to /stac');
Expand Down
249 changes: 249 additions & 0 deletions packages/stac_cli/lib/src/commands/skills/add_command.dart
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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'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;
}
Comment thread
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);
}
}
}
}
15 changes: 15 additions & 0 deletions packages/stac_cli/lib/src/commands/skills_command.dart
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());
}
}
Loading