From 98d69c977ba3ac751d7902d3a53f3263e5e0590b Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sat, 1 Aug 2026 22:00:23 +0000 Subject: [PATCH] Sync Yield from Intelligence Flow @ 4446951aa6a0 --- README.md | 29 ++- UPSTREAM.json | 32 ++- cmd/yskill/main.go | 98 +++------ cmd/yskill/main_test.go | 118 +++++++++++ cmd/yskill/scaffold.go | 190 ++++++++++++++++++ docs/quickstart.md | 27 +-- docs/reference/cli.md | 16 +- evals/results/latest.json | 4 +- packaging/README.md | 9 + packaging/assemble.mjs | 114 +++++++++++ packaging/cargo-index.mjs | 48 +++++ packaging/cargo-index.test.mjs | 16 ++ packaging/rust-launcher.rs | 62 ++++++ packaging/targets.mjs | 20 ++ .../2026-08-01-one-package-per-language.md | 11 + sdk/python/pyproject.toml | 8 +- sdk/python/test_cli.py | 49 +++++ sdk/python/yieldskill/__main__.py | 3 + sdk/python/yieldskill/_cli.py | 45 +++++ sdk/typescript/bin/runtime.mjs | 34 ++++ sdk/typescript/bin/runtime.test.mjs | 30 +++ sdk/typescript/bin/yskill.mjs | 33 +++ sdk/typescript/package.json | 5 + 23 files changed, 892 insertions(+), 109 deletions(-) create mode 100644 cmd/yskill/scaffold.go create mode 100644 packaging/README.md create mode 100644 packaging/assemble.mjs create mode 100644 packaging/cargo-index.mjs create mode 100644 packaging/cargo-index.test.mjs create mode 100644 packaging/rust-launcher.rs create mode 100644 packaging/targets.mjs create mode 100644 release-notes/2026-08-01-one-package-per-language.md create mode 100644 sdk/python/test_cli.py create mode 100644 sdk/python/yieldskill/__main__.py create mode 100644 sdk/python/yieldskill/_cli.py create mode 100644 sdk/typescript/bin/runtime.mjs create mode 100644 sdk/typescript/bin/runtime.test.mjs create mode 100644 sdk/typescript/bin/yskill.mjs diff --git a/README.md b/README.md index 78c78b5..ae70c2a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,31 @@ and moves the part prose loses under context pressure — order, branching, retries, approval, state, completion — into a deterministic program. The model keeps reasoning, exploration, editing, and judgment. +## Install + +Choose one language package. It includes the SDK and the matching `yskill` +runtime. + +```bash +# TypeScript +npm install @operatorstack/yield --registry=https://get.operatorstack.systems/npm/ +npm exec -- yskill --version + +# Python +python -m pip install yieldskill --index-url https://get.operatorstack.systems/pip/simple/ +python -m yieldskill --version + +# Go +GOPROXY=https://get.operatorstack.systems/go,direct \ + go install github.com/operatorstack/yield/cmd/yskill@latest +yskill --version + +# Rust +cargo install yieldskill \ + --index sparse+https://get.operatorstack.systems/cargo/index/ --locked +yskill --version +``` + ## How it works Deterministic re-execution: on every run/resume, `yskill` re-executes the @@ -25,7 +50,7 @@ and the process exits — no daemon. A replayed step that produces a different operation than the journal recorded is a divergence and fails the run loudly; it never silently forks. -- **`yskill` (supervisor)** owns the append-only run log +- **`yskill`** owns the append-only run log (`.yield/runs/.jsonl`), sequence and digest binding, response validation, and every refusal (stale, duplicate, wrong-run, schema-invalid, digest-mismatch, completion-unproven). @@ -45,7 +70,7 @@ Five primitives, two exits: ## Four languages, one protocol Write the skill program in Go, TypeScript, Python, or Rust — the -supervisor doesn't care. Every SDK implements the same certified +runtime doesn't care. Every SDK implements the same certified execution contract over the canonical `ir/yield.v1` schemas, and the conformance suite (`internal/conformance`) runs the *same program* in all four languages and asserts identical observable protocol behavior. diff --git a/UPSTREAM.json b/UPSTREAM.json index 7649753..be7574f 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -2,9 +2,10 @@ "files": { ".gitignore": "803c5f79d6da7f2c5a1dc0ce27c53b8e5c059309b165782831c4c472af058a4c", "LICENSE": "fff261ce507eabd57666c283a621f33e183a3aedebda04c4ecbc6309a62f5edf", - "README.md": "acd9f37f0f7013a4bf99df382fd3b5c14d070914ec958e3198d9c70aa8317ce6", - "cmd/yskill/main.go": "37a1004022d3b4c6db4b0c0e31bebcf6ec15e0737975762e3cb5290d96c9c231", - "cmd/yskill/main_test.go": "9e0e5b157a8e34d56bada07f6af49e0cd24f8e310777f27666dd9f7e2c20a942", + "README.md": "25414dfd686342b2153b63bab6c8a5c9fbab1e5fb80be368b8c43f5161b53b83", + "cmd/yskill/main.go": "391d388b1f5ad4eaebcd833d802eca0dcbcb669a876bc9fe6cd57f54e3689732", + "cmd/yskill/main_test.go": "a6f6c5475aebd80bca29434d24ff599bdda7766b3786c41d55f8c44453ed1e0a", + "cmd/yskill/scaffold.go": "e00b024d76ba58bef0f8680147d9a905db565ae6ddd8141bc6718bde22396ed3", "docs/README.md": "cd8a4105f4f04143172661b39789e3c533d031ad46437aa2c078625a29a0a9db", "docs/convert-existing-skill.md": "0dd538e908a1f2d3a1958e4b2cc1efede2a8e7a73341e32c1a75fec69a9acb8a", "docs/examples.md": "3c19bacae7ec7b31bf93417e61d01cd1872f26228fedf0ed6decfce7c41ed274", @@ -29,8 +30,8 @@ "docs/primitives/outcomes.md": "a584976fada6af3772894736e686f5deb466d3cc8fd1ecacf49f6b9042d6a232", "docs/primitives/require.md": "1ea8c3a9ea11aa61e2baee01cf83fca8c872e846a47d7ef230cf6b76d04c90d8", "docs/primitives/run-command.md": "af9bcc617f90fd2ee0086aeabb5c98854542efe81636a020699c2f688f8b3b8c", - "docs/quickstart.md": "aad58e7cc429fe00270a4d25adf7fd48eef7aae0096c6b89ed216427bcde2eba", - "docs/reference/cli.md": "09ba86f4d8e7384b4a21a0b909f09d3386acb3336c15b37300091a5bf647203d", + "docs/quickstart.md": "2fc6a4780a6c47ea00b02b4f644a6a12c804491e455ca498ed3d5f294a013f59", + "docs/reference/cli.md": "d26f76f367c31b99c4cc44cb486834f0a581cda5f952c781de5f5ffc8b9bef59", "docs/reference/execution-model.md": "b221051da602953ebd9e1cc5e7f848de8f59721b36a1793211e6374745f74507", "docs/reference/guarantees.md": "e42dcdc1e6c4ed755d0a8156f0d58b2f552edf1cb8f910f0c2f872b1ac6907ff", "docs/reference/sdk-parity.md": "f598cd9a19b76727f4ec27b6c029640fe2e4661d964db2938fda27bdc962b345", @@ -58,7 +59,7 @@ "evals/package.json": "84ac7bd7d1c0d1db2233dab54754296195b267e5e907d9f6c402d0d44d569adb", "evals/results/README.md": "65b74bfab83dfc51fc5b8a3a4824c37b722fb3a358a5dcfb217dc2af199f4801", "evals/results/latest-agent.json": "1484174819ae29bbcffeb615036fa165518669525e6d664123f6a38ca3614b69", - "evals/results/latest.json": "ba580e2f1d29ebfcb1f70700ed4148906686e9ded7350be0552b0fc30a91ff66", + "evals/results/latest.json": "ff2bee0e668755d3136b97883ced893b9e0294c003b88252a44604fcc067ffd6", "evals/scripts/run.mjs": "119248cd4d383326d396f4459df28c51ce7c31088d97639851f2f5ab4c329892", "evals/scripts/validate.mjs": "7ac2a3239cd1b52a604c8a8a52a1af4ac55c4007ac2751d4fbfe20d0e1f568dc", "examples/convert-skill/SKILL.md": "e6376f34365d4ac030d316db55e91f0c606a501668099f4ecf1e27d43ec806a2", @@ -276,6 +277,12 @@ "ir/yield.v1/program-output.schema.json": "6b80f6a267cfeea3429cd95485195df037176726ff999e0d94bd20e07600051c", "ir/yield.v1/request-envelope.schema.json": "2d5f34b04638450f1bd87ec1305ba7ecf0de2ec0ef6948365e6f46c2f2ee1c35", "ir/yield.v1/response-envelope.schema.json": "698fc20510bf1362cac17f332b8ec4b4dd336d2bee4294b1949ed3257b535639", + "packaging/README.md": "57580fa789c4d61d7946722fc031ca885d982a116d00c403c7978700914dbbed", + "packaging/assemble.mjs": "295e0fa5adc9898cf64ff554524329dedcfe43843804b260173f34a778f3e5ab", + "packaging/cargo-index.mjs": "3c8845ab1b82a568c076229831580d8f3ad684dc6ccb74f4860d69637eb06735", + "packaging/cargo-index.test.mjs": "62a986e7f98d83007bd4b0ca14ed0e613c8ec4e240656da5af23521d7b69653c", + "packaging/rust-launcher.rs": "d7a534609031397c816fb24702002c3c16be41cb1fedbd9c53b5915ba5f670ab", + "packaging/targets.mjs": "354e0700cf1c4f1a864e5eaa0fe951717559a381d50ad6e6c4f491449aeb526a", "release-notes/2026-08-01-agent-workflow-evaluation.md": "61fe8b5f893d21dac9dba9fa759818637c81ce2cd844571274e50f763ac72d00", "release-notes/2026-08-01-docs-and-typescript-package.md": "93382375cb47187092aff9447b280853151c738b140879828c74576a109195b8", "release-notes/2026-08-01-evaluation-case-guides.md": "570bc996eb4d2892456a02d938fb6299d106c431bb842ce726db1df550f779d7", @@ -284,13 +291,20 @@ "release-notes/2026-08-01-first-party-evaluations.md": "9e74c48112343c409a88358138db80d731004f87900f1b23073f16c415163fcf", "release-notes/2026-08-01-initial-projection.md": "d38f0832b5552fb97237b30d19bb63369442ddde69e678b752ac07eedeb7ba3d", "release-notes/2026-08-01-multi-language-and-converter.md": "d0cf62d191e6a58e827f3b35d442f88b83be4dde98aa25ad54e4ae3ff787a453", + "release-notes/2026-08-01-one-package-per-language.md": "6d0d55b4fdcef20332e98c34c1c42c8564aeeeac84647e9f76005b6842d0c91c", "release-notes/2026-08-01-remove-stray-analysis-traces.md": "0567f78ee97ffd23b3f26b5c39606e9ff6659c50a3fdef04ed9b3aa86cfa99af", "sdk/python/README.md": "415fde10711137e0baf30874844a0482cebbf60fb59cb751eba56dedfebb0ce8", - "sdk/python/pyproject.toml": "d9464afafbb78061b00aa7193fc342e2df80002b0443605a4b41a5da9e081802", + "sdk/python/pyproject.toml": "142249ed41c8ea58560e2b54400a2a778d2ac4b327112b2909ded69d9bd99bdb", + "sdk/python/test_cli.py": "b7a2fe9a72299aa86f6012129e00f9c2dc0f6e5c29241373fb956321fae5f0b4", "sdk/python/yieldskill/__init__.py": "7e47b47b8a038640a06f596ab043a9e4519c4b5a02d12ea4e6c2ed398026a739", + "sdk/python/yieldskill/__main__.py": "2f2978db2ba5bf8034466902e0c0f5fa61d0961b262d776598ba0d9bb47d6b62", + "sdk/python/yieldskill/_cli.py": "ab8978c5c053ba3007ba38ae22038a869cb0a5222762edd0fbea749623024b66", "sdk/rust/Cargo.toml": "39c19c2b7800c9b9d1f7387548f56ad3feb8c7712255e101d967bab82466fe8b", "sdk/rust/src/lib.rs": "0c0d85d55f81e289f83588030f69e67d6d9baead9b05aa5bb1aca74c9450b851", - "sdk/typescript/package.json": "b37a22ec3e50ae7a9ccb873b08cd13e7f4c328416edd9eed737db5616e13eda6", + "sdk/typescript/bin/runtime.mjs": "909da102e8ed8ccc917cd17d88acc65869a0a2bdb6f875f76d763383999a2b57", + "sdk/typescript/bin/runtime.test.mjs": "db9c2dc1054d7f03139d69d3ffdb1e6fe5b65a71f4bdcca50fdefb2562831a2b", + "sdk/typescript/bin/yskill.mjs": "4ac8b13afc26fdc5b9192e6443dc121b83c7abfb9a4b80768f8344b8faaa4197", + "sdk/typescript/package.json": "8a2b618ac94a00ef8a85ce2e97112b97db8a90505539b97f6b4afd4d383d6df5", "sdk/typescript/scripts/build.mjs": "1ba086bbdddb61226f3b6363e1fda0ffdd129ce90ea9da28ba71ecfb0f478e6d", "sdk/typescript/src/index.ts": "59e7ce9f0e5443a1294c327c71b2d19dfd45fa78761430a835dc12a98885c8b9", "sdk/yield/yield.go": "5250ded7e18be107b1ccbd5a46ab41f7baeeedbfe5b88a11b5a860b05b11625a" @@ -298,7 +312,7 @@ "generator": "operatorstack/yield:project", "schema_version": 1, "source": { - "commit": "7b82c7095893931a2cef22e9a076dec16b4ba0fc", + "commit": "4446951aa6a0e3a4fd64fefffacadb8b43252ffa", "path": "labs/22-yield", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/yskill/main.go b/cmd/yskill/main.go index 99ecf8a..0478795 100644 --- a/cmd/yskill/main.go +++ b/cmd/yskill/main.go @@ -9,6 +9,8 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "runtime/debug" "strings" "github.com/operatorstack/yield/internal/engine" @@ -19,14 +21,30 @@ const usage = `yskill — turn SKILL.md workflows into resumable programs Usage: yskill init scaffold a skill (or wrap an existing prose skill) + [--language typescript|python|go|rust] yskill run [--input file] start a run; prints the first operation envelope yskill resume --response file feed a response; prints the next operation [--skill dir] [--accept-new-digest] yskill inspect [--skill dir] print the run's event log yskill replay [--skill dir] re-derive the run from its log; verify determinism yskill test run the skill against fixtures/responses.json + yskill version print the runtime version and target ` +// version is set from the release tag with -ldflags "-X main.version=". +var version = "dev" +var readBuildInfo = debug.ReadBuildInfo + +func runtimeVersion() string { + if version != "" && version != "dev" { + return strings.TrimPrefix(version, "v") + } + if info, ok := readBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + return strings.TrimPrefix(info.Main.Version, "v") + } + return "dev" +} + func main() { if len(os.Args) < 2 { fmt.Fprint(os.Stderr, usage) @@ -46,6 +64,8 @@ func main() { err = cmdReplay(os.Args[2:]) case "test": err = cmdTest(os.Args[2:]) + case "version", "--version": + fmt.Printf("yskill %s %s/%s\n", runtimeVersion(), runtime.GOOS, runtime.GOARCH) case "help", "-h", "--help": fmt.Print(usage) default: @@ -258,84 +278,12 @@ func compact(raw json.RawMessage) string { func cmdInit(args []string) error { fs := flag.NewFlagSet("init", flag.ExitOnError) sdkPath := fs.String("sdk", "", "filesystem path to the yield module (written as a go.mod replace directive)") - if err := fs.Parse(args); err != nil { + language := fs.String("language", defaultLanguage(), "workflow language: typescript, python, go, or rust") + if err := parseOnePositional(fs, args); err != nil { return err } if fs.NArg() != 1 { return fmt.Errorf("init takes exactly one directory") } - dir := fs.Arg(0) - name := filepath.Base(dir) - if err := os.MkdirAll(filepath.Join(dir, "fixtures"), 0o755); err != nil { - return err - } - writeIfAbsent := func(rel, content string) error { - path := filepath.Join(dir, rel) - if _, err := os.Stat(path); err == nil { - fmt.Printf("init: %s exists, preserved\n", rel) - return nil - } - return os.WriteFile(path, []byte(content), 0o644) - } - if err := writeIfAbsent("SKILL.md", fmt.Sprintf(skillMD, name)); err != nil { - return err - } - if err := writeIfAbsent("main.go", mainGo); err != nil { - return err - } - if err := writeIfAbsent("fixtures/responses.json", "{\n \"confirm-start\": {\"value\": \"yes\"}\n}\n"); err != nil { - return err - } - gomod := fmt.Sprintf("module %s\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v0.0.0\n", name) - if *sdkPath != "" { - gomod += fmt.Sprintf("\nreplace github.com/operatorstack/yield => %s\n", *sdkPath) - } else { - gomod += "\n// Point this at your yield checkout:\n// replace github.com/operatorstack/yield => ../path/to/yield\n" - } - if err := writeIfAbsent("go.mod", gomod); err != nil { - return err - } - fmt.Printf("init: skill %q scaffolded in %s\n", name, dir) - return nil + return scaffoldSkill(fs.Arg(0), *language, *sdkPath) } - -const skillMD = `--- -name: %s -description: TODO — one line on what this skill does. ---- - -Run: - - yskill run . - -Follow each returned operation exactly. - -- ` + "`ask_user`" + `: ask the user using the host's normal interface. -- ` + "`agent_task`" + `: perform the task and return schema-valid JSON. -- ` + "`run_command`" + `: yskill executes it itself; you will not see this kind. - -Resume the run after each operation: - - yskill resume --response response.json - -Do not skip an operation or invent its response. -` - -const mainGo = `package main - -import ( - "github.com/operatorstack/yield/sdk/yield" -) - -func main() { - yield.Main(func(ctx *yield.Context) (yield.Outcome, error) { - answer := ctx.AskUser("confirm-start", "Ready to start?") - if answer != "yes" { - return yield.Outcome{}, ctx.Refused("user declined to start") - } - tests := ctx.RunCommand("run-tests", "true", 60) - ctx.Require(tests.ExitCode == 0, "the test command passes", tests) - return ctx.Complete(map[string]string{"status": "ok"}) - }) -} -` diff --git a/cmd/yskill/main_test.go b/cmd/yskill/main_test.go index ace1a0e..3eeb41d 100644 --- a/cmd/yskill/main_test.go +++ b/cmd/yskill/main_test.go @@ -2,9 +2,29 @@ package main import ( "flag" + "os" + "path/filepath" + "runtime/debug" + "strings" "testing" ) +func TestRuntimeVersionUsesGoModuleVersion(t *testing.T) { + previousVersion := version + previousReadBuildInfo := readBuildInfo + version = "dev" + readBuildInfo = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}, true + } + t.Cleanup(func() { + version = previousVersion + readBuildInfo = previousReadBuildInfo + }) + if got := runtimeVersion(); got != "1.2.3" { + t.Fatalf("runtimeVersion = %q, want 1.2.3", got) + } +} + func TestParseOnePositionalAllowsDocumentedFlagOrder(t *testing.T) { fs := flag.NewFlagSet("resume", flag.ContinueOnError) response := fs.String("response", "", "response file") @@ -30,3 +50,101 @@ func TestParseOnePositionalKeepsFlagFirstOrder(t *testing.T) { t.Fatalf("args = %q response = %q", fs.Args(), *response) } } + +func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { + previousVersion := version + version = "0.1.9" + t.Cleanup(func() { version = previousVersion }) + + tests := []struct { + language string + files []string + command string + pin string + }{ + {"typescript", []string{"main.ts", "package.json", "skill.json"}, "npm exec -- yskill run .", `"@operatorstack/yield": "0.1.9"`}, + {"python", []string{"main.py", "requirements.txt", "skill.json"}, "python -m yieldskill run .", "yieldskill==0.1.9"}, + {"go", []string{"main.go", "go.mod"}, "yskill run .", "github.com/operatorstack/yield v0.1.9"}, + {"rust", []string{"src/main.rs", "Cargo.toml", ".cargo/config.toml", "skill.json"}, "yskill run .", `version = "=0.1.9"`}, + } + for _, tt := range tests { + t.Run(tt.language, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "My Skill") + if err := scaffoldSkill(dir, tt.language, ""); err != nil { + t.Fatal(err) + } + for _, rel := range append(tt.files, "SKILL.md", "fixtures/responses.json") { + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))); err != nil { + t.Fatalf("%s: %v", rel, err) + } + } + skill := readTestFile(t, filepath.Join(dir, "SKILL.md")) + if !strings.Contains(skill, tt.command) { + t.Fatalf("SKILL.md does not contain %q:\n%s", tt.command, skill) + } + var manifest string + switch tt.language { + case "typescript": + manifest = readTestFile(t, filepath.Join(dir, "package.json")) + case "python": + manifest = readTestFile(t, filepath.Join(dir, "requirements.txt")) + case "go": + manifest = readTestFile(t, filepath.Join(dir, "go.mod")) + case "rust": + manifest = readTestFile(t, filepath.Join(dir, "Cargo.toml")) + } + if !strings.Contains(manifest, tt.pin) { + t.Fatalf("manifest does not contain %q:\n%s", tt.pin, manifest) + } + }) + } +} + +func TestPythonScaffoldUsesInvokingInterpreter(t *testing.T) { + previousVersion := version + version = "0.1.9" + t.Cleanup(func() { version = previousVersion }) + t.Setenv("YIELD_PYTHON", "/opt/yield/.venv/bin/python") + dir := filepath.Join(t.TempDir(), "python-skill") + if err := scaffoldSkill(dir, "python", ""); err != nil { + t.Fatal(err) + } + skill := readTestFile(t, filepath.Join(dir, "skill.json")) + if !strings.Contains(skill, `"/opt/yield/.venv/bin/python"`) { + t.Fatalf("skill.json does not use the invoking interpreter: %s", skill) + } +} + +func TestScaffoldSkillPreservesExistingSkillAndRejectsUnknownLanguage(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("keep me\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := scaffoldSkill(dir, "typescript", ""); err != nil { + t.Fatal(err) + } + if got := readTestFile(t, filepath.Join(dir, "SKILL.md")); got != "keep me\n" { + t.Fatalf("existing SKILL.md changed: %q", got) + } + if err := scaffoldSkill(t.TempDir(), "java", ""); err == nil || !strings.Contains(err.Error(), "unsupported language") { + t.Fatalf("unknown language error = %v", err) + } +} + +func TestPackageVersionFallsBackForDevelopmentBuilds(t *testing.T) { + previousVersion := version + version = "dev" + t.Cleanup(func() { version = previousVersion }) + if got := packageVersion(); got != "0.0.0" { + t.Fatalf("packageVersion = %q", got) + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/cmd/yskill/scaffold.go b/cmd/yskill/scaffold.go new file mode 100644 index 0000000..58bb345 --- /dev/null +++ b/cmd/yskill/scaffold.go @@ -0,0 +1,190 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$`) +var unsafePackageCharacter = regexp.MustCompile(`[^a-z0-9_-]+`) + +func defaultLanguage() string { + if language := strings.TrimSpace(os.Getenv("YIELD_LANGUAGE")); language != "" { + return language + } + return "go" +} + +func packageVersion() string { + v := runtimeVersion() + if releaseVersionPattern.MatchString(v) { + return v + } + return "0.0.0" +} + +func scaffoldSkill(dir, language, sdkPath string) error { + language = strings.ToLower(strings.TrimSpace(language)) + if language != "typescript" && language != "python" && language != "go" && language != "rust" { + return fmt.Errorf("unsupported language %q; choose typescript, python, go, or rust", language) + } + if sdkPath != "" && language != "go" { + return fmt.Errorf("--sdk is only valid with --language go") + } + name := safePackageName(filepath.Base(filepath.Clean(dir))) + writeIfAbsent := func(rel, content string) error { + path := filepath.Join(dir, filepath.FromSlash(rel)) + if _, err := os.Stat(path); err == nil { + fmt.Printf("init: %s exists, preserved\n", rel) + return nil + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0o644) + } + + launcher := map[string]string{ + "typescript": "npm exec -- yskill", + "python": "python -m yieldskill", + "go": "yskill", + "rust": "yskill", + }[language] + files := scaffoldFiles(name, language, sdkPath) + files["SKILL.md"] = fmt.Sprintf(skillMD, name, launcher, launcher) + files["fixtures/responses.json"] = "{\n \"confirm-start\": {\"value\": \"yes\"}\n}\n" + keys := make([]string, 0, len(files)) + for key := range files { + keys = append(keys, key) + } + sort.Strings(keys) + for _, rel := range keys { + if err := writeIfAbsent(rel, files[rel]); err != nil { + return err + } + } + fmt.Printf("init: %s skill %q scaffolded in %s\n", language, name, dir) + return nil +} + +func scaffoldFiles(name, language, sdkPath string) map[string]string { + v := packageVersion() + switch language { + case "typescript": + return map[string]string{ + "main.ts": mainTypeScript, + "package.json": fmt.Sprintf(`{ + "private": true, + "type": "module", + "dependencies": { "@operatorstack/yield": "%s" } +} +`, v), + "skill.json": "{\"run\":[\"node\",\"main.ts\"]}\n", + } + case "python": + python := strings.TrimSpace(os.Getenv("YIELD_PYTHON")) + if python == "" { + python = "python" + } + return map[string]string{ + "main.py": mainPython, + "requirements.txt": fmt.Sprintf("--index-url https://get.operatorstack.systems/pip/simple/\nyieldskill==%s\n", v), + "skill.json": fmt.Sprintf("{\"run\":[%q,\"main.py\"]}\n", python), + } + case "rust": + return map[string]string{ + ".cargo/config.toml": "[registries.operatorstack]\nindex = \"sparse+https://get.operatorstack.systems/cargo/index/\"\n", + "Cargo.toml": fmt.Sprintf("[package]\nname = %q\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nyieldskill = { version = \"=%s\", registry = \"operatorstack\" }\nserde_json = \"1\"\n", name, v), + "src/main.rs": mainRust, + "skill.json": "{\"run\":[\"cargo\",\"run\",\"--quiet\"]}\n", + } + default: + gomod := fmt.Sprintf("module %s\n\ngo 1.26.5\n\nrequire github.com/operatorstack/yield v%s\n", name, v) + if sdkPath != "" { + gomod += fmt.Sprintf("\nreplace github.com/operatorstack/yield => %s\n", sdkPath) + } + return map[string]string{"main.go": mainGo, "go.mod": gomod} + } +} + +func safePackageName(value string) string { + value = unsafePackageCharacter.ReplaceAllString(strings.ToLower(value), "-") + value = strings.Trim(value, "-_") + if value == "" { + return "yield-skill" + } + return value +} + +const skillMD = `--- +name: %s +description: TODO — one line on what this skill does. +--- + +Run: + + %s run . + +Follow each returned operation exactly. Resume after each response: + + %s resume --response response.json --skill . + +Do not skip an operation or invent a response. +` + +const mainGo = `package main + +import "github.com/operatorstack/yield/sdk/yield" + +func main() { + yield.Main(func(ctx *yield.Context) (yield.Outcome, error) { + answer := ctx.AskUser("confirm-start", "Ready to start?") + if answer != "yes" { + return yield.Outcome{}, ctx.Refused("user declined to start") + } + return ctx.Complete(map[string]string{"status": "ready"}) + }) +} +` + +const mainTypeScript = `import { defineSkill } from "@operatorstack/yield" + +defineSkill((ctx) => { + const answer = ctx.askUser("confirm-start", "Ready to start?", [ + { value: "yes" }, { value: "no" } + ]) + if (answer !== "yes") ctx.refused("user declined to start") + return { status: "ready" } +}) +` + +const mainPython = `from yieldskill import define_skill + +def program(ctx): + answer = ctx.ask_user("confirm-start", "Ready to start?", options=[{"value": "yes"}, {"value": "no"}]) + if answer != "yes": + ctx.refused("user declined to start") + return {"status": "ready"} + +define_skill(program) +` + +const mainRust = `use serde_json::json; +use yieldskill::{define_skill, Context, SkillResult}; + +fn program(ctx: &mut Context) -> SkillResult { + let answer = ctx.ask_user("confirm-start", "Ready to start?", &[("yes", "Yes"), ("no", "No")]); + if answer != "yes" { + return Err(ctx.refused("user declined to start")); + } + Ok(json!({"status": "ready"})) +} + +fn main() { define_skill(program); } +` diff --git a/docs/quickstart.md b/docs/quickstart.md index 1ebfb70..890d1a4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -8,20 +8,20 @@ program: 3. stop unless the result has zero critical findings; 4. save the structured review. -You need Go 1.24 or newer and Node.js 24 or newer. +You need Node.js 24 or newer. -## 1. Install the supervisor and SDK +## 1. Install Yield ```bash -go install github.com/operatorstack/yield/cmd/yskill@latest - mkdir review-skill cd review-skill -mkdir -p fixtures +npm init -y +npm install @operatorstack/yield \ + --registry=https://get.operatorstack.systems/npm/ +npm exec -- yskill init . --language typescript ``` -Make sure the Go bin directory is on your `PATH`. `go env GOPATH` prints its -parent directory; the binary normally lives in `$(go env GOPATH)/bin`. +The package includes the TypeScript SDK and its matching Yield runtime. ## 2. Create the package @@ -37,13 +37,6 @@ Create `package.json`: } ``` -Install the SDK: - -```bash -npm install @operatorstack/yield \ - --registry=https://get.operatorstack.systems/npm/ -``` - ## 3. Add the workflow Create `main.ts`: @@ -95,10 +88,10 @@ name: review description: Check and review the current branch before it is shipped. --- -Run `yskill run .` and follow each returned operation exactly. +Run `npm exec -- yskill run .` and follow each returned operation exactly. For `agent_task`, perform the task and return schema-valid JSON. Resume with -`yskill resume --response response.json --skill .`. +`npm exec -- yskill resume --response response.json --skill .`. Do not skip an operation or invent a response. The program owns the order and the finish rule. @@ -123,7 +116,7 @@ Create `fixtures/responses.json`: Run: ```bash -yskill test . +npm exec -- yskill test . ``` `run_command` operations execute for real. The fixture supplies only the model diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 32d55ae..df4a004 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,15 +1,25 @@ # CLI reference -`yskill` is the supervisor. It owns run logs, validates responses, executes -commands, and starts the skill program. +`yskill` runs workflows. It owns run logs, validates responses, executes +commands, and starts the skill program. It comes with each language package. ## `init` ```bash -yskill init +yskill init --language typescript|python|go|rust ``` Scaffolds a new skill or adds a Yield program beside an existing prose skill. +Generated dependencies are pinned to the installed `yskill` version. + +## `version` + +```bash +yskill --version +yskill version +``` + +Prints the runtime version and platform. ## `run` diff --git a/evals/results/latest.json b/evals/results/latest.json index 2c097da..a4948a9 100644 --- a/evals/results/latest.json +++ b/evals/results/latest.json @@ -1,8 +1,8 @@ { "schema_version": 2, "methodology_version": "1.0", - "generated_at": "2026-08-01T18:42:40.588Z", - "source_digest": "864ef22f4ea4d990b0576a69ba1208f58964c4aec4791373569053f2f7626edb", + "generated_at": "2026-08-01T21:57:38.190Z", + "source_digest": "8fba9d25376a73e2a2230d8d10cfe8364a9b2db73e61b6974fa39928956ea9ef", "status": "passed", "workflow_conformance": { "passed": 40, diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..bcaf24c --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,9 @@ +# Release packaging + +`assemble.mjs` turns six immutable `yskill` binaries into the language packages users install. It fails if any target is missing and writes a SHA-256 manifest. + +```sh +node packaging/assemble.mjs --version 0.1.9 --binaries dist/bin --output dist/packages +``` + +The public TypeScript, Python, and Rust packages carry exactly one matching runtime through platform-specific package artifacts. They do not download a runtime or search `PATH` when invoked. diff --git a/packaging/assemble.mjs b/packaging/assemble.mjs new file mode 100644 index 0000000..0a1248b --- /dev/null +++ b/packaging/assemble.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import { chmod, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import process from "node:process"; +import { binaryName, npmPackage, rustPackage, targets } from "./targets.mjs"; + +const root = resolve(import.meta.dirname, ".."); + +function parseArgs(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) values[argv[index]?.replace(/^--/, "")] = argv[index + 1]; + if (!/^\d+\.\d+\.\d+$/.test(values.version ?? "")) throw new Error("--version must be semver without a leading v"); + if (!values.binaries || !values.output) throw new Error("--binaries and --output are required"); + return { version: values.version, binaries: resolve(values.binaries), output: resolve(values.output) }; +} + +async function json(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function sha256(path) { + return createHash("sha256").update(await readFile(path)).digest("hex"); +} + +async function copyBinary(source, destination, executable = true) { + await mkdir(resolve(destination, ".."), { recursive: true }); + await cp(source, destination); + if (executable && !destination.endsWith(".exe")) await chmod(destination, 0o755); +} + +async function validateBinaries(directory) { + const records = []; + for (const target of targets) { + const path = join(directory, binaryName(target)); + const details = await stat(path).catch(() => null); + if (!details?.isFile() || details.size === 0) throw new Error(`missing runtime ${path}`); + records.push({ target: target.id, file: basename(path), bytes: details.size, sha256: await sha256(path) }); + } + return records; +} + +async function assembleNpm({ version, binaries, output }) { + const npm = join(output, "npm"); + const main = join(npm, "yield"); + await cp(join(root, "sdk/typescript"), main, { recursive: true, filter: (source) => !source.includes("node_modules") && !source.includes("/dist") }); + const packageJson = await json(join(main, "package.json")); + packageJson.version = version; + packageJson.optionalDependencies = Object.fromEntries(targets.map((target) => [npmPackage(target), version])); + await writeFile(join(main, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + + for (const target of targets) { + const directory = join(npm, target.id); + const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; + await mkdir(directory, { recursive: true }); + await copyBinary(join(binaries, binaryName(target)), join(directory, runtime)); + await writeFile(join(directory, "package.json"), `${JSON.stringify({ + name: npmPackage(target), version, description: `Yield runtime for ${target.id}`, + license: "MIT", os: [target.nodeOs], cpu: [target.nodeCpu], main: `./${runtime}`, + files: [runtime], repository: { type: "git", url: "https://github.com/operatorstack/yield" }, + }, null, 2)}\n`); + } +} + +async function assemblePython({ version, binaries, output }) { + const python = join(output, "python"); + for (const target of targets) { + const directory = join(python, target.id); + await cp(join(root, "sdk/python"), directory, { recursive: true, filter: (source) => !source.includes("__pycache__") && !source.includes("/dist") && !source.includes("/build") }); + const pyproject = (await readFile(join(directory, "pyproject.toml"), "utf8")).replace(/^version = ".*"/m, `version = "${version}"`); + await writeFile(join(directory, "pyproject.toml"), pyproject); + const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; + await copyBinary(join(binaries, binaryName(target)), join(directory, "yieldskill/_runtime", runtime)); + await writeFile(join(directory, "setup.py"), `from wheel.bdist_wheel import bdist_wheel\nfrom setuptools import setup\n\nclass PlatformWheel(bdist_wheel):\n def finalize_options(self):\n super().finalize_options()\n self.root_is_pure = False\n def get_tag(self):\n return ("py3", "none", "${target.pythonTag}")\n\nsetup(cmdclass={"bdist_wheel": PlatformWheel})\n`); + } +} + +function rustDependency(target, version) { + return `[target.'cfg(all(target_os = "${target.rustOs}", target_arch = "${target.rustArch}"))'.dependencies]\n${rustPackage(target)} = { version = "=${version}", registry = "operatorstack" }\n`; +} + +async function assembleRust({ version, binaries, output }, records) { + const rust = join(output, "rust"); + const runtimeByTarget = new Map(records.map((record) => [record.target, record])); + for (const target of targets) { + const name = rustPackage(target); + const directory = join(rust, "runtime", target.id); + const runtime = target.goos === "windows" ? "yskill.exe" : "yskill"; + await mkdir(join(directory, "src"), { recursive: true }); + await copyBinary(join(binaries, binaryName(target)), join(directory, "runtime", runtime), false); + await writeFile(join(directory, "Cargo.toml"), `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\nlicense = "MIT"\ndescription = "Internal Yield runtime for ${target.id}"\nrepository = "https://github.com/operatorstack/yield"\ninclude = ["src/lib.rs", "runtime/${runtime}"]\n\n[lib]\npath = "src/lib.rs"\n`); + await writeFile(join(directory, "src/lib.rs"), `pub const BYTES: &[u8] = include_bytes!("../runtime/${runtime}");\npub const SHA256: &str = "${runtimeByTarget.get(target.id).sha256}";\n`); + } + + const main = join(rust, "yieldskill"); + await cp(join(root, "sdk/rust"), main, { recursive: true, filter: (source) => !source.includes("/target") }); + let cargo = (await readFile(join(main, "Cargo.toml"), "utf8")).replace(/^version = ".*"/m, `version = "${version}"`); + cargo += `\n[[bin]]\nname = "yskill"\npath = "src/bin/yskill.rs"\n\n${targets.map((target) => rustDependency(target, version)).join("\n")}`; + await writeFile(join(main, "Cargo.toml"), cargo); + await mkdir(join(main, "src/bin"), { recursive: true }); + await cp(join(root, "packaging/rust-launcher.rs"), join(main, "src/bin/yskill.rs")); +} + +export async function assemble(options) { + await rm(options.output, { recursive: true, force: true }); + await mkdir(options.output, { recursive: true }); + const records = await validateBinaries(options.binaries); + await Promise.all([assembleNpm(options), assemblePython(options), assembleRust(options, records)]); + await writeFile(join(options.output, "SHA256SUMS.json"), `${JSON.stringify({ version: options.version, artifacts: records }, null, 2)}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + assemble(parseArgs(process.argv.slice(2))).catch((error) => { console.error(`assemble: ${error.message}`); process.exit(1); }); +} diff --git a/packaging/cargo-index.mjs b/packaging/cargo-index.mjs new file mode 100644 index 0000000..d2f7dcf --- /dev/null +++ b/packaging/cargo-index.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import process from "node:process"; +import { resolve } from "node:path"; +import { rustPackage, targets } from "./targets.mjs"; + +const cratesIo = "https://github.com/rust-lang/crates.io-index"; +const operatorstack = "sparse+https://get.operatorstack.systems/cargo/index/"; + +function dependency(name, req, { features = [], target = null, registry = cratesIo } = {}) { + return { name, req, features, optional: false, default_features: true, target, kind: "normal", registry }; +} + +export function record(name, version, checksum) { + const deps = name === "yieldskill" ? [ + dependency("hex", "^0.4"), + dependency("serde", "^1", { features: ["derive"] }), + dependency("serde_json", "^1"), + dependency("sha2", "^0.10"), + ...targets.map((target) => dependency(rustPackage(target), `=${version}`, { + target: `cfg(all(target_os = "${target.rustOs}", target_arch = "${target.rustArch}"))`, + registry: operatorstack, + })), + ] : []; + return { name, vers: version, deps, cksum: checksum, features: {}, yanked: false, links: null }; +} + +function args(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) values[argv[index]?.replace(/^--/, "")] = argv[index + 1]; + if (!values.name || !values.version || !/^[a-f0-9]{64}$/.test(values.checksum ?? "") || !values.output) { + throw new Error("--name, --version, --checksum, and --output are required"); + } + return values; +} + +async function main() { + const values = args(process.argv.slice(2)); + const output = resolve(values.output); + const current = await readFile(output, "utf8").catch(() => ""); + const lines = current.split("\n").filter(Boolean).filter((line) => JSON.parse(line).vers !== values.version); + lines.push(JSON.stringify(record(values.name, values.version, values.checksum))); + await writeFile(output, `${lines.join("\n")}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + main().catch((error) => { console.error(`cargo-index: ${error.message}`); process.exit(1); }); +} diff --git a/packaging/cargo-index.test.mjs b/packaging/cargo-index.test.mjs new file mode 100644 index 0000000..b41f1fb --- /dev/null +++ b/packaging/cargo-index.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { record } from "./cargo-index.mjs"; + +test("runtime crate index records have no dependencies", () => { + assert.deepEqual(record("yieldskill-runtime-linux-amd64", "1.2.3", "a".repeat(64)).deps, []); +}); + +test("public crate pins all six runtime packages to the same version", () => { + const result = record("yieldskill", "1.2.3", "b".repeat(64)); + const runtimes = result.deps.filter((dep) => dep.name.startsWith("yieldskill-runtime-")); + assert.equal(runtimes.length, 6); + assert.ok(runtimes.every((dep) => dep.req === "=1.2.3")); + assert.ok(runtimes.every((dep) => dep.registry.includes("get.operatorstack.systems"))); + assert.equal(new Set(runtimes.map((dep) => dep.target)).size, 6); +}); diff --git a/packaging/rust-launcher.rs b/packaging/rust-launcher.rs new file mode 100644 index 0000000..0118136 --- /dev/null +++ b/packaging/rust-launcher.rs @@ -0,0 +1,62 @@ +use sha2::{Digest, Sha256}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(all(target_os = "macos", target_arch = "x86_64"))] use yieldskill_runtime_darwin_amd64 as runtime; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] use yieldskill_runtime_darwin_arm64 as runtime; +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] use yieldskill_runtime_linux_amd64 as runtime; +#[cfg(all(target_os = "linux", target_arch = "aarch64"))] use yieldskill_runtime_linux_arm64 as runtime; +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] use yieldskill_runtime_windows_amd64 as runtime; +#[cfg(all(target_os = "windows", target_arch = "aarch64"))] use yieldskill_runtime_windows_arm64 as runtime; + +fn runtime_path() -> Result { + let root = std::env::var_os("YIELD_RUNTIME_CACHE").map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("yieldskill")); + let name = if cfg!(windows) { "yskill.exe" } else { "yskill" }; + let directory = root.join(env!("CARGO_PKG_VERSION")).join(std::env::consts::ARCH); + let path = directory.join(name); + if verified(&path) { return Ok(path); } + fs::create_dir_all(&directory).map_err(|error| format!("could not create runtime cache: {error}"))?; + let temporary = directory.join(format!(".{name}.{}.tmp", std::process::id())); + let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary) + .map_err(|error| format!("could not stage packaged runtime: {error}"))?; + file.write_all(runtime::BYTES).and_then(|_| file.sync_all()) + .map_err(|error| format!("could not write packaged runtime: {error}"))?; + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o755)) + .map_err(|error| format!("could not mark packaged runtime executable: {error}"))?; + } + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + if !verified(&path) { return Err(format!("could not install packaged runtime: {error}")); } + } + if !verified(&path) { return Err("packaged runtime checksum mismatch".to_string()); } + Ok(path) +} + +fn verified(path: &Path) -> bool { + fs::read(path).map(|bytes| hex::encode(Sha256::digest(bytes)) == runtime::SHA256).unwrap_or(false) +} + +fn main() { + let path = match runtime_path() { Ok(path) => path, Err(error) => { eprintln!("yskill: {error}"); std::process::exit(1); } }; + let args: Vec<_> = std::env::args_os().skip(1).collect(); + let mut command = Command::new(path); + command.args(args); + if std::env::var_os("YIELD_LANGUAGE").is_none() { command.env("YIELD_LANGUAGE", "rust"); } + #[cfg(unix)] { + use std::os::unix::process::CommandExt; + let error = command.exec(); + eprintln!("yskill: could not start packaged runtime: {error}"); + std::process::exit(1) + } + #[cfg(windows)] { + match command.status() { + Ok(status) => std::process::exit(status.code().unwrap_or(1)), + Err(error) => { eprintln!("yskill: could not start packaged runtime: {error}"); std::process::exit(1); } + } + } +} diff --git a/packaging/targets.mjs b/packaging/targets.mjs new file mode 100644 index 0000000..d4104ae --- /dev/null +++ b/packaging/targets.mjs @@ -0,0 +1,20 @@ +export const targets = [ + { id: "darwin-amd64", goos: "darwin", goarch: "amd64", nodeOs: "darwin", nodeCpu: "x64", pythonTag: "macosx_11_0_x86_64", rustOs: "macos", rustArch: "x86_64" }, + { id: "darwin-arm64", goos: "darwin", goarch: "arm64", nodeOs: "darwin", nodeCpu: "arm64", pythonTag: "macosx_11_0_arm64", rustOs: "macos", rustArch: "aarch64" }, + { id: "linux-amd64", goos: "linux", goarch: "amd64", nodeOs: "linux", nodeCpu: "x64", pythonTag: "manylinux_2_17_x86_64", rustOs: "linux", rustArch: "x86_64" }, + { id: "linux-arm64", goos: "linux", goarch: "arm64", nodeOs: "linux", nodeCpu: "arm64", pythonTag: "manylinux_2_17_aarch64", rustOs: "linux", rustArch: "aarch64" }, + { id: "windows-amd64", goos: "windows", goarch: "amd64", nodeOs: "win32", nodeCpu: "x64", pythonTag: "win_amd64", rustOs: "windows", rustArch: "x86_64" }, + { id: "windows-arm64", goos: "windows", goarch: "arm64", nodeOs: "win32", nodeCpu: "arm64", pythonTag: "win_arm64", rustOs: "windows", rustArch: "aarch64" }, +]; + +export function binaryName(target) { + return `yskill-${target.goos}-${target.goarch}${target.goos === "windows" ? ".exe" : ""}`; +} + +export function npmPackage(target) { + return `@operatorstack/yield-${target.id}`; +} + +export function rustPackage(target) { + return `yieldskill-runtime-${target.id}`; +} diff --git a/release-notes/2026-08-01-one-package-per-language.md b/release-notes/2026-08-01-one-package-per-language.md new file mode 100644 index 0000000..7fd137d --- /dev/null +++ b/release-notes/2026-08-01-one-package-per-language.md @@ -0,0 +1,11 @@ +# One package per language + +- Install one TypeScript, Python, Go, or Rust package to get both the SDK and + the matching `yskill` runtime. +- Add `yskill --version`, `yskill version`, and language-aware `yskill init` + scaffolds pinned to the installed version. +- Carry immutable Go runtimes for macOS, Linux, and Windows on amd64 and arm64. +- Fail clearly on unsupported or incomplete installations. The wrappers never + download another runtime or search `PATH`. +- Verify the package launchers, runtime checksums, and shared IR behavior before + publishing the public packages. diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 79fe6a1..6f10670 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -9,9 +9,15 @@ requires-python = ">=3.10" [project.urls] Repository = "https://github.com/operatorstack/yield" +[project.scripts] +yskill = "yieldskill._cli:main" + [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=68", "wheel>=0.42"] build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["yieldskill"] + +[tool.setuptools.package-data] +yieldskill = ["_runtime/*"] diff --git a/sdk/python/test_cli.py b/sdk/python/test_cli.py new file mode 100644 index 0000000..e414e76 --- /dev/null +++ b/sdk/python/test_cli.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from yieldskill import _cli + + +class CliTest(unittest.TestCase): + def test_missing_runtime_fails_without_path_fallback(self) -> None: + with mock.patch.object(_cli.Path, "is_file", return_value=False): + with self.assertRaisesRegex(FileNotFoundError, "packaged Yield runtime is missing"): + _cli.runtime_path("linux") + + def test_unix_replaces_process_and_forwards_arguments(self) -> None: + with tempfile.TemporaryDirectory() as directory: + binary = Path(directory) / "yskill" + binary.touch() + with mock.patch.object(_cli, "runtime_path", return_value=binary): + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.object(os, "execve", side_effect=RuntimeError("exec")) as execute: + with self.assertRaisesRegex(RuntimeError, "exec"): + _cli.run(["test", "skill"], "linux") + call = execute.call_args + self.assertEqual(call.args[:2], (str(binary), [str(binary), "test", "skill"])) + self.assertEqual(call.args[2]["YIELD_LANGUAGE"], "python") + self.assertEqual(call.args[2]["YIELD_PYTHON"], os.sys.executable) + + def test_windows_preserves_exit_code_and_arguments(self) -> None: + with tempfile.TemporaryDirectory() as directory: + binary = Path(directory) / "yskill.exe" + binary.touch() + completed = mock.Mock(returncode=23) + with mock.patch.object(_cli, "runtime_path", return_value=binary): + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.object(_cli.subprocess, "run", return_value=completed) as execute: + self.assertEqual(_cli.run(["--version"], "win32"), 23) + call = execute.call_args + self.assertEqual(call.args[0], [str(binary), "--version"]) + self.assertFalse(call.kwargs["check"]) + self.assertEqual(call.kwargs["env"]["YIELD_LANGUAGE"], "python") + self.assertEqual(call.kwargs["env"]["YIELD_PYTHON"], os.sys.executable) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/python/yieldskill/__main__.py b/sdk/python/yieldskill/__main__.py new file mode 100644 index 0000000..84a45d4 --- /dev/null +++ b/sdk/python/yieldskill/__main__.py @@ -0,0 +1,3 @@ +from ._cli import main + +main() diff --git a/sdk/python/yieldskill/_cli.py b/sdk/python/yieldskill/_cli.py new file mode 100644 index 0000000..ba81ca9 --- /dev/null +++ b/sdk/python/yieldskill/_cli.py @@ -0,0 +1,45 @@ +"""Launch the version-locked Yield runtime carried by this wheel.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import NoReturn, Sequence + + +def runtime_path(platform: str | None = None) -> Path: + """Return the wheel-local runtime or fail without checking PATH.""" + selected = platform or sys.platform + name = "yskill.exe" if selected == "win32" else "yskill" + path = Path(__file__).with_name("_runtime") / name + if not path.is_file(): + raise FileNotFoundError( + f"the packaged Yield runtime is missing at {path}; reinstall yieldskill" + ) + return path + + +def run(argv: Sequence[str] | None = None, platform: str | None = None) -> int | NoReturn: + """Replace this process on Unix; preserve the child exit code on Windows.""" + args = [str(runtime_path(platform)), *(argv if argv is not None else sys.argv[1:])] + selected = platform or sys.platform + environment = { + **os.environ, + "YIELD_LANGUAGE": os.environ.get("YIELD_LANGUAGE", "python"), + "YIELD_PYTHON": os.environ.get("YIELD_PYTHON", sys.executable), + } + if selected == "win32": + return subprocess.run(args, check=False, env=environment).returncode + os.execve(args[0], args, environment) + raise AssertionError("os.execve returned") + + +def main() -> NoReturn: + try: + code = run() + except FileNotFoundError as error: + print(f"yskill: {error}", file=sys.stderr) + raise SystemExit(1) from error + raise SystemExit(code) diff --git a/sdk/typescript/bin/runtime.mjs b/sdk/typescript/bin/runtime.mjs new file mode 100644 index 0000000..255b3cc --- /dev/null +++ b/sdk/typescript/bin/runtime.mjs @@ -0,0 +1,34 @@ +import { createRequire } from "node:module"; + +const packages = new Map([ + ["darwin:x64", "@operatorstack/yield-darwin-amd64"], + ["darwin:arm64", "@operatorstack/yield-darwin-arm64"], + ["linux:x64", "@operatorstack/yield-linux-amd64"], + ["linux:arm64", "@operatorstack/yield-linux-arm64"], + ["win32:x64", "@operatorstack/yield-windows-amd64"], + ["win32:arm64", "@operatorstack/yield-windows-arm64"], +]); + +export function runtimePackage(platform = process.platform, arch = process.arch) { + const name = packages.get(`${platform}:${arch}`); + if (!name) { + throw new Error(`Yield does not provide a runtime for ${platform}/${arch}`); + } + return name; +} + +export function resolveRuntime({ + platform = process.platform, + arch = process.arch, + resolve = createRequire(import.meta.url).resolve, +} = {}) { + const name = runtimePackage(platform, arch); + try { + return resolve(name); + } catch (error) { + throw new Error( + `The runtime package ${name} is missing. Reinstall @operatorstack/yield for ${platform}/${arch}.`, + { cause: error }, + ); + } +} diff --git a/sdk/typescript/bin/runtime.test.mjs b/sdk/typescript/bin/runtime.test.mjs new file mode 100644 index 0000000..bcb2e77 --- /dev/null +++ b/sdk/typescript/bin/runtime.test.mjs @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveRuntime, runtimePackage } from "./runtime.mjs"; + +const cases = [ + ["darwin", "x64", "@operatorstack/yield-darwin-amd64"], + ["darwin", "arm64", "@operatorstack/yield-darwin-arm64"], + ["linux", "x64", "@operatorstack/yield-linux-amd64"], + ["linux", "arm64", "@operatorstack/yield-linux-arm64"], + ["win32", "x64", "@operatorstack/yield-windows-amd64"], + ["win32", "arm64", "@operatorstack/yield-windows-arm64"], +]; + +test("selects the exact runtime package for every supported target", () => { + for (const [platform, arch, expected] of cases) { + assert.equal(runtimePackage(platform, arch), expected); + assert.equal(resolveRuntime({ platform, arch, resolve: (name) => `/packages/${name}` }), `/packages/${expected}`); + } +}); + +test("rejects unsupported targets", () => { + assert.throws(() => runtimePackage("freebsd", "x64"), /does not provide a runtime/); +}); + +test("does not fall back when the selected package is missing", () => { + assert.throws( + () => resolveRuntime({ platform: "linux", arch: "x64", resolve: () => { throw new Error("missing"); } }), + /Reinstall @operatorstack\/yield/, + ); +}); diff --git a/sdk/typescript/bin/yskill.mjs b/sdk/typescript/bin/yskill.mjs new file mode 100644 index 0000000..b8c2988 --- /dev/null +++ b/sdk/typescript/bin/yskill.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import process from "node:process"; +import { resolveRuntime } from "./runtime.mjs"; + +let binary; +try { + binary = resolveRuntime(); +} catch (error) { + console.error(`yskill: ${error.message}`); + process.exit(1); +} + +const child = spawn(binary, process.argv.slice(2), { + stdio: "inherit", + env: { ...process.env, YIELD_LANGUAGE: process.env.YIELD_LANGUAGE ?? "typescript" }, +}); +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.killed) child.kill(signal); + }); +} +child.on("error", (error) => { + console.error(`yskill: could not start the packaged runtime: ${error.message}`); + process.exit(1); +}); +child.on("exit", (code, signal) => { + if (signal && process.platform !== "win32") { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 803f7e9..ff23b46 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -5,9 +5,13 @@ "license": "MIT", "type": "module", "files": [ + "bin", "dist", "src" ], + "bin": { + "yskill": "bin/yskill.mjs" + }, "exports": { ".": { "types": "./src/index.ts", @@ -17,6 +21,7 @@ }, "scripts": { "build": "node scripts/build.mjs", + "test": "node --test", "prepack": "npm run build" }, "engines": {