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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,13 @@ Registration is the discovery step. This command detects installed verified
agents and writes a small adapter for each one:

```bash
npm exec -- yskill register skills/release --root .
npm exec -- yskill register skills/release
```

Select verified agents explicitly when you do not want automatic detection:

```bash
npm exec -- yskill register skills/release --root . \
npm exec -- yskill register skills/release \
--agent cursor,codex,claude-code
```

Expand Down Expand Up @@ -239,8 +239,8 @@ helper:

| Language | Command |
| ---------- | ------------------------------------------------------------------------------------------------------------------- |
| TypeScript | `npm exec -- yskill helper install --root . --language typescript` |
| Python | `python -m yieldskill helper install --root . --language python` |
| TypeScript | `npm exec -- yskill helper install --language typescript` |
| Python | `python -m yieldskill helper install --language python` |
| Rust | `cargo install yieldskill --root .yield --locked`, then `.yield/bin/yskill helper install --root . --language rust` |
| Go | `go run github.com/operatorstack/yield/cmd/yskill@latest helper install --root . --language go` |

Expand Down
51 changes: 38 additions & 13 deletions cmd/yskill/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func cmdRegister(args []string) error {
fs := flag.NewFlagSet("register", flag.ContinueOnError)
var agents agentListFlag
fs.Var(&agents, "agent", "agent id, comma-separated ids, or auto")
root := fs.String("root", "", "repository root (detected from .git by default)")
root := fs.String("root", "", "project root (inferred for supported layouts)")
if err := parseOnePositional(fs, args); err != nil {
return err
}
Expand All @@ -143,7 +143,7 @@ func cmdRegisterAll(args []string) error {
fs := flag.NewFlagSet("register-all", flag.ContinueOnError)
var agents agentListFlag
fs.Var(&agents, "agent", "agent id, comma-separated ids, or auto")
root := fs.String("root", "", "repository root (detected from .git by default)")
root := fs.String("root", "", "project root (inferred for supported layouts)")
dryRun := fs.Bool("dry-run", false, "print the synchronization plan without writing")
prune := fs.Bool("prune", false, "remove obsolete generated adapters owned by this workflow directory")
if err := parseOnePositional(fs, args); err != nil {
Expand Down Expand Up @@ -195,6 +195,8 @@ func cmdRegisterAll(args []string) error {
if repoRoot == "" {
repoRoot, selected = resolvedRoot, selectedAgents
parentRel, _ = filepath.Rel(repoRoot, parent)
} else if resolvedRoot != repoRoot {
return fmt.Errorf("all workflows must resolve to the same project root: %s and %s", repoRoot, resolvedRoot)
}
usesLocalRuntime = usesLocalRuntime || manifest.Language == "go" || manifest.Language == "rust"
digest, digestErr := protocol.DigestSkillDir(skillDir)
Expand Down Expand Up @@ -377,17 +379,21 @@ func registrationInputs(skillArg, rootArg string, requested []string) (string, s
if err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err
}
repoRoot, err := findRepoRoot(skillDir, rootArg)
skillDir, err = filepath.EvalSymlinks(skillDir)
if err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, fmt.Errorf("resolve skill directory: %w", err)
}
manifest, err := readSkillManifest(skillDir)
if err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err
}
repoRoot, err = filepath.EvalSymlinks(repoRoot)
repoRoot, err := findWorkflowRoot(skillDir, rootArg, manifest.Language)
if err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, fmt.Errorf("resolve repository root: %w", err)
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err
}
skillDir, err = filepath.EvalSymlinks(skillDir)
repoRoot, err = filepath.EvalSymlinks(repoRoot)
if err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, fmt.Errorf("resolve skill directory: %w", err)
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, fmt.Errorf("resolve repository root: %w", err)
}
sourceRel, err := filepath.Rel(repoRoot, skillDir)
if err != nil || sourceRel == ".." || strings.HasPrefix(sourceRel, ".."+string(filepath.Separator)) {
Expand All @@ -400,10 +406,6 @@ func registrationInputs(skillArg, rootArg string, requested []string) (string, s
if err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err
}
manifest, err := readSkillManifest(skillDir)
if err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err
}
if err := verifyWorkflowSDKVersion(manifest, skillDir, repoRoot, runtimeVersion()); err != nil {
return "", "", "", skillMetadata{}, skillManifest{}, agentRegistry{}, nil, err
}
Expand Down Expand Up @@ -570,6 +572,29 @@ func findRepoRoot(skillDir, explicit string) (string, error) {
return "", fmt.Errorf("cannot find repository root from %s; pass --root", skillDir)
}

func findWorkflowRoot(skillDir, explicit, language string) (string, error) {
root, err := findRepoRoot(skillDir, explicit)
if err == nil || explicit != "" || (language != "typescript" && language != "python") {
return root, err
}
cwd, cwdErr := os.Getwd()
if cwdErr != nil {
return "", err
}
cwd, cwdErr = filepath.EvalSymlinks(cwd)
if cwdErr != nil {
return "", fmt.Errorf("resolve current directory: %w", cwdErr)
}
resolvedSkill, skillErr := filepath.EvalSymlinks(skillDir)
if skillErr != nil {
return "", fmt.Errorf("resolve skill directory: %w", skillErr)
}
if !within(cwd, resolvedSkill) {
return "", err
}
return filepath.Clean(cwd), nil
}

func within(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
Expand Down Expand Up @@ -926,7 +951,7 @@ func cmdDoctor(args []string) error {
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
var agents agentListFlag
fs.Var(&agents, "agent", "agent id, comma-separated ids, or auto")
root := fs.String("root", "", "repository root (detected from .git by default)")
root := fs.String("root", "", "project root (inferred for supported layouts)")
runTest := fs.Bool("test", false, "run the workflow fixture after static checks")
if err := parseOnePositional(fs, args); err != nil {
return err
Expand All @@ -950,7 +975,7 @@ func cmdDoctor(args []string) error {
if err != nil {
return err
}
packageBoundary, boundaryErr := findRepoRoot(skillDir, *root)
packageBoundary, boundaryErr := findWorkflowRoot(skillDir, *root, manifest.Language)
if boundaryErr != nil {
if manifest.Language == "go" || manifest.Language == "rust" {
return fmt.Errorf("%s workflow needs a repository root for .yield/bin; pass --root: %w", manifest.Language, boundaryErr)
Expand Down
124 changes: 124 additions & 0 deletions cmd/yskill/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,68 @@ func TestDoctorWorkflowOnlyDoesNotRequireRepository(t *testing.T) {
}
}

func TestTypeScriptAndPythonInferCurrentDirectoryOutsideGit(t *testing.T) {
for _, language := range []string{"typescript", "python"} {
t.Run(language, func(t *testing.T) {
root := t.TempDir()
var skill string
if language == "typescript" {
skill = createTypeScriptSkill(t, root, "review")
} else {
skill = createPythonSkill(t, root, "review")
}
t.Chdir(root)
if _, err := registerSkill(skill, "", []string{"codex"}); err != nil {
t.Fatalf("register without --root: %v", err)
}
if err := cmdDoctor([]string{skill, "--agent", "codex"}); err != nil {
t.Fatalf("doctor without --root: %v", err)
}
adapter := readTestFile(t, filepath.Join(root, ".agents", "skills", "review", "SKILL.md"))
if !strings.Contains(adapter, "source: skills/review;") {
t.Fatalf("adapter is not rooted at the invocation directory:\n%s", adapter)
}
})
}
}

func TestCurrentDirectoryFallbackRejectsOutsideAndSymlinkedWorkflows(t *testing.T) {
root := t.TempDir()
t.Chdir(root)
outside := createTypeScriptSkill(t, t.TempDir(), "outside")
if _, err := registerSkill(outside, "", []string{"codex"}); err == nil || !strings.Contains(err.Error(), "cannot find repository root") {
t.Fatalf("outside current directory error = %v", err)
}
if runtime.GOOS == "windows" {
return
}
link := filepath.Join(root, "skills", "linked")
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
if _, err := registerSkill(link, "", []string{"codex"}); err == nil || !strings.Contains(err.Error(), "cannot find repository root") {
t.Fatalf("symlinked workflow error = %v", err)
}
}

func TestGoAndRustWorkflowsDoNotUseCurrentDirectoryFallback(t *testing.T) {
for _, language := range []string{"go", "rust"} {
t.Run(language, func(t *testing.T) {
root := t.TempDir()
t.Chdir(root)
skill := filepath.Join(root, "skills", "review")
writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: review\ndescription: Review a change before it is merged.\n---\n")
writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"`+language+`","run":["run"]}`)
if _, err := registerSkill(skill, "", []string{"codex"}); err == nil || !strings.Contains(err.Error(), "cannot find repository root") {
t.Fatalf("%s workflow used current directory fallback: %v", language, err)
}
})
}
}

func TestRegisterAllPreflightsAndWritesEveryWorkflow(t *testing.T) {
repo := t.TempDir()
writeTestFile(t, filepath.Join(repo, ".git"), "gitdir: fixture\n")
Expand Down Expand Up @@ -249,6 +311,58 @@ func TestRegisterAllPreflightsAndWritesEveryWorkflow(t *testing.T) {
}
}

func TestRegisterAllInfersOneCurrentDirectoryRootOutsideGit(t *testing.T) {
for _, language := range []string{"typescript", "python"} {
t.Run(language, func(t *testing.T) {
repo := t.TempDir()
if language == "typescript" {
writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.19"}}`)
}
for _, name := range []string{"review", "release"} {
skill := filepath.Join(repo, "skills", name)
writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Run "+name+" when the matching project workflow is requested.\n---\n")
writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"`+language+`","run":["run"]}`)
if language == "typescript" {
writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n")
} else {
writeTestFile(t, filepath.Join(skill, "requirements.txt"), "yieldskill==0.1.23\n")
writeTestFile(t, filepath.Join(skill, "main.py"), "print('ok')\n")
}
}
t.Chdir(repo)
if err := cmdRegisterAll([]string{"skills", "--agent", "codex"}); err != nil {
t.Fatal(err)
}
for _, name := range []string{"review", "release"} {
if _, err := os.Stat(filepath.Join(repo, ".agents", "skills", name, "SKILL.md")); err != nil {
t.Fatal(err)
}
}
})
}
}

func TestRegisterAllRefusesMixedResolvedRoots(t *testing.T) {
repo := t.TempDir()
writeTestFile(t, filepath.Join(repo, "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.19"}}`)
for _, name := range []string{"outer", "nested"} {
skill := filepath.Join(repo, "skills", name)
writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Run "+name+" when the matching project workflow is requested.\n---\n")
writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"typescript","run":["node","main.ts"]}`)
writeTestFile(t, filepath.Join(skill, "main.ts"), "export {}\n")
}
writeTestFile(t, filepath.Join(repo, "skills", "nested", ".git"), "gitdir: fixture\n")
writeTestFile(t, filepath.Join(repo, "skills", "nested", "package.json"), `{"dependencies":{"@operatorstack/yield":"0.1.19"}}`)
t.Chdir(repo)
err := cmdRegisterAll([]string{"skills", "--agent", "codex"})
if err == nil || !strings.Contains(err.Error(), "same project root") {
t.Fatalf("mixed roots error = %v", err)
}
if _, statErr := os.Stat(filepath.Join(repo, ".agents")); !os.IsNotExist(statErr) {
t.Fatalf("mixed-root preflight wrote adapters: %v", statErr)
}
}

func TestRegisterAllPruneRemovesOnlyOwnedAdapterFile(t *testing.T) {
repo := t.TempDir()
writeTestFile(t, filepath.Join(repo, ".git"), "gitdir: fixture\n")
Expand Down Expand Up @@ -535,6 +649,16 @@ func createTypeScriptSkill(t *testing.T, repo, name string) string {
return skill
}

func createPythonSkill(t *testing.T, repo, name string) string {
t.Helper()
skill := filepath.Join(repo, "skills", name)
writeTestFile(t, filepath.Join(skill, "SKILL.md"), "---\nname: "+name+"\ndescription: Review the branch when the user wants code checked before shipping.\n---\n")
writeTestFile(t, filepath.Join(skill, "skill.json"), `{"version":1,"yield_version":"0.1.23","language":"python","run":["python","main.py"]}`)
writeTestFile(t, filepath.Join(skill, "requirements.txt"), "yieldskill==0.1.23\n")
writeTestFile(t, filepath.Join(skill, "main.py"), "print('ok')\n")
return skill
}

func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
Expand Down
14 changes: 13 additions & 1 deletion cmd/yskill/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,19 @@ func makeBootstrapPlan(rootArg, language string, requested []string) (bootstrapP
}
root, err := findRepoRoot(cwd, rootArg)
if err != nil {
return bootstrapPlan{}, err
if rootArg != "" {
return bootstrapPlan{}, err
}
if language == "" {
language, err = detectBootstrapLanguage(cwd)
if err != nil {
return bootstrapPlan{}, err
}
}
if language != "typescript" && language != "python" {
return bootstrapPlan{}, err
}
root = cwd
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
Expand Down
49 changes: 49 additions & 0 deletions cmd/yskill/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,55 @@ func TestHelperInstallUsesBootstrapContract(t *testing.T) {
}
}

func TestHelperInfersCurrentDirectoryForTypeScriptAndPython(t *testing.T) {
for _, language := range []string{"typescript", "python"} {
t.Run(language, func(t *testing.T) {
withBootstrapTestState(t)
root := t.TempDir()
t.Chdir(root)
if err := cmdHelper([]string{"install", "--language", language, "--agent", "codex", "--dry-run"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "skills")); !os.IsNotExist(err) {
t.Fatalf("helper dry run wrote skills directory: %v", err)
}
if err := cmdHelper([]string{"install", "--language", language, "--agent", "codex", "--yes"}); err != nil {
t.Fatal(err)
}
for _, path := range []string{
"skills/yield-workflow-builder/SKILL.md",
".agents/skills/yield-workflow-builder/SKILL.md",
} {
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(path))); err != nil {
t.Fatalf("missing %s after helper install: %v", path, err)
}
}
})
}
}

func TestHelperAutoDetectsSupportedCurrentDirectoryAndRefusesAmbiguity(t *testing.T) {
withBootstrapTestState(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "pyproject.toml"), "[project]\nname = 'example'\n")
t.Chdir(root)
plan, err := makeBootstrapPlan("", "", []string{"codex"})
if err != nil {
t.Fatal(err)
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
if plan.Root != resolvedRoot || plan.Language != "python" {
t.Fatalf("auto-detected plan = root %q language %q", plan.Root, plan.Language)
}
writeTestFile(t, filepath.Join(root, "package.json"), "{}\n")
if _, err := makeBootstrapPlan("", "", []string{"codex"}); err == nil || !strings.Contains(err.Error(), "multiple project languages") {
t.Fatalf("ambiguous current directory error = %v", err)
}
}

func TestBootstrapCancellationDoesNotWrite(t *testing.T) {
withBootstrapTestState(t)
bootstrapInput = bytes.NewBufferString("no\n")
Expand Down
9 changes: 8 additions & 1 deletion cmd/yskill/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import (
"github.com/operatorstack/yield/internal/runlog"
)

func TestMain(m *testing.M) {
// Launcher-sensitive tests control this boundary explicitly. Do not let the
// runtime that invoked `go test` override their test-local launchers.
_ = os.Unsetenv("YIELD_LAUNCHER_PATH")
os.Exit(m.Run())
}

func stubRustLockfile(t *testing.T) {
t.Helper()
previous := generateRustLockfile
Expand Down Expand Up @@ -313,7 +320,7 @@ func TestPackageScaffoldsPrintCreatedWorkflowInNextCommands(t *testing.T) {
workflow := shellQuoteForPlatform(dir, runtime.GOOS)
for _, line := range []string{
"test: " + tt.launcher + " doctor " + workflow + " --test",
"then: " + tt.launcher + " register " + workflow + " --root .",
"then: " + tt.launcher + " register " + workflow,
} {
if !strings.Contains(output, line) {
t.Fatalf("init output does not contain %q:\n%s", line, output)
Expand Down
8 changes: 1 addition & 7 deletions cmd/yskill/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,7 @@ func scaffoldSkill(dir, language, sdkPath, description string) error {
fmt.Printf("init: %s skill %q scaffolded in %s\n", language, name, dir)
fmt.Println("next: replace the starter program and fixtures with the described workflow")
fmt.Printf("test: %s doctor %s --test\n", launcher, workflow)
rootFlag := ""
if language == "typescript" || language == "python" {
if _, err := findRepoRoot(dir, ""); err != nil {
rootFlag = " --root ."
}
}
fmt.Printf("then: %s register %s%s\n", launcher, workflow, rootFlag)
fmt.Printf("then: %s register %s\n", launcher, workflow)
return nil
}

Expand Down
Loading