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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ The default flow is preview first (`render` / `diff`), then `apply`. Existing
unmanaged files block writes unless `--adopt` is passed, and overwritten files
are backed up under `.kanon/backups`.

Skills may be stored locally under `skills/<name>` or materialized from a
pinned git source:

```yaml
skills:
- name: code-reviewer
source:
type: git
url: https://github.com/acme/agent-skills.git
ref: 8f3c4e2d9a1b0c7d6e5f4a3b2c1d0e9f8a7b6c5d
subdir: code-reviewer
```

Remote skills are fetched automatically the first time `render`, `diff`,
`apply`, `status`, or `update` needs them. Kanon caches materialized sources
under `.kanon/cache/sources/`, which is gitignored by the starter `.gitignore`;
if the cache already exists, Kanon reuses it and does not refresh it. Pin `ref`
to a commit SHA for reproducible behavior across machines.

The source is the single source of truth: when you remove an instruction,
skill, or hook from the source, `apply` deletes the file it generated so the
destination stays a projection of the source (deletions are backed up too, and
Expand Down
13 changes: 12 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func statusCommand(opts *options) *cobra.Command {
}
plan, _, err := opts.plan(false)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if isMissingConfigError(home, opts.configPath, err) {
return nil
}
return err
Expand Down Expand Up @@ -405,6 +405,17 @@ func validationError(errs []error) error {
return errors.Join(errs...)
}

func isMissingConfigError(home, configPath string, err error) bool {
var pathErr *os.PathError
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, os.ErrNotExist) {
return false
}
if configPath == "" {
configPath = filepath.Join(home, "kanon.yaml")
}
return filepath.Clean(pathErr.Path) == filepath.Clean(configPath)
}

func confirm(in io.Reader, out io.Writer) (bool, error) {
fmt.Fprint(out, "Apply these changes? [y/N] ")
line, err := bufio.NewReader(in).ReadString('\n')
Expand Down
51 changes: 51 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,44 @@ func TestUpdatePullsAndApplies(t *testing.T) {
}
}

func TestStatusReportsRemoteSkillMaterializationErrors(t *testing.T) {
userHome := t.TempDir()
t.Setenv("HOME", userHome)

repo := t.TempDir()
gitRun(t, repo, "init")
gitRun(t, repo, "config", "user.email", "test@example.com")
gitRun(t, repo, "config", "user.name", "Test")
writeFile(t, filepath.Join(repo, "README.md"), "not a skill\n")
gitRun(t, repo, "add", ".")
gitRun(t, repo, "commit", "-m", "missing skill")
ref := strings.TrimSpace(string(gitOutput(t, repo, "rev-parse", "HEAD")))

home := t.TempDir()
if err := core.WriteConfig(filepath.Join(home, "kanon.yaml"), &core.Config{
Version: 1,
Skills: []core.Skill{{
Name: "broken",
Source: &core.RemoteSource{Type: "git", URL: repo, Ref: ref},
}},
}); err != nil {
t.Fatal(err)
}

cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--home", home, "status"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected status to report remote materialization error\n%s", out.String())
}
if !strings.Contains(err.Error(), `skill "broken" source missing SKILL.md`) {
t.Fatalf("unexpected status error: %v\n%s", err, out.String())
}
}

func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
if dir != "" {
Expand All @@ -101,6 +139,19 @@ func gitRun(t *testing.T, dir string, args ...string) {
}
}

func gitOutput(t *testing.T, dir string, args ...string) []byte {
t.Helper()
if dir != "" {
args = append([]string{"-C", dir}, args...)
}
cmd := exec.Command("git", args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return out
}

func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
Expand Down
34 changes: 34 additions & 0 deletions internal/core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ func ValidateConfig(cfg *Config, home string) []error {
continue
}
validateTargets(fmt.Sprintf("skill %q", skill.Name), skill.Targets, &errs)
if skill.Source != nil {
validateRemoteSource(fmt.Sprintf("skill %q source", skill.Name), *skill.Source, skill.Path, &errs)
continue
}
path := skill.Path
if path == "" {
path = filepath.Join("skills", skill.Name)
Expand Down Expand Up @@ -115,6 +119,26 @@ func ValidateConfig(cfg *Config, home string) []error {
return errs
}

func validateRemoteSource(label string, source RemoteSource, path string, errs *[]error) {
if path != "" {
*errs = append(*errs, fmt.Errorf("%s cannot be used with path", label))
}
if source.Type != "git" {
*errs = append(*errs, fmt.Errorf("%s has unsupported type %q", label, source.Type))
}
if strings.TrimSpace(source.URL) == "" {
*errs = append(*errs, fmt.Errorf("%s requires url", label))
}
if strings.TrimSpace(source.Ref) == "" {
*errs = append(*errs, fmt.Errorf("%s requires ref", label))
} else if strings.HasPrefix(source.Ref, "-") || strings.ContainsAny(source.Ref, "\x00\r\n") {
*errs = append(*errs, fmt.Errorf("%s has invalid ref %q", label, source.Ref))
}
if _, err := cleanRemoteSubdir(source.Subdir); err != nil {
*errs = append(*errs, fmt.Errorf("%s has invalid subdir %q: %w", label, source.Subdir, err))
}
}

func ResolvePath(home, path string) string {
if path == "" {
return home
Expand Down Expand Up @@ -213,7 +237,17 @@ func validateEnvRefs(label string, value any, errs *[]error) {
case Skill:
validateEnvRefs(label+".name", typed.Name, errs)
validateEnvRefs(label+".path", typed.Path, errs)
validateEnvRefs(label+".source", typed.Source, errs)
validateEnvRefs(label+".targets", typed.Targets, errs)
case *RemoteSource:
if typed != nil {
validateEnvRefs(label, *typed, errs)
}
case RemoteSource:
validateEnvRefs(label+".type", typed.Type, errs)
validateEnvRefs(label+".url", typed.URL, errs)
validateEnvRefs(label+".ref", typed.Ref, errs)
validateEnvRefs(label+".subdir", typed.Subdir, errs)
case MCPConfig:
for name, item := range typed.Servers {
validateEnvRefs(label+".servers."+name, item, errs)
Expand Down
Loading
Loading