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
101 changes: 101 additions & 0 deletions cli/SimpleModule.Cli/Commands/Skill/SkillAddCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using SimpleModule.Cli.Infrastructure;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.Skill;

public sealed class SkillAddCommand : AsyncCommand<SkillAddSettings>
{
public override async Task<int> ExecuteAsync(CommandContext context, SkillAddSettings settings)
{
var solution = SolutionContext.Discover();
if (solution is null)
{
AnsiConsole.MarkupLine(
"[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]"
);
return 1;
}

var name = settings.ResolveName();
var skillDir = SkillWriter.GetSkillDirectory(solution.RootPath, name);

if (Directory.Exists(skillDir) && !settings.Force && !settings.DryRun)
{
AnsiConsole.MarkupLine(
$"[red]Skill '{Markup.Escape(name)}' already exists at {Markup.Escape(Path.GetRelativePath(solution.RootPath, skillDir))}. Use --force to overwrite or 'sm skill update' to refresh.[/]"
);
return 1;
}

SkillSource source;
FetchedSkill fetched;

if (string.IsNullOrWhiteSpace(settings.Source))
{
source = new SkillSource(SkillSourceType.Scaffold, "scaffold");
fetched = SkillWriter.BuildScaffold(name, settings.Description);
AnsiConsole.MarkupLine($"[blue]Scaffolding new skill '{Markup.Escape(name)}'.[/]");
}
else
{
source = SkillSource.Parse(settings.Source!);
AnsiConsole.MarkupLine(
$"[blue]Fetching skill '{Markup.Escape(name)}' from {Markup.Escape(source.Type.ToString().ToLowerInvariant())} source '{Markup.Escape(source.CanonicalSource)}'...[/]"
);

try
{
fetched = await new SkillFetcher().FetchAsync(source).ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
AnsiConsole.MarkupLine(
$"[red]Failed to fetch skill: {Markup.Escape(ex.Message)}[/]"
);
return 1;
}
}

if (settings.DryRun)
{
AnsiConsole.MarkupLine("[yellow]Dry run — no files will be written.[/]");
foreach (var file in fetched.Files)
{
var rel = Path.GetRelativePath(
solution.RootPath,
Path.Combine(skillDir, file.RelativePath)
);
AnsiConsole.MarkupLine($" [green]CREATE[/] {Markup.Escape(rel)}");
}

AnsiConsole.MarkupLine(
$" [green]UPDATE[/] {Markup.Escape(SkillsLockFile.FileName)} (entry '{Markup.Escape(name)}')"
);
return 0;
}

var written = SkillWriter.WriteFiles(skillDir, fetched.Files, replace: settings.Force);
foreach (var path in written)
{
var rel = Path.GetRelativePath(solution.RootPath, Path.Combine(skillDir, path));
AnsiConsole.MarkupLine($" [green]CREATE[/] {Markup.Escape(rel)}");
}

var lockFile = SkillsLockFile.Load(solution.RootPath);
lockFile.Skills[name] = new SkillsLockEntry
{
Source = source.CanonicalSource,
SourceType = source.SourceTypeId,
Ref = source.Ref,
ComputedHash = fetched.ComputedHash,
};
lockFile.Save(solution.RootPath);

AnsiConsole.MarkupLine($" [green]UPDATE[/] {Markup.Escape(SkillsLockFile.FileName)}");
AnsiConsole.MarkupLine(
$"\n[green]Skill '{Markup.Escape(name)}' added.[/] [dim]({fetched.Files.Count} file(s), hash {fetched.ComputedHash[..8]})[/]"
);
return 0;
}
}
46 changes: 46 additions & 0 deletions cli/SimpleModule.Cli/Commands/Skill/SkillAddSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.ComponentModel;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.Skill;

public sealed class SkillAddSettings : CommandSettings
{
[CommandArgument(0, "[name]")]
[Description("Skill name in kebab-case (e.g. shadcn, react-expert)")]
public string? Name { get; set; }

[CommandOption("--source <SOURCE>")]
[Description(
"Source for the skill. GitHub: 'owner/repo' optionally followed by '/path' and '@ref'. Local: a directory path. Omit to scaffold a new template."
)]
public string? Source { get; set; }

[CommandOption("--description <TEXT>")]
[Description("Description used when scaffolding a new skill (only when --source is omitted).")]
public string? Description { get; set; }

[CommandOption("--force")]
[Description("Overwrite the skill directory if it already exists.")]
public bool Force { get; set; }

[CommandOption("--dry-run")]
[Description("Show what would be written without modifying any files.")]
public bool DryRun { get; set; }

public string ResolveName()
{
if (string.IsNullOrWhiteSpace(Name))
{
Name = AnsiConsole.Ask<string>("Skill name (kebab-case):");
}

Name = SkillNameValidator.Normalize(Name);
if (!SkillNameValidator.IsValid(Name))
{
throw new InvalidOperationException(SkillNameValidator.ValidationMessage(Name));
}

return Name;
}
}
Loading
Loading